diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebbee297..691687b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,6 +236,12 @@ jobs: set -eu go test -count=1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/... go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/... + go test -count=1 \ + -run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \ + -coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \ + -coverprofile=coverage-shortcut.txt \ + -covermode=atomic \ + ./internal/app ./internal/helpers ./internal/shortcut/... base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")" rmdir "$base_worktree" @@ -257,7 +263,7 @@ jobs: env: COVERAGE_TARGET: "80" COVERAGE_ENFORCE_OVERALL: "false" - run: make coverage-gate BASE_REF="$COVERAGE_BASE_REF" + run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF" - name: Generate coverage report run: go tool cover -html=coverage.txt -o coverage.html @@ -270,6 +276,7 @@ jobs: coverage.txt coverage-base.txt coverage-policy.txt + coverage-shortcut.txt coverage.html policy: diff --git a/internal/app/usage_recording_test.go b/internal/app/usage_recording_test.go index a7e63a3b..ad5ca8b7 100644 --- a/internal/app/usage_recording_test.go +++ b/internal/app/usage_recording_test.go @@ -13,9 +13,30 @@ package app -import "testing" +import ( + "context" + "testing" -func TestCloneToolArgsDefensiveCopy(t *testing.T) { + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type crossPlatformCoverageCaller struct { + args map[string]any + dryRun bool +} + +func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) { + c.args = args + return &edition.ToolResult{}, nil +} + +func (*crossPlatformCoverageCaller) Format() string { return "json" } +func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun } +func (*crossPlatformCoverageCaller) Fields() string { return "id,name" } +func (*crossPlatformCoverageCaller) JQ() string { return ".result" } + +func TestCrossPlatformCoverageCloneToolArgsDefensiveCopy(t *testing.T) { args := map[string]any{"page": 1, "query": "keep"} cloned := cloneToolArgs(args) args["page"] = 2 @@ -29,7 +50,7 @@ func TestCloneToolArgsDefensiveCopy(t *testing.T) { } } -func TestCloneToolArgsEmpty(t *testing.T) { +func TestCrossPlatformCoverageCloneToolArgsEmpty(t *testing.T) { if got := cloneToolArgs(nil); got != nil { t.Fatalf("nil clone = %#v, want nil", got) } @@ -37,3 +58,54 @@ func TestCloneToolArgsEmpty(t *testing.T) { t.Fatalf("empty clone = %#v, want nil", got) } } + +func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + t.Setenv("DWS_USAGE_TRACKING", "1") + + inner := &crossPlatformCoverageCaller{} + caller := newRecordingToolCaller(inner) + args := map[string]any{"open_conversation_id": "cid_x", "text": "private"} + if _, err := caller.CallTool(context.Background(), "chat", "send_message", args); err != nil { + t.Fatal(err) + } + if inner.args["open_conversation_id"] != "cid_x" { + t.Fatalf("forwarded args = %#v", inner.args) + } + if caller.Format() != "json" || caller.Fields() != "id,name" || caller.JQ() != ".result" || caller.DryRun() { + t.Fatal("recording caller did not delegate output settings") + } + records, err := usage.Read() + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || records[0].Product != "chat" || records[0].Tool != "send_message" { + t.Fatalf("usage records = %#v", records) + } + if _, leaked := records[0].SampleArgs["text"]; leaked { + t.Fatal("sensitive text must not be recorded") + } + + inner.dryRun = true + if _, err := caller.CallTool(context.Background(), "chat", "send_message", args); err != nil { + t.Fatal(err) + } + records, err = usage.Read() + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("dry-run call must not be recorded: %#v", records) + } +} + +func TestCrossPlatformCoverageRootPublishesShortcutCommands(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + root := NewRootCommand(context.Background()) + for _, path := range [][]string{{"shortcut", "list"}, {"calendar", "+today"}} { + cmd, remaining, err := root.Find(path) + if err != nil || len(remaining) != 0 || cmd == nil { + t.Fatalf("root.Find(%v) = cmd=%v remaining=%v err=%v", path, cmd, remaining, err) + } + } +} diff --git a/internal/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index c76d4802..b442d3e2 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:024b37942df489fb1b6e5a7768a5da50316c402dfe2fa8d0b40dc5a03d0aeddc", + "source_hash": "sha256:84ae56ee689d80254b9f590733f7bcaa77f9c0f94ac9e0e5e6762956b1b102a2", "surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3", "coverage": { "surface_products": 22, diff --git a/internal/cli/schema_agent_metadata_audit.json b/internal/cli/schema_agent_metadata_audit.json index ba63e722..349da66f 100644 --- a/internal/cli/schema_agent_metadata_audit.json +++ b/internal/cli/schema_agent_metadata_audit.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:024b37942df489fb1b6e5a7768a5da50316c402dfe2fa8d0b40dc5a03d0aeddc", + "source_hash": "sha256:84ae56ee689d80254b9f590733f7bcaa77f9c0f94ac9e0e5e6762956b1b102a2", "surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3", "source_files": 149, "hint_files": 46, @@ -160,7 +160,7 @@ { "tool_path": "calendar participant delete", "source": "skills/mono/SKILL.md", - "line": 99, + "line": 135, "candidates": [ "calendar attendee delete", "calendar event delete", diff --git a/internal/cli/schema_catalog.json b/internal/cli/schema_catalog.json index 9511710e..65ddff6d 100644 --- a/internal/cli/schema_catalog.json +++ b/internal/cli/schema_catalog.json @@ -1,12 +1,12 @@ { "version": 1, - "source_hash": "sha256:0ed2874f8e5000b0d9156d12d45b535d98eb65aaf2f8cdaa330390846b6b8e84", + "source_hash": "sha256:0ff538ed022fb3d54947a8b849600711106005086f119ff27dc93ad116ec319e", "surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3", "catalog": { "agent_metadata": { "products_with_metadata": 22, "source": "embedded-skill-metadata", - "source_hash": "sha256:024b37942df489fb1b6e5a7768a5da50316c402dfe2fa8d0b40dc5a03d0aeddc", + "source_hash": "sha256:84ae56ee689d80254b9f590733f7bcaa77f9c0f94ac9e0e5e6762956b1b102a2", "surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3", "surface_products": 22, "surface_tools": 564, diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index f036ef71..61b08b08 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -18,6 +18,7 @@ import ( "io" "os" "reflect" + "strings" "testing" "time" @@ -44,7 +45,7 @@ func (*chatMessageSearchCaller) DryRun() bool { return false } func (*chatMessageSearchCaller) Fields() string { return "" } func (*chatMessageSearchCaller) JQ() string { return "" } -func TestChatMessageSearchUsesMCPContracts(t *testing.T) { +func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) { previousDeps := deps previousArgs := os.Args t.Cleanup(func() { @@ -127,3 +128,101 @@ func TestChatMessageSearchUsesMCPContracts(t *testing.T) { }) } } + +type chatChangedContractCaller struct { + calls []chatMessageSearchCall + resolveUsers bool +} + +func (c *chatChangedContractCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args}) + text := `{}` + if c.resolveUsers && toolName == "get_user_info_by_user_ids" { + text = `{"result":[{"userId":"123","openDingTalkId":"open-123"}]}` + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (*chatChangedContractCaller) Format() string { return "json" } +func (*chatChangedContractCaller) DryRun() bool { return false } +func (*chatChangedContractCaller) Fields() string { return "" } +func (*chatChangedContractCaller) JQ() string { return "" } + +func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller, args ...string) error { + t.Helper() + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + InitDeps(caller) + deps.Out.w = io.Discard + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestCrossPlatformCoverageChatMessageListUsesObservedGroupKeys(t *testing.T) { + caller := &chatChangedContractCaller{} + err := executeChatChangedContract(t, caller, + "message", "list", "--group", "cid-1", "--time", "2026-07-15 09:00:00", "--limit", "50") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].toolName != "list_conversation_message_v2" { + t.Fatalf("calls = %#v", caller.calls) + } + want := map[string]any{"openCid": "cid-1", "cid": "cid-1", "time": "2026-07-15 09:00:00", "forward": true, "limit": 50} + if !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want) + } +} + +func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) { + caller := &chatChangedContractCaller{} + err := executeChatChangedContract(t, caller, + "group", "audit-join-validation", + "--group", "cid-1", "--record-id", "123", "--applicant", "user-a", "--inviter", "user-b", "--status", "AuditApprove") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "audit_join_group" { + t.Fatalf("calls = %#v", caller.calls) + } + want := map[string]any{ + "openConversationId": "cid-1", "applyRecordId": int64(123), + "applicantUid": "user-a", "inviterUid": "user-b", "status": "AuditApprove", + } + if !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want) + } +} + +func TestCrossPlatformCoverageChatSendResolvesUserBeforeDispatch(t *testing.T) { + caller := &chatChangedContractCaller{resolveUsers: true} + err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || caller.calls[1].toolName != "send_personal_message" { + t.Fatalf("calls = %#v", caller.calls) + } + if got := caller.calls[1].args["receiverOpenDingTalkId"]; got != "open-123" { + t.Fatalf("receiverOpenDingTalkId = %#v, args = %#v", got, caller.calls[1].args) + } + if _, leaked := caller.calls[1].args["receiverUid"]; leaked { + t.Fatalf("resolved send must not include receiverUid: %#v", caller.calls[1].args) + } +} + +func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) { + caller := &chatChangedContractCaller{} + err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello") + if err == nil || !strings.Contains(err.Error(), "pass --open-dingtalk-id instead") { + t.Fatalf("error = %v, want explicit resolution failure", err) + } + for _, call := range caller.calls { + if call.toolName == "send_personal_message" { + t.Fatalf("unresolved user must not be dispatched: %#v", caller.calls) + } + } +} diff --git a/internal/helpers/helpers_error_test.go b/internal/helpers/helpers_error_test.go index 81c0ecc7..eb389bb9 100644 --- a/internal/helpers/helpers_error_test.go +++ b/internal/helpers/helpers_error_test.go @@ -2,7 +2,7 @@ package helpers import "testing" -func TestIsBusinessErrorRecognizesRealErrorEnvelopes(t *testing.T) { +func TestCrossPlatformCoverageIsBusinessErrorRecognizesRealErrorEnvelopes(t *testing.T) { cases := []map[string]any{ {"status": "error", "success": true, "error": map[string]any{"code": "INVALID_BASE_ID"}}, {"success": true, "errorCode": "1001"}, @@ -16,7 +16,7 @@ func TestIsBusinessErrorRecognizesRealErrorEnvelopes(t *testing.T) { } } -func TestIsBusinessErrorAllowsSuccessEnvelope(t *testing.T) { +func TestCrossPlatformCoverageIsBusinessErrorAllowsSuccessEnvelope(t *testing.T) { body := map[string]any{ "success": true, "errorCode": nil, @@ -28,7 +28,7 @@ func TestIsBusinessErrorAllowsSuccessEnvelope(t *testing.T) { } } -func TestIsBusinessErrorAllowsCodeZeroSuccessEnvelope(t *testing.T) { +func TestCrossPlatformCoverageIsBusinessErrorAllowsCodeZeroSuccessEnvelope(t *testing.T) { body := map[string]any{ "success": true, "code": "0", diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index af9a0fb1..ebfa940d 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -1482,13 +1482,10 @@ var BossCheck = shortcut.Shortcut{ {Name: "absent-min", Type: shortcut.FlagInt, Desc: "缺勤时长(分钟),异常结果时传值"}, {Name: "remark", Type: shortcut.FlagString, Desc: "备注,最长 500 字符"}, }, - Tips: []string{`dws attendance +boss-check --plan-id 123456 --time "2026-04-21 08:30"`}, - Validate: func(rt *shortcut.RuntimeContext) error { - if rt.Str("plan-id") == "" && rt.Str("result-id") == "" { - return fmt.Errorf("--plan-id 和 --result-id 至少传一个") - } - return nil + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"plan-id", "result-id"}}, }, + Tips: []string{`dws attendance +boss-check --plan-id 123456 --time "2026-04-21 08:30"`}, Execute: func(rt *shortcut.RuntimeContext) error { req := map[string]any{} if resultID := rt.Str("result-id"); resultID != "" { diff --git a/internal/shortcut/builtin/coverage_test.go b/internal/shortcut/builtin/coverage_test.go index 753869bb..1a4332ca 100644 --- a/internal/shortcut/builtin/coverage_test.go +++ b/internal/shortcut/builtin/coverage_test.go @@ -15,6 +15,7 @@ package builtin_test import ( "context" + "encoding/json" "io" "os" "path/filepath" @@ -39,13 +40,18 @@ type fakeCaller struct { tool string args map[string]any dryRun bool + payload int } func (f *fakeCaller) reset() { f.called, f.product, f.tool, f.args = false, "", "", nil } func (f *fakeCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { f.called, f.product, f.tool, f.args = true, product, tool, args - return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"ok":true}`}}}, nil + payload, err := json.Marshal(fakePayload(f.payload)) + if err != nil { + return nil, err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(payload)}}}, nil } func (f *fakeCaller) Format() string { return "json" } func (f *fakeCaller) DryRun() bool { return f.dryRun } @@ -90,37 +96,243 @@ func realToolSet(t *testing.T) map[string]bool { // confirmation on write/high-risk commands. func synthArgs(s shortcut.Shortcut) []string { args := []string{s.Service, s.Command} + skipped := map[string]bool{} + for _, constraint := range s.Constraints { + if constraint.Kind != shortcut.ConstraintExactlyOne && constraint.Kind != shortcut.ConstraintMutuallyExclusive { + continue + } + for _, name := range constraint.Flags[1:] { + skipped[name] = true + } + } for _, f := range s.Flags { + if skipped[f.Name] { + continue + } switch { case len(f.Enum) > 0: args = append(args, "--"+f.Name, f.Enum[0]) case f.Type == shortcut.FlagBool: args = append(args, "--"+f.Name) - case f.Type == shortcut.FlagInt: - args = append(args, "--"+f.Name, "1") - default: // string / string_slice - args = append(args, "--"+f.Name, "x") + default: + args = append(args, "--"+f.Name, synthFlagValue(s, f)) } } return args } +func synthFlagValue(s shortcut.Shortcut, f shortcut.Flag) string { + name := strings.ToLower(f.Name) + desc := strings.ToLower(f.Desc) + if f.Type == shortcut.FlagInt { + switch name { + case "from": + return "9" + case "to": + return "18" + default: + return "1" + } + } + if name == "pair" { + return "原文=>替换" + } + if name == "category-ids" { + return "1" + } + if name == "role-types" { + return "creator" + } + if name == "num" { + return "1" + } + if name == "type" && s.Service == "attendance" && s.Command == "+get-approve-template" { + return "leave" + } + if s.Service == "attendance" { + switch { + case s.Command == "+list-approve" && name == "types": + return "overtime" + case s.Command == "+import-schedule" && name == "schedules": + return `[{"userId":"user-1","workDate":"2026-04-01 09:00:00","classId":1,"isRest":"N"}]` + case s.Command == "+create-class" && name == "class-vo": + return `{"sections":[{"times":[{"checkType":"OnDuty","checkTime":"09:00","across":0},{"checkType":"OffDuty","checkTime":"18:00","across":0}]}]}` + case s.Command == "+create-group" && name == "group-vo": + return `{"defaultClassId":1,"workDayClassList":[0,1,1,1,1,1,0]}` + case s.Command == "+update-group" && name == "class-ids": + return `[1]` + case s.Command == "+query-report-data" && name == "columns": + return "1" + case s.Command == "+update-leave-type" && name == "visibility-rules": + return `[{"type":"dept","visible":["-1"]}]` + } + } + if strings.Contains(desc, "json") || strings.Contains(name, "config") || strings.HasSuffix(name, "-vo") { + switch name { + case "records": + return `[{"recordId":"rec-1","cells":{"name":"test"}}]` + case "schedules": + return `[{}]` + case "filters", "sub-roles", "visibility-rules": + return `[]` + default: + return `{}` + } + } + if name == "start" || name == "end" || name == "date" { + if s.Service == "attendance" { + if strings.Contains(desc, "hh:mm:ss") || strings.Contains(desc, "时分秒") { + if name == "end" { + return "2026-04-01 18:00:00" + } + return "2026-04-01 09:00:00" + } + if name == "end" { + return "2026-04-02" + } + return "2026-04-01" + } + if name == "end" { + return "2026-04-01T18:00:00+08:00" + } + return "2026-04-01T09:00:00+08:00" + } + if name == "modified-start" { + return "2026-04-01T09:00:00+08:00" + } + if name == "modified-end" { + return "2026-04-01T18:00:00+08:00" + } + if strings.Contains(name, "email") { + return "user@example.com" + } + if strings.Contains(name, "url") { + return "https://example.com/file" + } + return "x" +} + +func fakePayload(mode int) map[string]any { + item := map[string]any{ + "id": "id-1", "uuid": "uuid-1", "taskUuid": "task-1", "taskId": "task-1", + "todoTaskId": "todo-1", "recordId": "rec-1", "baseId": "base-1", "tableId": "table-1", + "fieldId": "field-1", "viewId": "view-1", "userId": "user-1", "deptId": "dept-1", + "spaceId": "space-1", "nodeId": "node-1", "dentryUuid": "file-1", "eventId": "event-1", + "roomId": "room-1", "calendarId": "cal-1", "messageId": "msg-1", "reportId": "report-1", + "processInstanceId": "process-1", "openConversationId": "cid-1", "conversationId": "cid-1", + "name": "测试名称", "title": "测试标题", "subject": "测试主题", "summary": "测试摘要", + "description": "测试描述", "text": "测试内容", "content": "测试内容", "status": "NORMAL", + "type": "text", "email": "user@example.com", "mobile": "13800000000", "priority": 1, + "startTime": int64(1775005200000), "endTime": int64(1775037600000), "createTime": int64(1775005200000), + "planFinishDate": int64(1775037600000), "fileSize": int64(128), "cells": map[string]any{"name": "test"}, + "sender": map[string]any{"userId": "user-1", "name": "测试用户"}, + "creator": map[string]any{"userId": "user-1", "name": "测试用户"}, + "owner": map[string]any{"userId": "user-1", "name": "测试用户"}, + } + list := []any{item} + container := map[string]any{ + "ok": true, "hasMore": false, "nextCursor": "", "nextToken": "", "total": 1, + } + for _, key := range []string{ + "list", "items", "records", "users", "departments", "spaces", "tables", "fields", "views", + "messages", "events", "rooms", "calendars", "participants", "attendees", "approvals", "tasks", + "todos", "mails", "reports", "minutes", "documents", "files", "nodes", "groups", "members", + } { + container[key] = list + } + for key, value := range item { + container[key] = value + } + switch mode { + case 1: + container["result"] = list + container["data"] = list + return container + case 2: + return map[string]any{"ok": true, "result": container, "data": container} + case 3: + return map[string]any{"ok": true, "result": list, "data": container, "items": list, "list": list} + default: + return map[string]any{"ok": true} + } +} + +func setOutputRecursive(cmd *cobra.Command, writer io.Writer) { + cmd.SetOut(writer) + cmd.SetErr(writer) + for _, child := range cmd.Commands() { + setOutputRecursive(child, writer) + } +} + +func newCoverageRoot() *cobra.Command { + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + root.AddCommand(builtin.Commands()...) + setOutputRecursive(root, io.Discard) + return root +} + +func silenceProcessOutput(t *testing.T) { + t.Helper() + file, err := os.Create(filepath.Join(t.TempDir(), "shortcut-output.log")) + if err != nil { + t.Fatal(err) + } + oldStdout, oldStderr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = file, file + t.Cleanup(func() { + os.Stdout, os.Stderr = oldStdout, oldStderr + _ = file.Close() + }) +} + +func TestAllShortcutConstraintsAreDiscoverableAndBound(t *testing.T) { + for _, s := range shortcut.All() { + name := s.Service + " " + s.Command + flags := make(map[string]bool, len(s.Flags)) + for _, flag := range s.Flags { + flags[flag.Name] = true + } + hasCustom := false + for _, constraint := range s.Constraints { + if len(constraint.Flags) == 0 { + t.Errorf("%s: %s constraint has no flags", name, constraint.Kind) + } + for _, flag := range constraint.Flags { + if !flags[flag] { + t.Errorf("%s: %s constraint references unknown --%s", name, constraint.Kind, flag) + } + } + if constraint.Kind == shortcut.ConstraintCustom { + hasCustom = true + if strings.TrimSpace(constraint.Description) == "" { + t.Errorf("%s: custom constraint has no description", name) + } + } + } + if s.Validate != nil && !hasCustom { + t.Errorf("%s: Validate is not published as a custom constraint", name) + } + if hasCustom && s.Validate == nil { + t.Errorf("%s: custom constraint has no Validate implementation", name) + } + } +} + // TestAllShortcutsAssemble drives every registered shortcut end-to-end through // the cobra tree with synthesized inputs and asserts each one is healthy: // - it either assembles an MCP call with a real (non-hallucinated) tool name, // - or it is rejected by its own validation (proving the plumbing ran), // - and it never panics or silently no-ops. func TestAllShortcutsAssemble(t *testing.T) { + silenceProcessOutput(t) fake := &fakeCaller{} helpers.InitDeps(fake) - root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} - root.SetOut(io.Discard) - root.SetErr(io.Discard) - root.PersistentFlags().Bool("yes", false, "") - root.PersistentFlags().Bool("dry-run", false, "") - root.PersistentFlags().String("format", "json", "") - root.AddCommand(builtin.Commands()...) + root := newCoverageRoot() real := realToolSet(t) all := shortcut.All() @@ -160,7 +372,7 @@ func TestAllShortcutsAssemble(t *testing.T) { case err != nil: // Rejected by required/enum/Validate before dispatch — plumbing OK. validated++ - validatedNames = append(validatedNames, s.Service+s.Command) + validatedNames = append(validatedNames, s.Service+s.Command+"="+err.Error()) default: failed++ t.Errorf("%s: returned nil error but never assembled an MCP call (dead command)", name) @@ -175,17 +387,40 @@ func TestAllShortcutsAssemble(t *testing.T) { } } -func TestReplaceBatchDryRunDoesNotCallTool(t *testing.T) { +func TestAllShortcutsHandleRepresentativeResponseShapes(t *testing.T) { + silenceProcessOutput(t) + all := shortcut.All() + for _, mode := range []int{1, 2, 3} { + fake := &fakeCaller{payload: mode} + helpers.InitDeps(fake) + root := newCoverageRoot() + called := 0 + for _, s := range all { + fake.reset() + root.SetArgs(append(synthArgs(s), "--yes")) + func() { + defer func() { + if recovered := recover(); recovered != nil { + t.Errorf("payload mode %d panicked in %s %s: %v", mode, s.Service, s.Command, recovered) + } + }() + _ = root.Execute() + }() + if fake.called { + called++ + } + } + if called < 350 { + t.Errorf("payload mode %d reached only %d/%d shortcuts", mode, called, len(all)) + } + } +} + +func TestCrossPlatformCoverageReplaceBatchDryRunDoesNotCallTool(t *testing.T) { fake := &fakeCaller{dryRun: true} helpers.InitDeps(fake) - root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} - root.SetOut(io.Discard) - root.SetErr(io.Discard) - root.PersistentFlags().Bool("yes", false, "") - root.PersistentFlags().Bool("dry-run", false, "") - root.PersistentFlags().String("format", "json", "") - root.AddCommand(builtin.Commands()...) + root := newCoverageRoot() root.SetArgs([]string{ "minutes", "+replace-batch", "--id", "task-1", "--pair", "Q2=>第二季度", "--dry-run", "--yes", @@ -202,7 +437,7 @@ func TestReplaceBatchDryRunDoesNotCallTool(t *testing.T) { // CallMCP("tool", ...) literals and asserts each tool exists in the helper // ground truth. This covers the ~55 shortcuts whose own validation blocks the // runtime assemble path, so no shortcut's tool name goes unverified. -func TestAllToolLiteralsAreReal(t *testing.T) { +func TestCrossPlatformCoverageAllToolLiteralsAreReal(t *testing.T) { real := realToolSet(t) srcs, err := filepath.Glob("../*/*.go") if err != nil || len(srcs) == 0 { @@ -243,7 +478,7 @@ func TestAllToolLiteralsAreReal(t *testing.T) { // TestAllHaveIntent enforces that every shortcut carries a natural-language // Intent (a fuller "what/when to use" description for discovery and AI-agent // matching), not just the terse one-line Description. -func TestAllHaveIntent(t *testing.T) { +func TestCrossPlatformCoverageAllHaveIntent(t *testing.T) { var missing []string for _, s := range shortcut.All() { if strings.TrimSpace(s.Intent) == "" { @@ -258,7 +493,7 @@ func TestAllHaveIntent(t *testing.T) { // TestNoDuplicateCommands guards against two shortcuts colliding on the same // service+command path. -func TestNoDuplicateCommands(t *testing.T) { +func TestCrossPlatformCoverageNoDuplicateCommands(t *testing.T) { seen := map[string]bool{} for _, s := range shortcut.All() { key := s.Service + " " + s.Command diff --git a/internal/shortcut/calendar/calendar.go b/internal/shortcut/calendar/calendar.go index 6d07ac8f..33a72661 100644 --- a/internal/shortcut/calendar/calendar.go +++ b/internal/shortcut/calendar/calendar.go @@ -540,15 +540,12 @@ var BusySearch = shortcut.Shortcut{ {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601", Required: true}, {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601", Required: true}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"users", "rooms"}}, + }, Tips: []string{ `dws calendar +freebusy --users userId1,userId2 --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - if len(rt.StrSlice("users")) == 0 && len(rt.StrSlice("rooms")) == 0 { - return fmt.Errorf("--users 与 --rooms 至少指定其一") - } - return nil - }, Execute: func(rt *shortcut.RuntimeContext) error { startMs, err := parseMillis("start", rt.Str("start")) if err != nil { diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index fe9cd185..9f1eff61 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -645,6 +645,9 @@ var MessagesSendCard = shortcut.Shortcut{ {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --receiver 互斥)"}, {Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group 互斥)"}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "receiver"}}, + }, Tips: []string{`dws chat +messages-send-card --group `}, Execute: func(rt *shortcut.RuntimeContext) error { group := rt.Str("group") diff --git a/internal/shortcut/mail/mail.go b/internal/shortcut/mail/mail.go index adb3a2fa..c700d4d2 100644 --- a/internal/shortcut/mail/mail.go +++ b/internal/shortcut/mail/mail.go @@ -17,11 +17,7 @@ // declared in internal/helpers/mail.go. package mail -import ( - "fmt" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) +import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" // ── mailbox ──────────────────────────────────────────────── @@ -368,16 +364,13 @@ var UserSearch = shortcut.Shortcut{ {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量"}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "employee-no"}}, + }, Tips: []string{ `dws mail +user-search --keyword "张三"`, `dws mail +user-search --email user@company.com --employee-no "E123456"`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - if rt.Str("keyword") == "" && rt.Str("employee-no") == "" { - return fmt.Errorf("--keyword 与 --employee-no 至少需要提供一个") - } - return nil - }, Execute: func(rt *shortcut.RuntimeContext) error { params := map[string]any{} if rt.Str("keyword") != "" { diff --git a/internal/shortcut/public_catalog.go b/internal/shortcut/public_catalog.go index e5d4cd1e..2add27d5 100644 --- a/internal/shortcut/public_catalog.go +++ b/internal/shortcut/public_catalog.go @@ -3,6 +3,8 @@ package shortcut +var publicShortcutCatalog = generatedPublicShortcutCatalog() + func publicCatalogKey(service, command string) string { return service + "\x00" + command } diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index ffc06ff4..25d0ddcc 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -4,215 +4,217 @@ package shortcut // publicShortcutCatalog contains the generated shortcut surface used by // command discovery and skill generation. -var publicShortcutCatalog = map[string]struct{}{ - "aitable\u0000+base-get": {}, - "aitable\u0000+base-list": {}, - "aitable\u0000+base-search": {}, - "aitable\u0000+chart-get": {}, - "aitable\u0000+chart-widgets-example": {}, - "aitable\u0000+dashboard-config-example": {}, - "aitable\u0000+dashboard-get": {}, - "aitable\u0000+field-get": {}, - "aitable\u0000+find-record": {}, - "aitable\u0000+form-field-list": {}, - "aitable\u0000+form-list": {}, - "aitable\u0000+form-share-get": {}, - "aitable\u0000+list-tables": {}, - "aitable\u0000+record-history-list": {}, - "aitable\u0000+record-query": {}, - "aitable\u0000+record-query-empty": {}, - "aitable\u0000+record-share-links": {}, - "aitable\u0000+record-share-url": {}, - "aitable\u0000+resolve-base": {}, - "aitable\u0000+resolve-table": {}, - "aitable\u0000+role-list": {}, - "aitable\u0000+section-list-empty": {}, - "aitable\u0000+section-list-nodes": {}, - "aitable\u0000+table-get": {}, - "aitable\u0000+template-search": {}, - "aitable\u0000+view-get": {}, - "aitable\u0000+view-get-frozen-cols": {}, - "aitable\u0000+view-get-lock": {}, - "aitable\u0000+view-get-row-height": {}, - "attendance\u0000+check-record": {}, - "attendance\u0000+check-result": {}, - "attendance\u0000+get-adjustment-rule": {}, - "attendance\u0000+get-approve-template": {}, - "attendance\u0000+get-checkin-record": {}, - "attendance\u0000+get-leave-records": {}, - "attendance\u0000+get-overtime-rule": {}, - "attendance\u0000+get-schedule": {}, - "attendance\u0000+get-self-setting": {}, - "attendance\u0000+get-summary": {}, - "attendance\u0000+list-approve": {}, - "attendance\u0000+list-leave-types": {}, - "attendance\u0000+my-attendance": {}, - "attendance\u0000+query-report-data": {}, - "attendance\u0000+search-adjustment-rule": {}, - "attendance\u0000+search-class": {}, - "attendance\u0000+search-group": {}, - "attendance\u0000+search-overtime-rule": {}, - "attendance\u0000+this-month": {}, - "calendar\u0000+agenda": {}, - "calendar\u0000+attendee-list": {}, - "calendar\u0000+book": {}, - "calendar\u0000+book-list": {}, - "calendar\u0000+book-search": {}, - "calendar\u0000+cancel-event": {}, - "calendar\u0000+conflicts": {}, - "calendar\u0000+free": {}, - "calendar\u0000+free-slots": {}, - "calendar\u0000+freebusy": {}, - "calendar\u0000+invite": {}, - "calendar\u0000+my-free": {}, - "calendar\u0000+next-event": {}, - "calendar\u0000+reschedule": {}, - "calendar\u0000+room-groups": {}, - "calendar\u0000+room-search": {}, - "calendar\u0000+suggest-time": {}, - "calendar\u0000+today": {}, - "calendar\u0000+tomorrow": {}, - "calendar\u0000+week": {}, - "chat\u0000+at-me": {}, - "chat\u0000+bot-find": {}, - "chat\u0000+bot-search": {}, - "chat\u0000+broadcast": {}, - "chat\u0000+category-create": {}, - "chat\u0000+category-delete": {}, - "chat\u0000+category-list": {}, - "chat\u0000+category-rename": {}, - "chat\u0000+chat-bots": {}, - "chat\u0000+chat-dismiss": {}, - "chat\u0000+chat-invite-url": {}, - "chat\u0000+chat-list-all": {}, - "chat\u0000+chat-list-join-requests": {}, - "chat\u0000+chat-list-mine": {}, - "chat\u0000+chat-mute": {}, - "chat\u0000+chat-role-add": {}, - "chat\u0000+chat-role-list": {}, - "chat\u0000+chat-role-query-user": {}, - "chat\u0000+chat-role-set-user": {}, - "chat\u0000+chat-role-update": {}, - "chat\u0000+chat-search": {}, - "chat\u0000+chat-set-admin": {}, - "chat\u0000+chat-set-history": {}, - "chat\u0000+chat-update-alias": {}, - "chat\u0000+chat-update-nick": {}, - "chat\u0000+conversation-clear-all-red-point": {}, - "chat\u0000+conversation-info": {}, - "chat\u0000+conversation-list": {}, - "chat\u0000+conversation-list-top": {}, - "chat\u0000+dm": {}, - "chat\u0000+group-members": {}, - "chat\u0000+messages-list-direct": {}, - "chat\u0000+messages-list-pin": {}, - "chat\u0000+messages-list-unread-conversations": {}, - "chat\u0000+messages-mget": {}, - "chat\u0000+messages-query-send-status": {}, - "chat\u0000+messages-read-status": {}, - "chat\u0000+messages-send-by-webhook": {}, - "chat\u0000+messages-update-card": {}, - "chat\u0000+my-groups": {}, - "chat\u0000+send-to-group": {}, - "chat\u0000+unread-chats": {}, - "contact\u0000+by-mobile": {}, - "contact\u0000+dept-members": {}, - "contact\u0000+list-dept-members": {}, - "contact\u0000+list-followings": {}, - "contact\u0000+list-role-members": {}, - "contact\u0000+list-roles": {}, - "contact\u0000+list-sub-depts": {}, - "contact\u0000+lookup": {}, - "contact\u0000+me": {}, - "contact\u0000+org": {}, - "contact\u0000+resolve-dept": {}, - "contact\u0000+search-mobile": {}, - "contact\u0000+search-user": {}, - "contact\u0000+team": {}, - "devapp\u0000+create": {}, - "devapp\u0000+delete": {}, - "devapp\u0000+disable": {}, - "devapp\u0000+enable": {}, - "devapp\u0000+event-list": {}, - "devapp\u0000+get": {}, - "devapp\u0000+list": {}, - "devapp\u0000+member-add": {}, - "devapp\u0000+member-list": {}, - "devapp\u0000+member-remove": {}, - "devapp\u0000+permission-list": {}, - "devapp\u0000+robot-get": {}, - "devapp\u0000+update": {}, - "devapp\u0000+version-check-approval": {}, - "devapp\u0000+version-get": {}, - "devapp\u0000+version-list": {}, - "devapp\u0000+version-status": {}, - "devapp\u0000+webapp-config": {}, - "devapp\u0000+webapp-get": {}, - "ding\u0000+list": {}, - "ding\u0000+recall-personal": {}, - "ding\u0000+receiver-status": {}, - "ding\u0000+send-personal": {}, - "doc\u0000+comment-create": {}, - "doc\u0000+comment-list": {}, - "doc\u0000+comment-reply": {}, - "doc\u0000+copy": {}, - "doc\u0000+doc-append": {}, - "doc\u0000+export-get": {}, - "doc\u0000+export-submit": {}, - "doc\u0000+find-doc": {}, - "doc\u0000+list": {}, - "doc\u0000+move": {}, - "doc\u0000+search": {}, - "doc\u0000+share-doc": {}, - "doc\u0000+template-list": {}, - "doc\u0000+template-search": {}, - "doc\u0000+version-list": {}, - "doc\u0000+version-revert": {}, - "doc\u0000+version-save": {}, - "drive\u0000+copy": {}, - "drive\u0000+find-file": {}, - "drive\u0000+info": {}, - "drive\u0000+move": {}, - "drive\u0000+recent": {}, - "drive\u0000+search": {}, - "drive\u0000+search-docs": {}, - "mail\u0000+contact-list": {}, - "mail\u0000+find-mail-user": {}, - "mail\u0000+folder-list": {}, - "mail\u0000+recent-mail": {}, - "mail\u0000+search-mail": {}, - "mail\u0000+tag-list": {}, - "mail\u0000+template-list": {}, - "mail\u0000+thread-list": {}, - "mail\u0000+unread-mail": {}, - "mail\u0000+user-search": {}, - "minutes\u0000+detail": {}, - "minutes\u0000+list-all": {}, - "minutes\u0000+list-mine": {}, - "minutes\u0000+list-shared": {}, - "minutes\u0000+record-start": {}, - "minutes\u0000+replace-batch": {}, - "oa\u0000+list-cc": {}, - "oa\u0000+list-executed": {}, - "oa\u0000+list-forms": {}, - "oa\u0000+list-pending": {}, - "oa\u0000+list-submitted": {}, - "oa\u0000+my-initiated": {}, - "oa\u0000+search-forms": {}, - "report\u0000+inbox-list": {}, - "report\u0000+outbox-list": {}, - "sheet\u0000+list-sheets": {}, - "sheet\u0000+read": {}, - "todo\u0000+assign": {}, - "todo\u0000+assign-multi": {}, - "todo\u0000+created-todos": {}, - "todo\u0000+get": {}, - "todo\u0000+get-my-tasks": {}, - "todo\u0000+list-attachment": {}, - "todo\u0000+list-comment": {}, - "todo\u0000+list-sub": {}, - "todo\u0000+overdue": {}, - "todo\u0000+remind": {}, - "todo\u0000+todo-done": {}, - "wiki\u0000+space-search": {}, +func generatedPublicShortcutCatalog() map[string]struct{} { + return map[string]struct{}{ + "aitable\u0000+base-get": {}, + "aitable\u0000+base-list": {}, + "aitable\u0000+base-search": {}, + "aitable\u0000+chart-get": {}, + "aitable\u0000+chart-widgets-example": {}, + "aitable\u0000+dashboard-config-example": {}, + "aitable\u0000+dashboard-get": {}, + "aitable\u0000+field-get": {}, + "aitable\u0000+find-record": {}, + "aitable\u0000+form-field-list": {}, + "aitable\u0000+form-list": {}, + "aitable\u0000+form-share-get": {}, + "aitable\u0000+list-tables": {}, + "aitable\u0000+record-history-list": {}, + "aitable\u0000+record-query": {}, + "aitable\u0000+record-query-empty": {}, + "aitable\u0000+record-share-links": {}, + "aitable\u0000+record-share-url": {}, + "aitable\u0000+resolve-base": {}, + "aitable\u0000+resolve-table": {}, + "aitable\u0000+role-list": {}, + "aitable\u0000+section-list-empty": {}, + "aitable\u0000+section-list-nodes": {}, + "aitable\u0000+table-get": {}, + "aitable\u0000+template-search": {}, + "aitable\u0000+view-get": {}, + "aitable\u0000+view-get-frozen-cols": {}, + "aitable\u0000+view-get-lock": {}, + "aitable\u0000+view-get-row-height": {}, + "attendance\u0000+check-record": {}, + "attendance\u0000+check-result": {}, + "attendance\u0000+get-adjustment-rule": {}, + "attendance\u0000+get-approve-template": {}, + "attendance\u0000+get-checkin-record": {}, + "attendance\u0000+get-leave-records": {}, + "attendance\u0000+get-overtime-rule": {}, + "attendance\u0000+get-schedule": {}, + "attendance\u0000+get-self-setting": {}, + "attendance\u0000+get-summary": {}, + "attendance\u0000+list-approve": {}, + "attendance\u0000+list-leave-types": {}, + "attendance\u0000+my-attendance": {}, + "attendance\u0000+query-report-data": {}, + "attendance\u0000+search-adjustment-rule": {}, + "attendance\u0000+search-class": {}, + "attendance\u0000+search-group": {}, + "attendance\u0000+search-overtime-rule": {}, + "attendance\u0000+this-month": {}, + "calendar\u0000+agenda": {}, + "calendar\u0000+attendee-list": {}, + "calendar\u0000+book": {}, + "calendar\u0000+book-list": {}, + "calendar\u0000+book-search": {}, + "calendar\u0000+cancel-event": {}, + "calendar\u0000+conflicts": {}, + "calendar\u0000+free": {}, + "calendar\u0000+free-slots": {}, + "calendar\u0000+freebusy": {}, + "calendar\u0000+invite": {}, + "calendar\u0000+my-free": {}, + "calendar\u0000+next-event": {}, + "calendar\u0000+reschedule": {}, + "calendar\u0000+room-groups": {}, + "calendar\u0000+room-search": {}, + "calendar\u0000+suggest-time": {}, + "calendar\u0000+today": {}, + "calendar\u0000+tomorrow": {}, + "calendar\u0000+week": {}, + "chat\u0000+at-me": {}, + "chat\u0000+bot-find": {}, + "chat\u0000+bot-search": {}, + "chat\u0000+broadcast": {}, + "chat\u0000+category-create": {}, + "chat\u0000+category-delete": {}, + "chat\u0000+category-list": {}, + "chat\u0000+category-rename": {}, + "chat\u0000+chat-bots": {}, + "chat\u0000+chat-dismiss": {}, + "chat\u0000+chat-invite-url": {}, + "chat\u0000+chat-list-all": {}, + "chat\u0000+chat-list-join-requests": {}, + "chat\u0000+chat-list-mine": {}, + "chat\u0000+chat-mute": {}, + "chat\u0000+chat-role-add": {}, + "chat\u0000+chat-role-list": {}, + "chat\u0000+chat-role-query-user": {}, + "chat\u0000+chat-role-set-user": {}, + "chat\u0000+chat-role-update": {}, + "chat\u0000+chat-search": {}, + "chat\u0000+chat-set-admin": {}, + "chat\u0000+chat-set-history": {}, + "chat\u0000+chat-update-alias": {}, + "chat\u0000+chat-update-nick": {}, + "chat\u0000+conversation-clear-all-red-point": {}, + "chat\u0000+conversation-info": {}, + "chat\u0000+conversation-list": {}, + "chat\u0000+conversation-list-top": {}, + "chat\u0000+dm": {}, + "chat\u0000+group-members": {}, + "chat\u0000+messages-list-direct": {}, + "chat\u0000+messages-list-pin": {}, + "chat\u0000+messages-list-unread-conversations": {}, + "chat\u0000+messages-mget": {}, + "chat\u0000+messages-query-send-status": {}, + "chat\u0000+messages-read-status": {}, + "chat\u0000+messages-send-by-webhook": {}, + "chat\u0000+messages-update-card": {}, + "chat\u0000+my-groups": {}, + "chat\u0000+send-to-group": {}, + "chat\u0000+unread-chats": {}, + "contact\u0000+by-mobile": {}, + "contact\u0000+dept-members": {}, + "contact\u0000+list-dept-members": {}, + "contact\u0000+list-followings": {}, + "contact\u0000+list-role-members": {}, + "contact\u0000+list-roles": {}, + "contact\u0000+list-sub-depts": {}, + "contact\u0000+lookup": {}, + "contact\u0000+me": {}, + "contact\u0000+org": {}, + "contact\u0000+resolve-dept": {}, + "contact\u0000+search-mobile": {}, + "contact\u0000+search-user": {}, + "contact\u0000+team": {}, + "devapp\u0000+create": {}, + "devapp\u0000+delete": {}, + "devapp\u0000+disable": {}, + "devapp\u0000+enable": {}, + "devapp\u0000+event-list": {}, + "devapp\u0000+get": {}, + "devapp\u0000+list": {}, + "devapp\u0000+member-add": {}, + "devapp\u0000+member-list": {}, + "devapp\u0000+member-remove": {}, + "devapp\u0000+permission-list": {}, + "devapp\u0000+robot-get": {}, + "devapp\u0000+update": {}, + "devapp\u0000+version-check-approval": {}, + "devapp\u0000+version-get": {}, + "devapp\u0000+version-list": {}, + "devapp\u0000+version-status": {}, + "devapp\u0000+webapp-config": {}, + "devapp\u0000+webapp-get": {}, + "ding\u0000+list": {}, + "ding\u0000+recall-personal": {}, + "ding\u0000+receiver-status": {}, + "ding\u0000+send-personal": {}, + "doc\u0000+comment-create": {}, + "doc\u0000+comment-list": {}, + "doc\u0000+comment-reply": {}, + "doc\u0000+copy": {}, + "doc\u0000+doc-append": {}, + "doc\u0000+export-get": {}, + "doc\u0000+export-submit": {}, + "doc\u0000+find-doc": {}, + "doc\u0000+list": {}, + "doc\u0000+move": {}, + "doc\u0000+search": {}, + "doc\u0000+share-doc": {}, + "doc\u0000+template-list": {}, + "doc\u0000+template-search": {}, + "doc\u0000+version-list": {}, + "doc\u0000+version-revert": {}, + "doc\u0000+version-save": {}, + "drive\u0000+copy": {}, + "drive\u0000+find-file": {}, + "drive\u0000+info": {}, + "drive\u0000+move": {}, + "drive\u0000+recent": {}, + "drive\u0000+search": {}, + "drive\u0000+search-docs": {}, + "mail\u0000+contact-list": {}, + "mail\u0000+find-mail-user": {}, + "mail\u0000+folder-list": {}, + "mail\u0000+recent-mail": {}, + "mail\u0000+search-mail": {}, + "mail\u0000+tag-list": {}, + "mail\u0000+template-list": {}, + "mail\u0000+thread-list": {}, + "mail\u0000+unread-mail": {}, + "mail\u0000+user-search": {}, + "minutes\u0000+detail": {}, + "minutes\u0000+list-all": {}, + "minutes\u0000+list-mine": {}, + "minutes\u0000+list-shared": {}, + "minutes\u0000+record-start": {}, + "minutes\u0000+replace-batch": {}, + "oa\u0000+list-cc": {}, + "oa\u0000+list-executed": {}, + "oa\u0000+list-forms": {}, + "oa\u0000+list-pending": {}, + "oa\u0000+list-submitted": {}, + "oa\u0000+my-initiated": {}, + "oa\u0000+search-forms": {}, + "report\u0000+inbox-list": {}, + "report\u0000+outbox-list": {}, + "sheet\u0000+list-sheets": {}, + "sheet\u0000+read": {}, + "todo\u0000+assign": {}, + "todo\u0000+assign-multi": {}, + "todo\u0000+created-todos": {}, + "todo\u0000+get": {}, + "todo\u0000+get-my-tasks": {}, + "todo\u0000+list-attachment": {}, + "todo\u0000+list-comment": {}, + "todo\u0000+list-sub": {}, + "todo\u0000+overdue": {}, + "todo\u0000+remind": {}, + "todo\u0000+todo-done": {}, + "wiki\u0000+space-search": {}, + } } diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index 61738905..fea33028 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -167,7 +167,7 @@ func mount(s Shortcut) *cobra.Command { cmd := &cobra.Command{ Use: s.Command, Short: s.Description, - Long: s.Intent, + Long: shortcutLongHelp(s), Hidden: s.Hidden, } if len(s.Tips) > 0 { @@ -180,6 +180,9 @@ func mount(s Shortcut) *cobra.Command { if err := validateFlags(rt, s); err != nil { return err } + if err := validateConstraints(rt, s); err != nil { + return err + } if s.Validate != nil { if err := s.Validate(rt); err != nil { return err @@ -199,15 +202,16 @@ func mount(s Shortcut) *cobra.Command { // registerFlags declares each Flag on the command with its type/default/desc. func registerFlags(cmd *cobra.Command, flags []Flag) { for _, f := range flags { + desc := flagHelp(f) switch f.Type { case FlagBool: - cmd.Flags().Bool(f.Name, f.Default == "true", f.Desc) + cmd.Flags().Bool(f.Name, f.Default == "true", desc) case FlagInt: - cmd.Flags().Int(f.Name, atoiDefault(f.Default), f.Desc) + cmd.Flags().Int(f.Name, atoiDefault(f.Default), desc) case FlagStringSlice: - cmd.Flags().StringSlice(f.Name, nil, f.Desc) + cmd.Flags().StringSlice(f.Name, nil, desc) default: // FlagString and empty - cmd.Flags().String(f.Name, f.Default, f.Desc) + cmd.Flags().String(f.Name, f.Default, desc) } if f.Hidden { _ = cmd.Flags().MarkHidden(f.Name) @@ -215,19 +219,126 @@ func registerFlags(cmd *cobra.Command, flags []Flag) { } } +func flagHelp(f Flag) string { + parts := make([]string, 0, 2) + if f.Required && !strings.Contains(f.Desc, "必填") { + parts = append(parts, "必填") + } + if len(f.Enum) > 0 { + parts = append(parts, "可选值: "+strings.Join(f.Enum, ", ")) + } + if len(parts) == 0 { + return f.Desc + } + if strings.TrimSpace(f.Desc) == "" { + return strings.Join(parts, ";") + } + return f.Desc + "(" + strings.Join(parts, ";") + ")" +} + +func shortcutLongHelp(s Shortcut) string { + long := strings.TrimSpace(s.Intent) + if long == "" { + long = strings.TrimSpace(s.Description) + } + if len(s.Constraints) == 0 { + return long + } + lines := make([]string, 0, len(s.Constraints)) + for _, constraint := range s.Constraints { + lines = append(lines, " - "+constraintHelp(constraint)) + } + return long + "\n\n参数约束:\n" + strings.Join(lines, "\n") +} + +func constraintHelp(constraint Constraint) string { + if strings.TrimSpace(constraint.Description) != "" { + return constraint.Description + } + switch constraint.Kind { + case ConstraintAtLeastOne: + return fmt.Sprintf("%s 至少指定一个", dashed(constraint.Flags)) + case ConstraintExactlyOne: + return fmt.Sprintf("%s 必须且只能指定一个", dashed(constraint.Flags)) + case ConstraintMutuallyExclusive: + return fmt.Sprintf("%s 互斥,最多指定一个", dashed(constraint.Flags)) + default: + return fmt.Sprintf("%s 使用未识别的约束类型 %q", dashed(constraint.Flags), constraint.Kind) + } +} + // validateFlags enforces the declarative Required and Enum constraints. func validateFlags(rt *RuntimeContext, s Shortcut) error { for _, f := range s.Flags { if f.Required && !rt.Changed(f.Name) { return apperrors.NewValidation(fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc)) } - if len(f.Enum) > 0 && rt.Changed(f.Name) { - val := rt.Str(f.Name) - if !contains(f.Enum, val) { - return apperrors.NewValidation(fmt.Sprintf( - "参数 --%s 取值 %q 不合法,允许值:%s", f.Name, val, strings.Join(f.Enum, ", "))) + if f.Required && rt.Changed(f.Name) { + switch f.Type { + case FlagStringSlice: + if !hasNonEmptyString(rt.StrSlice(f.Name)) { + return apperrors.NewValidation(fmt.Sprintf("必填参数 --%s 不能为空", f.Name)) + } + case FlagString, "": + if rt.Str(f.Name) == "" { + return apperrors.NewValidation(fmt.Sprintf("必填参数 --%s 不能为空", f.Name)) + } } } + if len(f.Enum) > 0 && rt.Changed(f.Name) { + values := []string{rt.Str(f.Name)} + if f.Type == FlagStringSlice { + values = rt.StrSlice(f.Name) + } + for _, val := range values { + val = strings.TrimSpace(val) + if !contains(f.Enum, val) { + return apperrors.NewValidation(fmt.Sprintf( + "参数 --%s 取值 %q 不合法,允许值:%s", f.Name, val, strings.Join(f.Enum, ", "))) + } + } + } + } + return nil +} + +func hasNonEmptyString(values []string) bool { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return true + } + } + return false +} + +func validateConstraints(rt *RuntimeContext, s Shortcut) error { + for _, constraint := range s.Constraints { + if len(constraint.Flags) == 0 { + return apperrors.NewInternal(fmt.Sprintf( + "shortcut %s %s 的约束 %q 未声明参数", s.Service, s.Command, constraint.Kind)) + } + switch constraint.Kind { + case ConstraintAtLeastOne: + if err := rt.AtLeastOne(constraint.Flags...); err != nil { + return err + } + case ConstraintExactlyOne: + if err := rt.ExactlyOne(constraint.Flags...); err != nil { + return err + } + case ConstraintMutuallyExclusive: + if err := rt.MutuallyExclusive(constraint.Flags...); err != nil { + return err + } + case ConstraintCustom: + if strings.TrimSpace(constraint.Description) == "" { + return apperrors.NewInternal(fmt.Sprintf( + "shortcut %s %s 的 custom 约束缺少描述", s.Service, s.Command)) + } + default: + return apperrors.NewInternal(fmt.Sprintf( + "shortcut %s %s 使用未知约束类型 %q", s.Service, s.Command, constraint.Kind)) + } } return nil } diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index c5431d60..439bc480 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -14,13 +14,14 @@ package shortcut import ( + "bytes" "strings" "testing" "github.com/spf13/cobra" ) -func TestProductDefaultsToService(t *testing.T) { +func TestCrossPlatformCoverageProductDefaultsToService(t *testing.T) { if got := (Shortcut{Service: "contact"}).product(); got != "contact" { t.Fatalf("product() = %q, want contact", got) } @@ -29,7 +30,7 @@ func TestProductDefaultsToService(t *testing.T) { } } -func TestRiskDefaultsToRead(t *testing.T) { +func TestCrossPlatformCoverageRiskDefaultsToRead(t *testing.T) { if got := (Shortcut{}).risk(); got != RiskRead { t.Fatalf("risk() = %q, want read", got) } @@ -38,7 +39,7 @@ func TestRiskDefaultsToRead(t *testing.T) { } } -func TestMountRegistersFlagsAndUse(t *testing.T) { +func TestCrossPlatformCoverageMountRegistersFlagsAndUse(t *testing.T) { s := Shortcut{ Service: "contact", Command: "+search-user", @@ -64,7 +65,7 @@ func TestMountRegistersFlagsAndUse(t *testing.T) { } } -func TestValidateFlagsRequiredAndEnum(t *testing.T) { +func TestCrossPlatformCoverageValidateFlagsRequiredAndEnum(t *testing.T) { s := Shortcut{ Service: "contact", Command: "+x", @@ -100,7 +101,100 @@ func TestValidateFlagsRequiredAndEnum(t *testing.T) { } } -func TestBuildGroupsByService(t *testing.T) { +func TestCrossPlatformCoverageValidateFlagsRejectsEmptyRequiredValuesAndInvalidSliceEnums(t *testing.T) { + s := Shortcut{ + Service: "minutes", + Command: "+detail", + Flags: []Flag{ + {Name: "id", Type: FlagString, Required: true}, + {Name: "artifacts", Type: FlagStringSlice, Enum: []string{"summary", "todos"}}, + }, + } + cmd := mount(s) + rt := &RuntimeContext{cmd: cmd, shortcut: s} + + _ = cmd.Flags().Set("id", " ") + if err := validateFlags(rt, s); err == nil { + t.Fatal("expected empty required string to fail") + } + _ = cmd.Flags().Set("id", "task-1") + _ = cmd.Flags().Set("artifacts", "summary,unknown") + if err := validateFlags(rt, s); err == nil { + t.Fatal("expected invalid string-slice enum value to fail") + } + cmd = mount(s) + rt = &RuntimeContext{cmd: cmd, shortcut: s} + _ = cmd.Flags().Set("id", "task-1") + _ = cmd.Flags().Set("artifacts", "summary,todos") + if err := validateFlags(rt, s); err != nil { + t.Fatalf("valid string-slice enum failed: %v", err) + } +} + +func TestCrossPlatformCoverageDeclarativeConstraintsRejectEmptyAndConflictingValues(t *testing.T) { + s := Shortcut{ + Service: "chat", + Command: "+messages", + Flags: []Flag{ + {Name: "group", Type: FlagString}, + {Name: "user", Type: FlagString}, + }, + Constraints: []Constraint{ + {Kind: ConstraintExactlyOne, Flags: []string{"group", "user"}}, + }, + } + cmd := mount(s) + rt := &RuntimeContext{cmd: cmd, shortcut: s} + if err := validateConstraints(rt, s); err == nil { + t.Fatal("expected missing exactly-one flags to fail") + } + _ = cmd.Flags().Set("group", " ") + if err := validateConstraints(rt, s); err == nil { + t.Fatal("empty flag must not satisfy exactly-one") + } + _ = cmd.Flags().Set("group", "cid-1") + if err := validateConstraints(rt, s); err != nil { + t.Fatalf("one non-empty flag should pass: %v", err) + } + _ = cmd.Flags().Set("user", "user-1") + if err := validateConstraints(rt, s); err == nil { + t.Fatal("two flags must violate exactly-one") + } +} + +func TestCrossPlatformCoverageMountHelpPublishesRequiredEnumsAndConstraints(t *testing.T) { + s := Shortcut{ + Service: "chat", + Command: "+messages", + Description: "messages", + Intent: "读取群聊或单聊消息。", + Flags: []Flag{ + {Name: "group", Type: FlagString, Required: true, Desc: "群 ID"}, + {Name: "user", Type: FlagString, Desc: "用户 ID"}, + {Name: "direction", Type: FlagString, Enum: []string{"newer", "older"}, Desc: "方向"}, + }, + Constraints: []Constraint{ + {Kind: ConstraintExactlyOne, Flags: []string{"group", "user"}}, + }, + } + cmd := mount(s) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + if err := cmd.Help(); err != nil { + t.Fatal(err) + } + help := out.String() + for _, want := range []string{ + "参数约束", "--group、--user 必须且只能指定一个", "群 ID(必填)", "可选值: newer, older", + } { + if !strings.Contains(help, want) { + t.Errorf("help missing %q:\n%s", want, help) + } + } +} + +func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) { shortcuts := []Shortcut{ {Service: "contact", Command: "+a", Execute: noop}, {Service: "contact", Command: "+b", Execute: noop}, @@ -124,7 +218,7 @@ func TestBuildGroupsByService(t *testing.T) { func noop(_ *RuntimeContext) error { return nil } -func TestCallMCPWriteDataRejectsDryRun(t *testing.T) { +func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("dry-run", false, "") cmd := &cobra.Command{Use: "x"} @@ -143,7 +237,7 @@ func TestCallMCPWriteDataRejectsDryRun(t *testing.T) { } } -func TestCallMCPDataRejectsLikelyWriteUnderDryRun(t *testing.T) { +func TestCrossPlatformCoverageCallMCPDataRejectsLikelyWriteUnderDryRun(t *testing.T) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().Bool("dry-run", false, "") cmd := &cobra.Command{Use: "x"} @@ -162,7 +256,7 @@ func TestCallMCPDataRejectsLikelyWriteUnderDryRun(t *testing.T) { } } -func TestValidationHelpers(t *testing.T) { +func TestCrossPlatformCoverageValidationHelpers(t *testing.T) { s := Shortcut{ Service: "x", Command: "+y", Flags: []Flag{ diff --git a/internal/shortcut/smart/approve.go b/internal/shortcut/smart/approve.go index 3d863c1b..9ad9a36b 100644 --- a/internal/shortcut/smart/approve.go +++ b/internal/shortcut/smart/approve.go @@ -62,9 +62,6 @@ var Approve = shortcut.Shortcut{ `dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("keyword") - }, Execute: func(rt *shortcut.RuntimeContext) error { keyword := strings.TrimSpace(rt.Str("keyword")) if keyword == "" { diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 80a2f107..8207a7c0 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -57,15 +57,14 @@ var ChatMessages = shortcut.Shortcut{ {Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数(可选)"}, {Name: "direction", Type: shortcut.FlagString, Desc: "时间方向 newer/older(可选,newer 从给定时间往现在拉,older 往以前拉)"}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "user"}}, + }, Tips: []string{ `dws chat +chat-messages --group --time "2025-03-01 00:00:00"`, `dws chat +chat-messages --user --time "2025-03-01 00:00:00" --limit 50`, `dws chat +chat-messages --group --direction older`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - // --group and --user are mutually exclusive and exactly one is required. - return rt.ExactlyOne("group", "user") - }, Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — build params and pick the right tool. Param keys // (openCid/cid / userId / time / forward / limit) match the MCP server diff --git a/internal/shortcut/smart/find_doc.go b/internal/shortcut/smart/find_doc.go index ca9d4f30..b0785ea5 100644 --- a/internal/shortcut/smart/find_doc.go +++ b/internal/shortcut/smart/find_doc.go @@ -54,9 +54,6 @@ var FindDoc = shortcut.Shortcut{ `dws doc +find-doc --query 季度汇报`, `dws doc +find-doc --query 合同 --limit 10`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("query") - }, Execute: func(rt *shortcut.RuntimeContext) error { // keyword / pageSize mirror the helpers doc search_documents path verbatim. params := map[string]any{ diff --git a/internal/shortcut/smart/find_file.go b/internal/shortcut/smart/find_file.go index d7399b1f..2baf8ed0 100644 --- a/internal/shortcut/smart/find_file.go +++ b/internal/shortcut/smart/find_file.go @@ -16,7 +16,6 @@ package smart import ( "strings" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -53,12 +52,6 @@ var FindFile = shortcut.Shortcut{ `dws drive +find-file --query 季度汇报`, `dws drive +find-file --query 合同`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - if strings.TrimSpace(rt.Str("query")) == "" { - return apperrors.NewValidation("请用 --query 提供文件名关键词") - } - return nil - }, Execute: func(rt *shortcut.RuntimeContext) error { keyword := strings.TrimSpace(rt.Str("query")) diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go index fb9d1c3a..976b9d3b 100644 --- a/internal/shortcut/smart/find_mail_user.go +++ b/internal/shortcut/smart/find_mail_user.go @@ -63,9 +63,6 @@ var FindMailUser = shortcut.Shortcut{ `dws mail +find-mail-user --query "张三"`, `dws mail +find-mail-user --query alice --limit 10`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("query") - }, Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — search mailbox users. keyword/size mirror the toolArgs built in // helpers.mail.go's `mail user search` command; size is passed as a string. diff --git a/internal/shortcut/smart/find_record.go b/internal/shortcut/smart/find_record.go index ece8621d..d476f98c 100644 --- a/internal/shortcut/smart/find_record.go +++ b/internal/shortcut/smart/find_record.go @@ -50,9 +50,6 @@ var FindRecord = shortcut.Shortcut{ `dws aitable +find-record --base B --table T`, `dws aitable +find-record --base B --table T --query 张三`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("base", "table") - }, Execute: func(rt *shortcut.RuntimeContext) error { // baseId / tableId / keyword mirror helpers RecordQuery (query_records). params := map[string]any{ diff --git a/internal/shortcut/smart/list_tables.go b/internal/shortcut/smart/list_tables.go index 54231b6b..65ea6753 100644 --- a/internal/shortcut/smart/list_tables.go +++ b/internal/shortcut/smart/list_tables.go @@ -49,9 +49,6 @@ var ListTables = shortcut.Shortcut{ Tips: []string{ `dws aitable +list-tables --base B`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("base") - }, Execute: func(rt *shortcut.RuntimeContext) error { // baseId mirrors helpers tableGetCmd (get_tables). Omitting tableIds // asks the server for every table in the base. diff --git a/internal/shortcut/smart/minutes_detail.go b/internal/shortcut/smart/minutes_detail.go index ce7bcc8a..d02da1ab 100644 --- a/internal/shortcut/smart/minutes_detail.go +++ b/internal/shortcut/smart/minutes_detail.go @@ -14,10 +14,8 @@ package smart import ( - "fmt" "strings" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -50,27 +48,14 @@ var MinutesDetail = shortcut.Shortcut{ Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ {Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true}, - {Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要拉取的产物子集: basic,summary,keywords,transcript,todos(默认全部)", Required: false}, - {Name: "direction", Type: shortcut.FlagString, Desc: "逐字稿排序: 0=正序(默认), 1=倒序(可选)", Required: false}, + {Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要拉取的产物子集(默认全部)", Required: false, Enum: []string{"basic", "summary", "keywords", "transcript", "todos"}}, + {Name: "direction", Type: shortcut.FlagString, Desc: "逐字稿排序: 0=正序(默认), 1=倒序(可选)", Required: false, Enum: []string{"0", "1"}}, }, Tips: []string{ `dws minutes +detail --id `, `dws minutes +detail --id --artifacts summary,todos`, `dws minutes +detail --id --direction 1`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - if err := rt.RequireAll("id"); err != nil { - return err - } - // Reject unknown artifact names early so the user gets a clear message - // rather than a silently empty bundle. - for _, a := range rt.StrSlice("artifacts") { - if _, ok := minutesArtifactTools[strings.ToLower(strings.TrimSpace(a))]; !ok { - return apperrors.NewValidation(fmt.Sprintf("未知的 --artifacts 取值 %q,可选: basic,summary,keywords,transcript,todos", a)) - } - } - return nil - }, Execute: func(rt *shortcut.RuntimeContext) error { taskUUID := rt.Str("id") direction := rt.Str("direction") diff --git a/internal/shortcut/smart/record_share_links.go b/internal/shortcut/smart/record_share_links.go index ac15a865..d538383c 100644 --- a/internal/shortcut/smart/record_share_links.go +++ b/internal/shortcut/smart/record_share_links.go @@ -56,9 +56,6 @@ var RecordShareLinks = shortcut.Shortcut{ `dws aitable +record-share-links --base B --table T --record-ids rec1,rec2,rec3`, `dws aitable +record-share-links --base B --table T --record-ids rec1 --view-id viw_VIP`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("base", "table", "record-ids") - }, Execute: func(rt *shortcut.RuntimeContext) error { // Dedup while preserving first-seen order. ids := dedupStrings(rt.StrSlice("record-ids")) diff --git a/internal/shortcut/smart/replace_batch.go b/internal/shortcut/smart/replace_batch.go index 204bb08d..02ead7ae 100644 --- a/internal/shortcut/smart/replace_batch.go +++ b/internal/shortcut/smart/replace_batch.go @@ -47,14 +47,18 @@ var ReplaceBatch = shortcut.Shortcut{ {Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true}, {Name: "pair", Type: shortcut.FlagStringSlice, Desc: `替换规则,格式 "原文=>替换",可重复传多组(必填)`, Required: true}, }, + Constraints: []shortcut.Constraint{ + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"pair"}, + Description: `每个 --pair 必须使用 "原文=>替换" 格式,原文不能为空且不能重复`, + }, + }, Tips: []string{ `dws minutes +replace-batch --id --pair "张三=>张三丰"`, `dws minutes +replace-batch --id --pair "Q2=>第二季度" --pair "PM=>产品经理"`, }, Validate: func(rt *shortcut.RuntimeContext) error { - if err := rt.RequireAll("id", "pair"); err != nil { - return err - } _, err := parseReplacePairs(rt.StrSlice("pair")) return err }, diff --git a/internal/shortcut/smart/resolve_base.go b/internal/shortcut/smart/resolve_base.go index 90a3afbb..1ad1da87 100644 --- a/internal/shortcut/smart/resolve_base.go +++ b/internal/shortcut/smart/resolve_base.go @@ -50,9 +50,6 @@ var ResolveBase = shortcut.Shortcut{ Tips: []string{ `dws aitable +resolve-base --name 项目管理`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("name") - }, Execute: func(rt *shortcut.RuntimeContext) error { // Search Bases by name. tool "search_bases" + arg "query" are taken // verbatim from helpers base search (callAitableTool → server aitable). diff --git a/internal/shortcut/smart/resolve_dept.go b/internal/shortcut/smart/resolve_dept.go index 9a28b3ed..9ba0d4f8 100644 --- a/internal/shortcut/smart/resolve_dept.go +++ b/internal/shortcut/smart/resolve_dept.go @@ -55,9 +55,6 @@ var ResolveDept = shortcut.Shortcut{ Tips: []string{ `dws contact +resolve-dept --name 技术部`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("name") - }, Execute: func(rt *shortcut.RuntimeContext) error { // Search departments by name. tool "search_dept_by_keyword" + arg // "query" are taken verbatim from helpers contact dept search diff --git a/internal/shortcut/smart/resolve_space.go b/internal/shortcut/smart/resolve_space.go index 7b9db578..bc7c050b 100644 --- a/internal/shortcut/smart/resolve_space.go +++ b/internal/shortcut/smart/resolve_space.go @@ -50,9 +50,6 @@ var ResolveSpace = shortcut.Shortcut{ Tips: []string{ `dws wiki +resolve-space --name 产品文档`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("name") - }, Execute: func(rt *shortcut.RuntimeContext) error { // Search spaces by name. tool "search_wikiSpaces" + arg "keyword" are // taken verbatim from helpers wiki space search (callMCPTool → server diff --git a/internal/shortcut/smart/resolve_table.go b/internal/shortcut/smart/resolve_table.go index 9b100770..b558a9d8 100644 --- a/internal/shortcut/smart/resolve_table.go +++ b/internal/shortcut/smart/resolve_table.go @@ -58,9 +58,6 @@ var ResolveTable = shortcut.Shortcut{ Tips: []string{ `dws aitable +resolve-table --base B --name 任务`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("base", "name") - }, Execute: func(rt *shortcut.RuntimeContext) error { // baseId mirrors list_tables / helpers tableGetCmd (get_tables). Omitting // tableIds asks the server for every table in the base. diff --git a/internal/shortcut/smart/respond_event.go b/internal/shortcut/smart/respond_event.go index e72d90a5..4ff9493f 100644 --- a/internal/shortcut/smart/respond_event.go +++ b/internal/shortcut/smart/respond_event.go @@ -52,9 +52,6 @@ var RespondEvent = shortcut.Shortcut{ `dws calendar +respond-event --event EVENT_ID --response decline`, `dws calendar +respond-event --event EVENT_ID --response tentative`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("event", "response") - }, Execute: func(rt *shortcut.RuntimeContext) error { eventID := strings.TrimSpace(rt.Str("event")) if eventID == "" { diff --git a/internal/shortcut/smart/thread_replies.go b/internal/shortcut/smart/thread_replies.go index 6bd72465..e1854db3 100644 --- a/internal/shortcut/smart/thread_replies.go +++ b/internal/shortcut/smart/thread_replies.go @@ -52,9 +52,6 @@ var ThreadReplies = shortcut.Shortcut{ `dws chat +thread-replies --group --topic-id `, `dws chat +thread-replies --group --topic-id --time "2025-03-01 00:00:00" --limit 20`, }, - Validate: func(rt *shortcut.RuntimeContext) error { - return rt.RequireAll("group", "topic-id") - }, Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — fetch the topic replies. Param keys (openconversationId / // topicId / startTime / pageSize) are copied verbatim from chat.go's diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index ff0816ed..301ae581 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -54,19 +54,43 @@ const ( // before the Execute hook runs. type Flag struct { // Name is the long flag name (kebab-case), e.g. "user-ids". - Name string + Name string `json:"name"` // Type is the value type; defaults to FlagString when empty. - Type FlagType + Type FlagType `json:"type"` // Default is the default value rendered as a string. - Default string + Default string `json:"default"` // Desc is the help text shown in --help. - Desc string + Desc string `json:"description"` // Required, when true, makes the framework error if the flag is not set. - Required bool + Required bool `json:"required"` // Enum, when non-empty, restricts the accepted values (string flags only). - Enum []string + Enum []string `json:"enum"` // Hidden hides the flag from --help while keeping it usable. - Hidden bool + Hidden bool `json:"-"` +} + +// ConstraintKind is a machine-readable cross-parameter or custom validation +// rule published by `dws shortcut list` and rendered in leaf help. +type ConstraintKind string + +const ( + // ConstraintAtLeastOne requires one or more of Flags. + ConstraintAtLeastOne ConstraintKind = "at_least_one" + // ConstraintExactlyOne requires exactly one of Flags. + ConstraintExactlyOne ConstraintKind = "exactly_one" + // ConstraintMutuallyExclusive permits zero or one of Flags. + ConstraintMutuallyExclusive ConstraintKind = "mutually_exclusive" + // ConstraintCustom documents validation enforced by Shortcut.Validate. + ConstraintCustom ConstraintKind = "custom" +) + +// Constraint declares a shortcut parameter relationship. Known kinds are +// enforced by the runner; custom constraints are enforced by Validate and must +// include a concrete Description. +type Constraint struct { + Kind ConstraintKind `json:"kind"` + Flags []string `json:"flags"` + Description string `json:"description,omitempty"` } // Shortcut is the declarative definition of a single high-fidelity command. @@ -95,6 +119,10 @@ type Shortcut struct { Risk Risk // Flags are the command-specific flags. Global flags are injected separately. Flags []Flag + // Constraints publish and enforce relationships that individual flags cannot + // express, such as "exactly one of --group and --user". Custom constraints + // describe checks implemented by Validate. + Constraints []Constraint // Tips are optional usage examples appended to --help. Tips []string // Hidden hides the command from listings while keeping it invocable. diff --git a/internal/shortcut/usage/command.go b/internal/shortcut/usage/command.go index f806d178..8190a14a 100644 --- a/internal/shortcut/usage/command.go +++ b/internal/shortcut/usage/command.go @@ -45,18 +45,12 @@ func NewShortcutCommand() *cobra.Command { func newListCommand() *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "列出内建 shortcut", + Short: "列出内建 shortcut 及其完整参数契约", + Long: "列出独立于 Runtime Schema 的 shortcut catalog。每条记录包含公开参数、" + + "必填/枚举约束、跨参数约束与可执行示例;也可用 dws + --help 查看单条契约。", RunE: func(cmd *cobra.Command, _ []string) error { svc, _ := cmd.Flags().GetString("service") - type row struct { - Service string `json:"service"` - Command string `json:"command"` - Product string `json:"product"` - Risk string `json:"risk"` - Description string `json:"description"` - Intent string `json:"intent,omitempty"` - } - var rows []row + rows := make([]shortcutListRow, 0) for _, s := range shortcut.All() { if svc != "" && s.Service != svc { continue @@ -64,27 +58,13 @@ func newListCommand() *cobra.Command { if s.Hidden { continue } - product := s.Product - if product == "" { - product = s.Service - } - risk := string(s.Risk) - if risk == "" { - risk = string(shortcut.RiskRead) - } - item := row{ - Service: s.Service, - Command: s.Command, - Product: product, - Risk: risk, - Description: s.Description, - Intent: s.Intent, - } - rows = append(rows, item) + rows = append(rows, newShortcutListRow(s)) } return output.WriteCommandPayload(cmd, map[string]any{ - "count": len(rows), - "shortcuts": rows, + "catalog": "shortcut", + "runtime_schema": false, + "count": len(rows), + "shortcuts": rows, }, output.FormatJSON) }, } @@ -92,6 +72,63 @@ func newListCommand() *cobra.Command { return cmd } +type shortcutListRow struct { + Service string `json:"service"` + Command string `json:"command"` + CLIPath string `json:"cli_path"` + Product string `json:"product"` + Risk string `json:"risk"` + Confirmation string `json:"confirmation"` + Description string `json:"description"` + Intent string `json:"intent,omitempty"` + Flags []shortcut.Flag `json:"flags"` + Constraints []shortcut.Constraint `json:"constraints"` + Examples []string `json:"examples"` +} + +func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { + product := s.Product + if product == "" { + product = s.Service + } + risk := string(s.Risk) + if risk == "" { + risk = string(shortcut.RiskRead) + } + confirmation := "not_required" + if risk != string(shortcut.RiskRead) { + confirmation = "user_required" + } + flags := make([]shortcut.Flag, 0, len(s.Flags)) + for _, flag := range s.Flags { + if flag.Hidden { + continue + } + if flag.Type == "" { + flag.Type = shortcut.FlagString + } + if flag.Enum == nil { + flag.Enum = []string{} + } + flags = append(flags, flag) + } + constraints := append([]shortcut.Constraint{}, s.Constraints...) + examples := append([]string{}, s.Tips...) + return shortcutListRow{ + Service: s.Service, + Command: s.Command, + CLIPath: s.Service + " " + s.Command, + Product: product, + Risk: risk, + Confirmation: confirmation, + Description: s.Description, + Intent: s.Intent, + Flags: flags, + Constraints: constraints, + Examples: examples, + } +} + func newStatsCommand() *cobra.Command { cmd := &cobra.Command{ Use: "stats", diff --git a/internal/shortcut/usage/usage_test.go b/internal/shortcut/usage/usage_test.go index bebeb3e3..7387e583 100644 --- a/internal/shortcut/usage/usage_test.go +++ b/internal/shortcut/usage/usage_test.go @@ -15,10 +15,50 @@ package usage import ( "os" + "reflect" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -func TestEnabledToggle(t *testing.T) { +func TestCrossPlatformCoverageShortcutListRowPublishesCompleteContract(t *testing.T) { + row := newShortcutListRow(shortcut.Shortcut{ + Service: "chat", + Command: "+messages", + Flags: []shortcut.Flag{ + {Name: "group", Required: true}, + {Name: "internal", Hidden: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "user"}}, + }, + Tips: []string{"dws chat +messages --group cid"}, + }) + if row.CLIPath != "chat +messages" || row.Product != "chat" || row.Risk != "read" || row.Confirmation != "not_required" { + t.Fatalf("unexpected normalized identity: %#v", row) + } + if len(row.Flags) != 1 || row.Flags[0].Name != "group" || row.Flags[0].Type != shortcut.FlagString { + t.Fatalf("public normalized flags = %#v", row.Flags) + } + if row.Flags[0].Enum == nil { + t.Fatal("empty enum must publish as [] rather than null") + } + if !reflect.DeepEqual(row.Examples, []string{"dws chat +messages --group cid"}) { + t.Fatalf("examples = %#v", row.Examples) + } + if len(row.Constraints) != 1 || row.Constraints[0].Kind != shortcut.ConstraintExactlyOne { + t.Fatalf("constraints = %#v", row.Constraints) + } +} + +func TestCrossPlatformCoverageShortcutListRowRequiresConfirmationForWrites(t *testing.T) { + row := newShortcutListRow(shortcut.Shortcut{Service: "chat", Command: "+send", Risk: shortcut.RiskWrite}) + if row.Risk != "write" || row.Confirmation != "user_required" { + t.Fatalf("write shortcut safety = risk %q confirmation %q", row.Risk, row.Confirmation) + } +} + +func TestCrossPlatformCoverageEnabledToggle(t *testing.T) { for _, v := range []string{"", "0", "false", "off", "NO", "garbage"} { t.Setenv("DWS_USAGE_TRACKING", v) if Enabled() { @@ -33,7 +73,7 @@ func TestEnabledToggle(t *testing.T) { } } -func TestSampleArgsRedaction(t *testing.T) { +func TestCrossPlatformCoverageSampleArgsRedaction(t *testing.T) { args := map[string]any{ "open_conversation_id": "cid_abc123", // ID-like → kept "page": 20, // number → kept @@ -67,7 +107,7 @@ func TestSampleArgsRedaction(t *testing.T) { } } -func TestAggregateRequiresSampleOnEveryOccurrenceForFixedArg(t *testing.T) { +func TestCrossPlatformCoverageAggregateRequiresSampleOnEveryOccurrenceForFixedArg(t *testing.T) { recs := []Record{ {Product: "chat", Tool: "send", ArgKeys: []string{"conversationId"}, SampleArgs: map[string]string{"conversationId": "cid_x"}}, {Product: "chat", Tool: "send", ArgKeys: []string{"conversationId"}}, @@ -81,7 +121,7 @@ func TestAggregateRequiresSampleOnEveryOccurrenceForFixedArg(t *testing.T) { } } -func TestAppendAndAggregate(t *testing.T) { +func TestCrossPlatformCoverageAppendAndAggregate(t *testing.T) { dir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", dir) t.Setenv("DWS_USAGE_TRACKING", "1") @@ -129,7 +169,7 @@ func TestAppendAndAggregate(t *testing.T) { } } -func TestDisabledSkipsWrite(t *testing.T) { +func TestCrossPlatformCoverageDisabledSkipsWrite(t *testing.T) { dir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", dir) t.Setenv("DWS_USAGE_TRACKING", "0") diff --git a/internal/shortcut/userdef/loader_test.go b/internal/shortcut/userdef/loader_test.go index ba0a330c..fd802cf5 100644 --- a/internal/shortcut/userdef/loader_test.go +++ b/internal/shortcut/userdef/loader_test.go @@ -37,7 +37,7 @@ func (c *captureCaller) DryRun() bool { return false } func (c *captureCaller) Fields() string { return "" } func (c *captureCaller) JQ() string { return "" } -func TestValidate(t *testing.T) { +func TestCrossPlatformCoverageValidate(t *testing.T) { cases := []struct { name string s Spec @@ -56,7 +56,7 @@ func TestValidate(t *testing.T) { } } -func TestCompileFlagsAndDefaults(t *testing.T) { +func TestCrossPlatformCoverageCompileFlagsAndDefaults(t *testing.T) { s := Spec{ Service: "chat", Command: "+notify-team", Product: "chat", Exec: ExecSpec{Tool: "send_message", Bind: map[string]string{ @@ -83,7 +83,7 @@ func TestCompileFlagsAndDefaults(t *testing.T) { } } -func TestFlagRef(t *testing.T) { +func TestCrossPlatformCoverageFlagRef(t *testing.T) { if n, ok := flagRef("${text}"); !ok || n != "text" { t.Errorf("flagRef(${text}) = %q,%v", n, ok) } @@ -92,7 +92,7 @@ func TestFlagRef(t *testing.T) { } } -func TestCompileBindsOptionalDefault(t *testing.T) { +func TestCrossPlatformCoverageCompileBindsOptionalDefault(t *testing.T) { s := Spec{ Service: "defaulttest", Command: "+run", Product: "chat", Exec: ExecSpec{Tool: "send_message", Bind: map[string]string{"limit": "${limit}"}}, @@ -118,7 +118,7 @@ func TestCompileBindsOptionalDefault(t *testing.T) { } } -func TestFilePathRejectsTraversal(t *testing.T) { +func TestCrossPlatformCoverageFilePathRejectsTraversal(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", t.TempDir()) if _, err := FilePath("../../outside", "+run"); err == nil { t.Fatal("expected traversal service to be rejected") @@ -136,7 +136,7 @@ func TestFilePathRejectsTraversal(t *testing.T) { } } -func TestLoadSkipsConflictAndParses(t *testing.T) { +func TestCrossPlatformCoverageLoadSkipsConflictAndParses(t *testing.T) { dir := t.TempDir() t.Setenv("DWS_CONFIG_DIR", dir) scDir := filepath.Join(dir, "shortcuts") diff --git a/internal/shortcut/validate.go b/internal/shortcut/validate.go index 1fea10e2..ab8f56a4 100644 --- a/internal/shortcut/validate.go +++ b/internal/shortcut/validate.go @@ -25,12 +25,30 @@ import ( // AtLeastOne / ExactlyOne / range checks) so per-command validation reads the // same everywhere instead of being hand-rolled each time. -// setFlags returns the subset of the given flag names the user actually set. +// setFlags returns the subset of the given flag names for which the user +// supplied a meaningful value. Empty strings and empty string slices do not +// satisfy cross-field constraints merely because the flag token was present. func (rt *RuntimeContext) setFlags(flags ...string) []string { var set []string - for _, f := range flags { - if rt.Changed(f) { - set = append(set, f) + for _, name := range flags { + if !rt.Changed(name) { + continue + } + provided := true + for _, flag := range rt.shortcut.Flags { + if flag.Name != name { + continue + } + switch flag.Type { + case FlagStringSlice: + provided = hasNonEmptyString(rt.StrSlice(name)) + case FlagString, "": + provided = rt.Str(name) != "" + } + break + } + if provided { + set = append(set, name) } } return set diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 5621226e..3d7f748e 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -6,6 +6,7 @@ from __future__ import annotations import datetime as dt import json import re +import subprocess from pathlib import Path from typing import Any @@ -99,13 +100,15 @@ def write_go(rows: list[dict[str, Any]]) -> None: "", "// publicShortcutCatalog contains the generated shortcut surface used by", "// command discovery and skill generation.", - "var publicShortcutCatalog = map[string]struct{}{", + "func generatedPublicShortcutCatalog() map[string]struct{} {", + "\treturn map[string]struct{}{", ] for row in rows: key = f"{row['service']}\x00{row['command']}" - lines.append(f"\t{go_quote(key)}: {{}},") - lines.extend(["}", ""]) + lines.append(f"\t\t{go_quote(key)}: {{}},") + lines.extend(["\t}", "}", ""]) GO_PATH.write_text("\n".join(lines), encoding="utf-8") + subprocess.run(["gofmt", "-w", str(GO_PATH)], check=True) def md_escape(s: str) -> str: @@ -138,20 +141,38 @@ def write_followup_md(rows: list[dict[str, Any]]) -> None: f"{md_escape(squish(basis, 160))} |" ) lines.append("") - FOLLOWUP_MD_PATH.write_text("\n".join(lines), encoding="utf-8") + rendered = "\n".join(lines) + if FOLLOWUP_MD_PATH.exists(): + existing = FOLLOWUP_MD_PATH.read_text(encoding="utf-8") + without_time = re.compile(r"生成时间:`[^`]+`") + if without_time.sub("生成时间:``", existing) == without_time.sub( + "生成时间:``", rendered + ): + return + FOLLOWUP_MD_PATH.write_text(rendered, encoding="utf-8") def write_json(public: list[dict[str, Any]], followups: list[dict[str, Any]]) -> None: generated = dt.datetime.now().isoformat() - CATALOG_PATH.write_text(json.dumps({ - "generated_at": generated, + write_json_if_changed(CATALOG_PATH, { "count": len(public), "results": public, - }, ensure_ascii=False, indent=2), encoding="utf-8") - FOLLOWUP_JSON_PATH.write_text(json.dumps({ - "generated_at": generated, + }, generated) + write_json_if_changed(FOLLOWUP_JSON_PATH, { "count": len(followups), "results": followups, + }, generated) + + +def write_json_if_changed(path: Path, payload: dict[str, Any], generated: str) -> None: + if path.exists(): + existing = load(path) + existing.pop("generated_at", None) + if existing == payload: + return + path.write_text(json.dumps({ + "generated_at": generated, + **payload, }, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/scripts/gen_shortcut_test_matrix.go b/scripts/gen_shortcut_test_matrix.go index 91897686..53272ac2 100644 --- a/scripts/gen_shortcut_test_matrix.go +++ b/scripts/gen_shortcut_test_matrix.go @@ -1,3 +1,5 @@ +//go:build ignore + // Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0. diff --git a/scripts/gen_skill_shortcut_sections.py b/scripts/gen_skill_shortcut_sections.py index be4c34b8..a6a908c4 100644 --- a/scripts/gen_skill_shortcut_sections.py +++ b/scripts/gen_skill_shortcut_sections.py @@ -2,8 +2,8 @@ """Generate shortcut sections for DWS skills. The skill should teach agents which high-level shortcut entries are available -in the public catalog, while `dws --help` remains the source of truth -for flags. +in the public catalog. `dws shortcut list` publishes their machine-readable +contract, while leaf `--help` remains the source of truth for accepted flags. """ from __future__ import annotations @@ -89,12 +89,12 @@ def mono_overview(items: list[dict[str, Any]]) -> str: for service, count in sorted(counts.items()): path = SERVICE_TO_SKILL.get(service) skill = path.parent.name if path else "—" - rows.append(f"| `{md_escape(service)}` | {count} | `{md_escape(skill)}` | `dws {md_escape(service)} --help` / `dws shortcut list --service {md_escape(service)} --format json` |") + rows.append(f"| `{md_escape(service)}` | {count} | `{md_escape(skill)}` | `dws shortcut list --service {md_escape(service)} --format json` |") body = "\n".join(rows) return f"""{MONO_START} ## Shortcut 总览 -下面统计当前公开 catalog 中的 shortcut。mono 模式不展开 200+ 行明细,避免 skill 过重;需要执行时先按产品路由,再用 `dws --help` 查看 flags。multi 模式的各产品 skill 会展开该产品的 shortcut 表。 +下面统计当前公开 catalog 中的 shortcut。mono 模式不展开 200+ 行明细,避免 skill 过重;需要执行时先按产品路由,再用 `dws shortcut list --service --format json` 读取参数、约束、风险和示例,最后用 `dws + --help` 核对当前 Cobra flags。multi 模式的各产品 skill 会展开该产品的 shortcut 表。 | 服务 | shortcut 数 | multi skill | 发现命令 | |---|---:|---|---| @@ -110,9 +110,9 @@ def product_section(service: str, rows: list[dict[str, Any]]) -> str: f"{md_escape(item['risk'])} | {md_escape(item['desc'])} |" ) return f"""{PRODUCT_START} -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws {service} --help` / `dws shortcut list --service {service}` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws {service} --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service {service} --format json` 读取参数、约束、风险和示例,并以 `dws {service} --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/scripts/policy/check-coverage-gate.sh b/scripts/policy/check-coverage-gate.sh index 7ffb37f9..32815ceb 100755 --- a/scripts/policy/check-coverage-gate.sh +++ b/scripts/policy/check-coverage-gate.sh @@ -4,6 +4,7 @@ set -eu ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" BASE_REF="" OVERALL_PROFILE="coverage.txt" +ADDITIONAL_PROFILE="${COVERAGE_ADDITIONAL_PROFILE:-}" BASELINE_PROFILE="coverage-base.txt" DIFF_PROFILE="coverage-policy.txt" TARGET="${COVERAGE_TARGET:-80}" @@ -13,7 +14,7 @@ CHANGED_ONLY="false" SCOPE_BUILDABLE="false" usage() { - printf '%s\n' "usage: $0 --base-ref [--changed-only] [--scope-buildable] [--overall-profile ] [--baseline-profile ] [--diff-profile ]" >&2 + printf '%s\n' "usage: $0 --base-ref [--changed-only] [--scope-buildable] [--overall-profile ] [--additional-profile ] [--baseline-profile ] [--diff-profile ]" >&2 } while [ "$#" -gt 0 ]; do @@ -28,6 +29,11 @@ while [ "$#" -gt 0 ]; do OVERALL_PROFILE="$2" shift 2 ;; + --additional-profile) + [ "$#" -ge 2 ] || { usage; exit 2; } + ADDITIONAL_PROFILE="$2" + shift 2 + ;; --baseline-profile) [ "$#" -ge 2 ] || { usage; exit 2; } BASELINE_PROFILE="$2" @@ -82,6 +88,11 @@ else --overall-profile "$OVERALL_PROFILE" \ --diff-profile "$OVERALL_PROFILE" \ --baseline-overall "$baseline" + if [ -n "$ADDITIONAL_PROFILE" ]; then + set -- "$@" \ + --overall-profile "$ADDITIONAL_PROFILE" \ + --diff-profile "$ADDITIONAL_PROFILE" + fi fi if [ "$SCOPE_BUILDABLE" = "true" ]; then set -- "$@" --scope-buildable diff --git a/scripts/policy/coverage-gate/main.go b/scripts/policy/coverage-gate/main.go index 137b4113..83f6b1ea 100644 --- a/scripts/policy/coverage-gate/main.go +++ b/scripts/policy/coverage-gate/main.go @@ -77,7 +77,7 @@ func run( changedLoader func(string) (map[string][]lineRange, error), buildableLoader func() (map[string]bool, error), ) int { - var overallPath string + var overallPaths stringList var diffPaths stringList var baseRef string var modulePath string @@ -89,7 +89,7 @@ func run( var scopeBuildable bool flags := flag.NewFlagSet("coverage-gate", flag.ContinueOnError) flags.SetOutput(stderr) - flags.StringVar(&overallPath, "overall-profile", "", "coverage profile used for overall coverage") + flags.Var(&overallPaths, "overall-profile", "coverage profile used for overall coverage (repeatable)") flags.Var(&diffPaths, "diff-profile", "coverage profile used for changed-code coverage (repeatable)") flags.StringVar(&baseRef, "base-ref", "", "Git merge-base or previous main SHA") flags.StringVar(&modulePath, "module", "", "Go module path used to normalize profile filenames") @@ -103,14 +103,14 @@ func run( return 2 } - if len(diffPaths) == 0 || baseRef == "" || modulePath == "" || (!changedOnly && (overallPath == "" || baselineOverall < 0)) { + if len(diffPaths) == 0 || baseRef == "" || modulePath == "" || (!changedOnly && (len(overallPaths) == 0 || baselineOverall < 0)) { fmt.Fprintln(stderr, "coverage-gate requires --diff-profile, --base-ref, and --module; overall mode also requires --overall-profile and --baseline-overall") return 2 } var overall []coverageBlock if !changedOnly { var err error - overall, err = readProfiles([]string{overallPath}, modulePath) + overall, err = readProfiles(overallPaths, modulePath) if err != nil { fmt.Fprintln(stderr, err) return 2 @@ -183,17 +183,11 @@ func evaluate(input gateInput) gateResult { profiledFiles := map[string]bool{} covered, total := 0, 0 - seen := map[string]bool{} - for _, block := range input.Diff { + for _, block := range mergeCoverageBlocks(input.Diff) { profiledFiles[block.File] = true if !intersectsAny(block, input.Changed[block.File]) { continue } - key := fmt.Sprintf("%s:%d:%d:%d", block.File, block.StartLine, block.EndLine, block.Statements) - if seen[key] { - continue - } - seen[key] = true total += block.Statements if block.Count > 0 { covered += block.Statements @@ -276,7 +270,7 @@ func readProfiles(paths []string, modulePath string) ([]coverageBlock, error) { } func gitChangedLines(baseRef string) (map[string][]lineRange, error) { - command := exec.Command("git", "diff", "--unified=0", "--no-color", baseRef+"...HEAD", "--", "*.go") + command := exec.Command("git", "diff", "--unified=0", "--no-color", baseRef, "--", "*.go") output, err := command.Output() if err != nil { var exitErr *exec.ExitError @@ -389,7 +383,7 @@ func intersectsAny(block coverageBlock, ranges []lineRange) bool { func coveragePercent(blocks []coverageBlock) float64 { covered, total := 0, 0 - for _, block := range blocks { + for _, block := range mergeCoverageBlocks(blocks) { total += block.Statements if block.Count > 0 { covered += block.Statements @@ -401,4 +395,29 @@ func coveragePercent(blocks []coverageBlock) float64 { return float64(covered) * 100 / float64(total) } +// mergeCoverageBlocks unions duplicate blocks emitted by cross-package +// coverage. A block is covered when any instrumented test binary executed it; +// counting the same source block once per binary would both dilute the overall +// percentage and make the result depend on package execution order. +func mergeCoverageBlocks(blocks []coverageBlock) []coverageBlock { + merged := make(map[string]coverageBlock, len(blocks)) + for _, block := range blocks { + key := fmt.Sprintf("%s:%d:%d:%d", block.File, block.StartLine, block.EndLine, block.Statements) + current, ok := merged[key] + if !ok || block.Count > current.Count { + merged[key] = block + } + } + keys := make([]string, 0, len(merged)) + for key := range merged { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]coverageBlock, 0, len(keys)) + for _, key := range keys { + result = append(result, merged[key]) + } + return result +} + func roundOne(value float64) float64 { return math.Round(value*10) / 10 } diff --git a/scripts/policy/coverage-gate/main_test.go b/scripts/policy/coverage-gate/main_test.go index b7fdf20d..11f1770e 100644 --- a/scripts/policy/coverage-gate/main_test.go +++ b/scripts/policy/coverage-gate/main_test.go @@ -54,6 +54,38 @@ func TestRun(t *testing.T) { } } +func TestCrossPlatformCoverageRunUnionsRepeatedOverallProfiles(t *testing.T) { + dir := t.TempDir() + uncovered := filepath.Join(dir, "uncovered.out") + covered := filepath.Join(dir, "covered.out") + if err := os.WriteFile(uncovered, []byte("mode: atomic\nexample.com/project/internal/a.go:10.1,12.2 5 0\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(covered, []byte("mode: atomic\nexample.com/project/internal/a.go:10.1,12.2 5 1\n"), 0o600); err != nil { + t.Fatal(err) + } + args := []string{ + "--overall-profile", uncovered, + "--overall-profile", covered, + "--diff-profile", uncovered, + "--diff-profile", covered, + "--base-ref", "base", + "--module", "example.com/project", + "--baseline-overall", "100", + "--target", "80", + } + loader := func(string) (map[string][]lineRange, error) { + return map[string][]lineRange{"internal/a.go": {{Start: 10, End: 12}}}, nil + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, loader, nil); code != 0 { + t.Fatalf("run code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "overall coverage: 100.0%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") { + t.Fatalf("repeated profiles were not unioned: %q", stdout.String()) + } +} + func TestRunChangedOnlyWithBuildableScope(t *testing.T) { profile := filepath.Join(t.TempDir(), "coverage.out") body := "mode: atomic\nexample.com/project/internal/a.go:10.1,12.2 5 1\n" @@ -153,6 +185,9 @@ func TestGitChangedLines(t *testing.T) { } runGit("add", "a.go") runGit("commit", "-m", "change") + if err := os.WriteFile(path, []byte("package sample\n\nfunc added() int { return 1 }\nfunc workingTree() int { return 2 }\n"), 0o600); err != nil { + t.Fatal(err) + } previous, err := os.Getwd() if err != nil { @@ -169,6 +204,10 @@ func TestGitChangedLines(t *testing.T) { if len(changed["a.go"]) == 0 { t.Fatalf("changed lines=%v", changed) } + last := changed["a.go"][len(changed["a.go"])-1] + if last.End < 4 { + t.Fatalf("working-tree line was not included: %v", changed["a.go"]) + } } func TestParseChangedLines(t *testing.T) { @@ -218,6 +257,24 @@ func TestEvaluateAllowsNonRegressionAndCoveredChanges(t *testing.T) { } } +func TestCrossPlatformCoverageUnionsDuplicateCrossPackageCoverageBlocks(t *testing.T) { + duplicate := coverageBlock{File: "internal/a.go", StartLine: 10, EndLine: 12, Statements: 4} + covered := duplicate + covered.Count = 3 + result := evaluate(gateInput{ + Overall: []coverageBlock{duplicate, covered}, + Diff: []coverageBlock{duplicate, covered}, + Changed: map[string][]lineRange{"internal/a.go": {{Start: 10, End: 12}}}, + Target: 80, + }) + if len(result.Failures) != 0 { + t.Fatalf("duplicate blocks should be unioned: %v", result.Failures) + } + if result.Overall != 100 || result.ChangedCoverage != 100 || result.ChangedStatements != 4 { + t.Fatalf("result = %#v, want one fully covered four-statement block", result) + } +} + func TestEvaluateAllowsMeasurementTolerance(t *testing.T) { result := evaluate(gateInput{ Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}}, @@ -233,7 +290,10 @@ func TestEvaluateAllowsMeasurementTolerance(t *testing.T) { func TestEvaluateFailsClosed(t *testing.T) { result := evaluate(gateInput{ - Overall: []coverageBlock{{Statements: 10, Count: 1}, {Statements: 10, Count: 0}}, + Overall: []coverageBlock{ + {File: "internal/base.go", StartLine: 1, EndLine: 2, Statements: 10, Count: 1}, + {File: "internal/base.go", StartLine: 3, EndLine: 4, Statements: 10, Count: 0}, + }, Diff: []coverageBlock{{File: "internal/a.go", StartLine: 1, EndLine: 2, Statements: 2, Count: 0}}, Changed: map[string][]lineRange{"internal/a.go": {{Start: 1, End: 2}}, "internal/missing.go": {{Start: 1, End: 1}}}, BaselineOverall: 54.2, diff --git a/scripts/policy/run-platform-coverage-gate.sh b/scripts/policy/run-platform-coverage-gate.sh index ac2a1900..6f2eed1d 100755 --- a/scripts/policy/run-platform-coverage-gate.sh +++ b/scripts/policy/run-platform-coverage-gate.sh @@ -53,7 +53,7 @@ cleanup() { trap cleanup EXIT HUP INT TERM : >"$PACKAGES" -git diff --name-only --diff-filter=ACMR "$BASE_REF...HEAD" -- '*.go' | +git diff --name-only --diff-filter=ACMR "$BASE_REF" -- '*.go' | while IFS= read -r file; do case "$file" in *_test.go|test/*) continue ;; @@ -69,8 +69,16 @@ git diff --name-only --diff-filter=ACMR "$BASE_REF...HEAD" -- '*.go' | sort -u "$PACKAGES" >"$SORTED_PACKAGES" set -- +COVERPKG="" while IFS= read -r package; do - [ -n "$package" ] && set -- "$@" "$package" + if [ -n "$package" ]; then + set -- "$@" "$package" + if [ -z "$COVERPKG" ]; then + COVERPKG="$package" + else + COVERPKG="$COVERPKG,$package" + fi + fi done <"$SORTED_PACKAGES" if [ "$#" -eq 0 ]; then @@ -79,7 +87,17 @@ else printf 'native changed coverage packages:' printf ' %s' "$@" printf '\n' - go test -count=1 -timeout="$TIMEOUT" -coverprofile="$PROFILE" -covermode=atomic "$@" + # The shortcut integration harness lives in internal/shortcut/builtin but + # executes code from every product package. Cross-package instrumentation is + # required for those real executions to count toward the owning source file. + # + # Platform runners intentionally execute only the exhaustive shortcut + # harness and explicitly cross-platform tests. All changed production + # packages remain instrumented, so unexercised statements still count as + # uncovered. This keeps the native Windows gate independent of unrelated + # tests that require Unix permissions, shell scripts, or process semantics. + go test -count=1 -timeout="$TIMEOUT" -run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \ + -coverpkg="$COVERPKG" -coverprofile="$PROFILE" -covermode=atomic "$@" fi ./scripts/policy/check-coverage-gate.sh \ diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 622be700..293f04f0 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -9,7 +9,7 @@ cli_version: ">=1.0.15" 通过 `dws` 命令管理钉钉产品能力。 -> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws --help` 不存在,说明当前版本未暴露该命令。`--help` 决定 Cobra 实际接受的 flags;对应 leaf Schema 决定 Agent 选择、参数映射/约束和安全确认语义。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 +> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws --help` 不存在,说明当前版本未暴露该命令。`--help` 决定 Cobra 实际接受的 flags;基础命令的 leaf Schema 决定 Agent 选择、参数映射/约束和安全确认语义,`+` shortcut 则读取独立 shortcut catalog。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 ## 严格禁止 (NEVER DO) - 不要使用 dws 命令以外的方式操作(禁止 curl、HTTP API、浏览器) @@ -28,8 +28,10 @@ cli_version: ">=1.0.15" `shortcut` 是对常用操作的高层封装,适合优先承担用户意图;产品参考文档和本 skill 负责判断意图、风险、跨产品流程和复杂参数,CLI 帮助负责声明当前版本真正可调用的命令。 -- 用户意图可由可见 shortcut 满足时,优先使用 `dws + ... --format json`。 -- 调用前用 `dws --help` 或 `dws shortcut list --service --format json` 确认可见命令;不要凭文档或记忆猜测 shortcut。 +- 先按产品参考、意图表和 recipe 路由。存在精确覆盖场景的专用脚本/recipe 时继续遵循“脚本优先”;否则用户意图可由可见 shortcut 满足时,优先使用 `dws + ... --format json`,不要手写等价的多步原子命令。 +- shortcut 有独立 catalog,按设计不进入 Runtime Schema。调用前用 `dws shortcut list --service --format json` 读取完整 flags、required/enum、跨参数 constraints、risk/confirmation 和 examples;不要对 `+` 路径调用 `dws schema`。 +- 真正组装参数前用叶子帮助 `dws + --help` 核对当前 Cobra 接受的 flags。父级 `dws --help` 只能发现子命令,不能替代叶子参数帮助。 +- shortcut catalog 中 `confirmation=user_required` 时,必须先获得用户确认,确认后才加 `--yes`;`not_required` 不额外确认。 - 如果 shortcut 不在 help / list 中,改用产品参考里的原子命令、脚本或标准流程;不要猜测未展示的 `+` 命令。 - shortcut 失败时按“错误处理”流程先加 `--verbose` 复查;若仍失败,应记录具体输入、输出、trace / endpoint / tool 信息。 @@ -37,26 +39,26 @@ cli_version: ">=1.0.15" ## Shortcut 总览 -下面统计当前公开 catalog 中的 shortcut。mono 模式不展开 200+ 行明细,避免 skill 过重;需要执行时先按产品路由,再用 `dws --help` 查看 flags。multi 模式的各产品 skill 会展开该产品的 shortcut 表。 +下面统计当前公开 catalog 中的 shortcut。mono 模式不展开 200+ 行明细,避免 skill 过重;需要执行时先按产品路由,再用 `dws shortcut list --service --format json` 读取参数、约束、风险和示例,最后用 `dws + --help` 核对当前 Cobra flags。multi 模式的各产品 skill 会展开该产品的 shortcut 表。 | 服务 | shortcut 数 | multi skill | 发现命令 | |---|---:|---|---| -| `aitable` | 29 | `dingtalk-aitable` | `dws aitable --help` / `dws shortcut list --service aitable --format json` | -| `attendance` | 19 | `dingtalk-attendance` | `dws attendance --help` / `dws shortcut list --service attendance --format json` | -| `calendar` | 20 | `dingtalk-calendar` | `dws calendar --help` / `dws shortcut list --service calendar --format json` | -| `chat` | 42 | `dingtalk-chat` | `dws chat --help` / `dws shortcut list --service chat --format json` | -| `contact` | 14 | `dingtalk-contact` | `dws contact --help` / `dws shortcut list --service contact --format json` | -| `devapp` | 19 | `dingtalk-dev` | `dws devapp --help` / `dws shortcut list --service devapp --format json` | -| `ding` | 4 | `dingtalk-ding` | `dws ding --help` / `dws shortcut list --service ding --format json` | -| `doc` | 17 | `dingtalk-doc` | `dws doc --help` / `dws shortcut list --service doc --format json` | -| `drive` | 7 | `dingtalk-drive` | `dws drive --help` / `dws shortcut list --service drive --format json` | -| `mail` | 10 | `dingtalk-mail` | `dws mail --help` / `dws shortcut list --service mail --format json` | -| `minutes` | 6 | `dingtalk-minutes` | `dws minutes --help` / `dws shortcut list --service minutes --format json` | -| `oa` | 7 | `dingtalk-oa` | `dws oa --help` / `dws shortcut list --service oa --format json` | -| `report` | 2 | `dingtalk-report` | `dws report --help` / `dws shortcut list --service report --format json` | -| `sheet` | 2 | `dingtalk-sheet` | `dws sheet --help` / `dws shortcut list --service sheet --format json` | -| `todo` | 11 | `dingtalk-todo` | `dws todo --help` / `dws shortcut list --service todo --format json` | -| `wiki` | 1 | `dingtalk-wiki` | `dws wiki --help` / `dws shortcut list --service wiki --format json` | +| `aitable` | 29 | `dingtalk-aitable` | `dws shortcut list --service aitable --format json` | +| `attendance` | 19 | `dingtalk-attendance` | `dws shortcut list --service attendance --format json` | +| `calendar` | 20 | `dingtalk-calendar` | `dws shortcut list --service calendar --format json` | +| `chat` | 42 | `dingtalk-chat` | `dws shortcut list --service chat --format json` | +| `contact` | 14 | `dingtalk-contact` | `dws shortcut list --service contact --format json` | +| `devapp` | 19 | `dingtalk-dev` | `dws shortcut list --service devapp --format json` | +| `ding` | 4 | `dingtalk-ding` | `dws shortcut list --service ding --format json` | +| `doc` | 17 | `dingtalk-doc` | `dws shortcut list --service doc --format json` | +| `drive` | 7 | `dingtalk-drive` | `dws shortcut list --service drive --format json` | +| `mail` | 10 | `dingtalk-mail` | `dws shortcut list --service mail --format json` | +| `minutes` | 6 | `dingtalk-minutes` | `dws shortcut list --service minutes --format json` | +| `oa` | 7 | `dingtalk-oa` | `dws shortcut list --service oa --format json` | +| `report` | 2 | `dingtalk-report` | `dws shortcut list --service report --format json` | +| `sheet` | 2 | `dingtalk-sheet` | `dws shortcut list --service sheet --format json` | +| `todo` | 11 | `dingtalk-todo` | `dws shortcut list --service todo --format json` | +| `wiki` | 1 | `dingtalk-wiki` | `dws shortcut list --service wiki --format json` | ## 产品总览 @@ -165,6 +167,8 @@ Step 3 → 加 --yes 执行命令 `dws schema` 内嵌当前二进制公开命令面的结构化契约。**Agent 选择命令、读取参数映射/约束和安全语义时必须优先渐进查询 leaf Schema**;真正组装执行参数前,用 `--help` 确认当前 Cobra 接受的 flags: +本节适用于进入 Runtime Schema 的基础/原子命令。`+` shortcut 是明确例外:按“Shortcut 与原子命令的使用原则”读取 `dws shortcut list`,不得把 Schema lookup 失败误判为 shortcut 不可执行。 + 稳定 command identity、主 CLI path 和 alias 已在构建时由 reviewed registry 与真实 Cobra tree 精确绑定。Agent 不应读取 Catalog 文件、native annotation 或其他生成 JSON 来重新推断命令;所有运行时查询都以当前二进制交付的 Schema 投影为准。 ```bash @@ -230,7 +234,8 @@ dws schema --all --format json | 信息 | 事实源 | |------|--------| | 命令是否存在、当前 Cobra 接受哪些 flags | `dws --help` | -| Agent 选择、参数映射/required/组合约束、risk/confirmation | `dws schema ""`(按需加 `--compact`) | +| Agent 选择、参数映射/required/组合约束、risk/confirmation(原子/基础命令) | `dws schema ""`(按需加 `--compact`) | +| shortcut 的参数、组合约束、risk/confirmation、示例 | `dws shortcut list --service --format json` | | 人类可读用法 | `dws --help` | | 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行对应的 `read` / `search` / `list` 命令 | @@ -261,7 +266,7 @@ Schema 与 Help 冲突是**契约漂移**,不得静默猜测或把两边字段 ## 详细参考 (按需读取) -- [references/products/](./references/products/) — 各产品命令详细参考(Cobra 接受的 flag 以 `--help` 为准,Agent 映射/约束/安全语义以 leaf Schema 为准) +- [references/products/](./references/products/) — 各产品命令详细参考(Cobra 接受的 flag 以叶子 `--help` 为准;基础命令的 Agent 映射/约束/安全语义以 leaf Schema 为准,shortcut 以独立 catalog 为准) - [references/intent-guide.md](./references/intent-guide.md) — 意图路由指南(易混淆场景对照) - [references/url-patterns.md](./references/url-patterns.md) — URL 格式规范 + alidocs URL 分流决策与类型探测流程(含钉盘 `document/edit|preview?dentryKey=` 链接) - [references/global-reference.md](./references/global-reference.md) — 全局标志、认证、输出格式 diff --git a/skills/multi/dingtalk-aitable/SKILL.md b/skills/multi/dingtalk-aitable/SKILL.md index 01292e0e..e44505d9 100644 --- a/skills/multi/dingtalk-aitable/SKILL.md +++ b/skills/multi/dingtalk-aitable/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[aitable.md](references/aitable.md);复杂命令按需加载 `references/aitable/*.md`;剧本:[06-data-analytics.md](references/06-data-analytics.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws aitable --help` / `dws shortcut list --service aitable` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws aitable --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service aitable --format json` 读取参数、约束、风险和示例,并以 `dws aitable --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-attendance/SKILL.md b/skills/multi/dingtalk-attendance/SKILL.md index d6e268e1..8cec2498 100644 --- a/skills/multi/dingtalk-attendance/SKILL.md +++ b/skills/multi/dingtalk-attendance/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[attendance.md](references/attendance.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws attendance --help` / `dws shortcut list --service attendance` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws attendance --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service attendance --format json` 读取参数、约束、风险和示例,并以 `dws attendance --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-calendar/SKILL.md b/skills/multi/dingtalk-calendar/SKILL.md index 3375f0aa..8afa1949 100644 --- a/skills/multi/dingtalk-calendar/SKILL.md +++ b/skills/multi/dingtalk-calendar/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[calendar.md](references/calendar.md);剧本:[03-meeting.md](references/03-meeting.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws calendar --help` / `dws shortcut list --service calendar` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws calendar --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service calendar --format json` 读取参数、约束、风险和示例,并以 `dws calendar --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index 3a5d76c7..56970d2c 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[chat.md](references/chat.md);表情:[chat-emoji-list.md](references/chat-emoji-list.md);剧本:[01-messaging.md](references/01-messaging.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws chat --help` / `dws shortcut list --service chat` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws chat --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service chat --format json` 读取参数、约束、风险和示例,并以 `dws chat --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-contact/SKILL.md b/skills/multi/dingtalk-contact/SKILL.md index a1bea4e8..9133ca39 100644 --- a/skills/multi/dingtalk-contact/SKILL.md +++ b/skills/multi/dingtalk-contact/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[contact.md](references/contact.md);剧本:[08-directory.md](references/08-directory.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws contact --help` / `dws shortcut list --service contact` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws contact --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service contact --format json` 读取参数、约束、风险和示例,并以 `dws contact --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-dev/SKILL.md b/skills/multi/dingtalk-dev/SKILL.md index 05522e35..68d7faf8 100644 --- a/skills/multi/dingtalk-dev/SKILL.md +++ b/skills/multi/dingtalk-dev/SKILL.md @@ -27,9 +27,9 @@ metadata: 10. `robot result` 若缺 `unifiedAppId` 或返回 `completionState=BLOCKED_BY_MISSING_UNIFIED_APP_ID`,必须停下要求明确的 `unifiedAppId`;禁止用 `clientId/appKey` 自动反查后继续执行版本写操作 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws devapp --help` / `dws shortcut list --service devapp` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws devapp --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service devapp --format json` 读取参数、约束、风险和示例,并以 `dws devapp --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-ding/SKILL.md b/skills/multi/dingtalk-ding/SKILL.md index 9c22f623..ab744465 100644 --- a/skills/multi/dingtalk-ding/SKILL.md +++ b/skills/multi/dingtalk-ding/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[ding.md](references/ding.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws ding --help` / `dws shortcut list --service ding` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws ding --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service ding --format json` 读取参数、约束、风险和示例,并以 `dws ding --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md index 9efd66d3..31caf8cd 100644 --- a/skills/multi/dingtalk-doc/SKILL.md +++ b/skills/multi/dingtalk-doc/SKILL.md @@ -37,9 +37,9 @@ metadata: - 每次 `create` / `update` / `block insert` / `media insert` 后必须 `dws doc read` 或 `dws doc block list` 回读关键内容。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws doc --help` / `dws shortcut list --service doc` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws doc --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service doc --format json` 读取参数、约束、风险和示例,并以 `dws doc --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-drive/SKILL.md b/skills/multi/dingtalk-drive/SKILL.md index 80119a02..cb6ab84a 100644 --- a/skills/multi/dingtalk-drive/SKILL.md +++ b/skills/multi/dingtalk-drive/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[drive.md](references/drive.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws drive --help` / `dws shortcut list --service drive` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws drive --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service drive --format json` 读取参数、约束、风险和示例,并以 `dws drive --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-mail/SKILL.md b/skills/multi/dingtalk-mail/SKILL.md index 8e8ae8bb..956f644b 100644 --- a/skills/multi/dingtalk-mail/SKILL.md +++ b/skills/multi/dingtalk-mail/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[mail.md](references/mail.md)。复杂搜索、附件、批量处理、草稿等多步邮件场景参考:[09-mail.md](references/09-mail.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws mail --help` / `dws shortcut list --service mail` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws mail --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service mail --format json` 读取参数、约束、风险和示例,并以 `dws mail --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-minutes/SKILL.md b/skills/multi/dingtalk-minutes/SKILL.md index 6bb9d175..cae0fe06 100644 --- a/skills/multi/dingtalk-minutes/SKILL.md +++ b/skills/multi/dingtalk-minutes/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[minutes.md](references/minutes.md);剧本:[07-minutes.md](references/07-minutes.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws minutes --help` / `dws shortcut list --service minutes` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws minutes --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service minutes --format json` 读取参数、约束、风险和示例,并以 `dws minutes --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-oa/SKILL.md b/skills/multi/dingtalk-oa/SKILL.md index ac05d876..693e5b9a 100644 --- a/skills/multi/dingtalk-oa/SKILL.md +++ b/skills/multi/dingtalk-oa/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[oa.md](references/oa.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws oa --help` / `dws shortcut list --service oa` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws oa --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service oa --format json` 读取参数、约束、风险和示例,并以 `dws oa --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-report/SKILL.md b/skills/multi/dingtalk-report/SKILL.md index 8f467e57..d96f802d 100644 --- a/skills/multi/dingtalk-report/SKILL.md +++ b/skills/multi/dingtalk-report/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[report.md](references/report.md);剧本:[05-reporting.md](references/05-reporting.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws report --help` / `dws shortcut list --service report` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws report --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service report --format json` 读取参数、约束、风险和示例,并以 `dws report --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-sheet/SKILL.md b/skills/multi/dingtalk-sheet/SKILL.md index c17a8d3e..07d7b2cb 100644 --- a/skills/multi/dingtalk-sheet/SKILL.md +++ b/skills/multi/dingtalk-sheet/SKILL.md @@ -28,9 +28,9 @@ metadata: 输入含 `alidocs.dingtalk.com` URL 时,该域名下存在多种路径格式:`/i/p/...`(分享短链)、`/i/nodes/...`(节点链接,类型需探测)、`/spreadsheetv2/...`(电子表格直链,直接路由到 `sheet`)、`/document/edit|preview?dentryKey=...`(文档链接,路由到 `dingtalk-doc`)等。**必须先读 [url-patterns.md](references/url-patterns.md) 中的「alidocs URL 分流决策」**,按规则识别 URL 类型;仅当确认是在线电子表格(`/spreadsheetv2/...` 或 `i/nodes/` 且 probe 出 `contentType=ALIDOC` + `extension=axls`)时,才继续走本 skill 的命令。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws sheet --help` / `dws shortcut list --service sheet` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws sheet --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service sheet --format json` 读取参数、约束、风险和示例,并以 `dws sheet --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-todo/SKILL.md b/skills/multi/dingtalk-todo/SKILL.md index 46d21083..64c88ff2 100644 --- a/skills/multi/dingtalk-todo/SKILL.md +++ b/skills/multi/dingtalk-todo/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[todo.md](references/todo.md);剧本:[02-task.md](references/02-task.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws todo --help` / `dws shortcut list --service todo` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws todo --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service todo --format json` 读取参数、约束、风险和示例,并以 `dws todo --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dingtalk-wiki/SKILL.md b/skills/multi/dingtalk-wiki/SKILL.md index 940d39a9..f8853f72 100644 --- a/skills/multi/dingtalk-wiki/SKILL.md +++ b/skills/multi/dingtalk-wiki/SKILL.md @@ -24,9 +24,9 @@ metadata: > 命令参考:[wiki.md](references/wiki.md)。 -## Shortcuts(优先使用) +## Shortcuts(无专用脚本/recipe 时优先) -以下 shortcut 来自当前公开 catalog,并可通过 `dws wiki --help` / `dws shortcut list --service wiki` 发现。用户意图命中时优先使用 shortcut;具体 flags 以 `dws wiki --help` 为准。 +以下 shortcut 来自独立于 Runtime Schema 的公开 catalog。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。用 `dws shortcut list --service wiki --format json` 读取参数、约束、风险和示例,并以 `dws wiki --help` 核对当前 Cobra flags;不要对 `+` 路径调用 `dws schema`。 | Shortcut | 风险 | 适用场景 | |---|---|---| diff --git a/skills/multi/dws-shared/SKILL.md b/skills/multi/dws-shared/SKILL.md index 332b9cc0..cdbaae18 100644 --- a/skills/multi/dws-shared/SKILL.md +++ b/skills/multi/dws-shared/SKILL.md @@ -30,6 +30,8 @@ metadata: 选择命令、读取参数映射/组合约束与安全语义时,优先渐进查询当前二进制内嵌的 leaf Schema;真正组装参数前,用 `--help` 确认 Cobra 当前接受的 flags: +本节适用于进入 Runtime Schema 的基础/原子命令。`+` shortcut 是明确例外,使用下方独立 shortcut catalog 契约。 + ```bash dws schema # 产品概览 dws schema calendar # 产品工具列表 @@ -42,15 +44,17 @@ dws schema "calendar event create" --compact # 完整 leaf 契约 | 要确认的信息 | 事实源 | |-------------|--------| | 命令是否存在、Cobra 接受哪些 flags | `dws --help` | -| Agent 选命令、参数映射/约束、risk/confirmation | `dws schema ""` | +| Agent 选命令、参数映射/约束、risk/confirmation(原子/基础命令) | `dws schema ""` | +| shortcut 的参数、约束、risk/confirmation、示例 | `dws shortcut list --service --format json` | | 钉钉中的文档、日程、消息等业务数据 | 真正执行对应的 `read` / `search` / `list` 命令 | Schema 与 Help 冲突属于契约漂移:参数只用 Help 接受的 flags,并报告漂移;安全语义冲突时采用更保守的确认方式。Schema 只描述命令契约,不能替代业务查询。 ## Shortcut 可用性 -- `shortcut` 是对常用操作的高层封装,子 skill 负责意图、风险、跨产品流程和复杂参数;当前 `dws` 二进制的 `--help` / `dws shortcut list` 才是可用命令事实源。 -- 用户意图可由默认可见 shortcut 满足时,优先使用 `dws + ... --format json`。 -- 调用前用 `dws --help` 或 `dws shortcut list --service --format json` 确认可见命令;不要凭 skill 文档或记忆猜测 shortcut。 +- `shortcut` 是对常用操作的高层封装。先按产品 skill 的意图表、脚本和 recipe 路由:存在精确覆盖场景的专用脚本/recipe 时按其执行;否则可见 shortcut 优先于手写等价原子流程。 +- shortcut 有独立 catalog,按设计不进入 Runtime Schema。用 `dws shortcut list --service --format json` 读取 flags、required/enum、跨参数 constraints、risk/confirmation 和 examples;不要对 `+` 路径调用 `dws schema`。 +- 真正组装参数前用叶子帮助 `dws + --help` 核对 Cobra 接受的 flags;父级 `dws --help` 只能用于发现子命令。 +- shortcut catalog 中 `confirmation=user_required` 时先获得用户确认,确认后才加 `--yes`;`not_required` 不额外确认。 - 如果 shortcut 不在 help / list 中,改用产品参考里的原子命令、脚本或标准流程;不要猜测未展示的 `+` 命令。 ## 多组织 / --profile(关键规则)