diff --git a/.changes/README.md b/.changes/README.md
index 79b5868f..1ed1fbd3 100644
--- a/.changes/README.md
+++ b/.changes/README.md
@@ -25,7 +25,9 @@ category: Added
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
-`.changes/released/Personal Event Subscription — real-time DingTalk messages for event-driven agents
-`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
+`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
@@ -530,12 +530,13 @@ dws event consume user_im_group_disbanded --group
@@ -476,7 +476,7 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
--start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20
+ dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20,"statuses":["RUNNING"]}'`,
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ // Cobra 的 flag group 校验(英文报错)在 PreRunE 之后执行,
+ // 这里先校验同一组约束,保证用户看到的是中文错误。
+ request, _ := cmd.Flags().GetString("request")
+ processCode, _ := cmd.Flags().GetString("process-code")
+ if request == "" && processCode == "" {
+ return fmt.Errorf("--request、--process-code 至少指定一个")
+ }
+ if request != "" {
+ for _, name := range listByAdminSimpleFlags {
+ if cmd.Flags().Changed(name) {
+ return fmt.Errorf("--request 与 --%s 不能同时指定", name)
+ }
+ }
+ }
+ if processCode != "" && !cmd.Flags().Changed("start") {
+ return fmt.Errorf("--process-code、--start 必须同时指定(缺少 --start)")
+ }
+ return nil
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if raw, _ := cmd.Flags().GetString("request"); raw != "" {
+ request, err := decodeOARequest(raw)
+ if err != nil {
+ return fmt.Errorf("--request JSON 解析失败: %w", err)
+ }
+ if err := validateOARequestProcessCode(request); err != nil {
+ return err
+ }
+ if err := validateOARequestPageSize(request); err != nil {
+ return err
+ }
+ if err := validateOARequestTimeRange(request); err != nil {
+ return err
+ }
+ return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request})
+ }
+ if err := validateRequiredFlags(cmd, "process-code", "start"); err != nil {
+ return err
+ }
+ startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
+ if err != nil {
+ return err
+ }
+ cursor, err := strconv.ParseFloat(mustGetFlag(cmd, "cursor"), 64)
+ if err != nil {
+ return fmt.Errorf("--cursor 必须为数字: %w", err)
+ }
+ pageSize, err := strconv.ParseFloat(mustGetFlag(cmd, "limit"), 64)
+ if err != nil {
+ return fmt.Errorf("--limit 必须为数字: %w", err)
+ }
+ if pageSize < 1 || pageSize > oaAdminQueryMaxPageSize {
+ return fmt.Errorf("--limit 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), mustGetFlag(cmd, "limit"))
+ }
+ request := map[string]any{
+ "processCode": mustGetFlag(cmd, "process-code"),
+ "startTime": formatOAAdminQueryTime(startMs),
+ "cursor": cursor,
+ "pageSize": pageSize,
+ }
+ if v, _ := cmd.Flags().GetString("end"); v != "" {
+ endMs, err := parseISOTimeToMillis("end", v)
+ if err != nil {
+ return err
+ }
+ if err := validateTimeRange(startMs, endMs); err != nil {
+ return err
+ }
+ request["endTime"] = formatOAAdminQueryTime(endMs)
+ }
+ if v, _ := cmd.Flags().GetString("user-ids"); v != "" {
+ request["userIds"] = strings.Split(v, ",")
+ }
+ if v, _ := cmd.Flags().GetString("statuses"); v != "" {
+ request["statuses"] = strings.Split(v, ",")
+ }
+ return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request})
+ },
+ }
+ DeclareLeafMetadata(approvalListByAdminCmd, LeafSpec{
+ Safety: contract.SafetySpec{
+ Effect: "read", Risk: "low",
+ Confirmation: "not_required", Idempotency: "idempotent",
+ },
+ Contract: LeafContract{
+ Identity: contract.ToolIdentitySpec{
+ ProductID: "oa",
+ Name: "get_process_instances_by_admin",
+ CanonicalPath: "oa.get_process_instances_by_admin",
+ CLIPath: "oa approval list-by-admin",
+ PrimaryCLIPath: "oa approval list-by-admin",
+ },
+ Description: "以管理员身份获取审批单列表",
+ Interface: &contract.InterfaceSpec{
+ Mode: "mcp",
+ Availability: "available",
+ Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_instances_by_admin"},
+ },
+ Selection: contract.SelectionSpec{
+ AgentSummary: "以管理员身份按模板、时间范围、状态与用户查询企业内审批实例列表",
+ UseWhen: []string{"具备 OA 审批管理员权限,需要跨用户统计或检索某模板下的审批实例时"},
+ AvoidWhen: []string{
+ "只查自己的待办/已办/已发起/抄送时改用 list-pending / list-executed / list-initiated / list-cc",
+ "无 OA 管理员权限时不要使用,该命令查不到数据",
+ },
+ Examples: []string{
+ "dws oa approval list-by-admin --process-code --start \"2026-03-10T00:00:00+08:00\" --cursor 0 --limit 20",
+ "dws oa approval list-by-admin --process-code --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --statuses RUNNING,COMPLETED --user-ids \"userId1,userId2\"",
+ },
+ },
+ // Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceListQueryRequest).
+ Parameters: []contract.ParamDecl{
+ {Name: "request", Property: "ProcessInstanceListQueryRequest", InterfaceType: "object"},
+ },
+ },
+ })
approvalCreateCmd := &cobra.Command{
Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)",
Example: "dws oa approval create-instance --process-code --form-values '{\"事由\":\"测试\"}' --yes",
@@ -1610,6 +2108,27 @@ func newOaCommand() *cobra.Command {
RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}},
})
+ approvalListByAdminCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
+ approvalListByAdminCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式使用;与 --request 互斥)")
+ approvalListByAdminCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选)")
+ approvalListByAdminCmd.Flags().String("cursor", "0", "分页游标,首次传 0")
+ approvalListByAdminCmd.Flags().String("limit", "20", "每页大小,最大 20")
+ approvalListByAdminCmd.Flags().String("user-ids", "", "按发起人 userId 过滤,多个用逗号分隔(可选)")
+ approvalListByAdminCmd.Flags().String("statuses", "", "按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED)")
+ approvalListByAdminCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
+ approvalListByAdminCmd.MarkFlagsOneRequired("request", "process-code")
+ approvalListByAdminCmd.MarkFlagsRequiredTogether("process-code", "start")
+ listByAdminMutuallyExclusive := make([][]string, 0, len(listByAdminSimpleFlags))
+ for _, name := range listByAdminSimpleFlags {
+ approvalListByAdminCmd.MarkFlagsMutuallyExclusive("request", name)
+ listByAdminMutuallyExclusive = append(listByAdminMutuallyExclusive, []string{"request", name})
+ }
+ cli.AnnotateRuntimeConstraints(approvalListByAdminCmd, cli.RuntimeSchemaConstraints{
+ MutuallyExclusive: listByAdminMutuallyExclusive,
+ RequireOneOf: [][]string{{"request", "process-code"}},
+ RequireTogether: [][]string{{"process-code", "start"}},
+ })
+
approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID")
approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
@@ -1656,6 +2175,7 @@ func newOaCommand() *cobra.Command {
approvalRevertTaskCmd,
approvalFormSchemaCmd,
approvalForecastCmd,
+ approvalListByAdminCmd,
approvalCreateCmd,
)
approvalCmd.AddCommand(newOAAttachmentCommand())
diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go
index 230e9021..d4173d05 100644
--- a/internal/helpers/oa_attachment_test.go
+++ b/internal/helpers/oa_attachment_test.go
@@ -10,7 +10,9 @@ import (
"errors"
"io"
"os"
+ "path/filepath"
"reflect"
+ "strconv"
"strings"
"testing"
@@ -30,6 +32,7 @@ func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToo
cmd := newOaCommand()
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
+ cmd.PersistentFlags().Bool("dry-run", false, "仅预览不执行")
cmd.PersistentFlags().String("format", caller.Format(), "输出格式")
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
@@ -337,3 +340,846 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin
})
}
}
+
+const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}`
+
+func oaUploadCommitResponse(size int64) string {
+ return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` +
+ strconv.FormatInt(size, 10) +
+ `,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}`
+}
+
+// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。
+func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), name)
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatalf("write temp file: %v", err)
+ }
+ return path, int64(len(content))
+}
+
+// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的
+// resourceURL 与签名 headers。
+type capturedPut struct {
+ calls int
+ url string
+ headers map[string]string
+ filePath string
+ fileSize int64
+}
+
+func mockOAAttachmentPut(t *testing.T) *capturedPut {
+ t.Helper()
+ put := &capturedPut{}
+ SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error {
+ put.calls++
+ put.url = url
+ put.headers = headers
+ put.filePath = filePath
+ put.fileSize = fileSize
+ return nil
+ })
+ t.Cleanup(func() { SetHTTPPutFile(nil) })
+ return put
+}
+
+func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) {
+ const content = "pdf-bytes-content"
+ filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content)
+ put := mockOAAttachmentPut(t)
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: oaUploadInitResponse},
+ {text: oaUploadCommitResponse(fileSize)},
+ }}
+ stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ "--file-name", "合同.pdf",
+ "--md5", "d41d8cd98f00b204e9800998ecf8427e",
+ )
+ if err != nil {
+ t.Fatalf("execute command: %v", err)
+ }
+
+ if caller.calls != 2 {
+ t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls)
+ }
+ if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" {
+ t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0])
+ }
+ wantInit := map[string]any{
+ "fileName": "合同.pdf",
+ "fileSize": float64(fileSize),
+ "md5": "d41d8cd98f00b204e9800998ecf8427e",
+ }
+ if !reflect.DeepEqual(caller.argsLog[0], wantInit) {
+ t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit)
+ }
+
+ if put.calls != 1 {
+ t.Fatalf("httpPutFile called %d times, want 1", put.calls)
+ }
+ if put.url != "https://oss.example.test/upload" {
+ t.Fatalf("PUT url = %q, want parsed resourceURL", put.url)
+ }
+ if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" {
+ t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers)
+ }
+ if put.filePath != filePath || put.fileSize != fileSize {
+ t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize)
+ }
+
+ if caller.toolLog[1] != "commit_attachment_upload_info" {
+ t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1])
+ }
+ wantCommit := map[string]any{
+ "fileName": "合同.pdf",
+ "uploadKey": "key-123",
+ "fileSize": float64(fileSize),
+ }
+ if !reflect.DeepEqual(caller.argsLog[1], wantCommit) {
+ t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit)
+ }
+
+ var envelope map[string]any
+ if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
+ t.Fatalf("decode unified output: %v", err)
+ }
+ if envelope["ok"] != true || envelope["outcome"] != "success" {
+ t.Fatalf("unified envelope = %#v", envelope)
+ }
+ data, ok := envelope["data"].(map[string]any)
+ if !ok || data["fileId"] != "file-abc" {
+ t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"])
+ }
+}
+
+func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) {
+ const content = "auto-md5-content"
+ filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content)
+ wantMD5, err := fileMD5Hex(filePath)
+ if err != nil {
+ t.Fatalf("compute expected md5: %v", err)
+ }
+ mockOAAttachmentPut(t)
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: oaUploadInitResponse},
+ {text: oaUploadCommitResponse(fileSize)},
+ }}
+ // 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。
+ if _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ ); err != nil {
+ t.Fatalf("execute command: %v", err)
+ }
+
+ if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" {
+ t.Fatalf("default fileName = %#v, want basename report.xlsx", got)
+ }
+ if got := caller.argsLog[0]["md5"]; got != wantMD5 {
+ t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5)
+ }
+ if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) {
+ t.Fatalf("fileSize = %#v, want number %d", got, fileSize)
+ }
+}
+
+func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitResponse(t *testing.T) {
+ tests := []struct {
+ name string
+ response string
+ wantErr string
+ }{
+ {
+ name: "missing result",
+ response: `{"success":true}`,
+ wantErr: "缺少 result",
+ },
+ {
+ name: "missing uploadKey",
+ response: `{"result":{"resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
+ wantErr: "缺少 uploadKey",
+ },
+ {
+ name: "empty uploadKey",
+ response: `{"result":{"uploadKey":" ","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
+ wantErr: "缺少 uploadKey",
+ },
+ {
+ name: "empty resourceUrls",
+ response: `{"result":{"uploadKey":"key-1","resourceUrls":[],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
+ wantErr: "缺少 resourceUrls",
+ },
+ {
+ name: "empty resourceUrls element",
+ response: `{"result":{"uploadKey":"key-1","resourceUrls":[""],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`,
+ wantErr: "首个 resourceUrls 为空",
+ },
+ {
+ name: "missing headers",
+ response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"]},"success":true}`,
+ wantErr: "缺少 headers",
+ },
+ {
+ name: "headers missing Authorization",
+ response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z"}},"success":true}`,
+ wantErr: "Authorization",
+ },
+ {
+ name: "headers missing x-oss-date",
+ response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"OSS sig"}},"success":true}`,
+ wantErr: "x-oss-date",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ filePath, _ := writeOAAttachmentTempFile(t, "test.pdf", "data")
+ put := mockOAAttachmentPut(t)
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: test.response},
+ // second step should never be reached
+ {text: `{"result":{},"success":true}`},
+ }}
+ _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ "--file-name", "test.pdf",
+ "--md5", "d41d8cd98f00b204e9800998ecf8427e",
+ )
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", test.wantErr)
+ }
+ if !strings.Contains(err.Error(), test.wantErr) {
+ t.Fatalf("error = %v, want substring %q", err, test.wantErr)
+ }
+ if caller.calls != 1 {
+ t.Fatalf("MCP calls = %d, want 1 (init only, no commit)", caller.calls)
+ }
+ if put.calls != 0 {
+ t.Fatalf("httpPutFile called %d times, want 0 (should not PUT)", put.calls)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) {
+ existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x")
+ tests := []struct {
+ name string
+ args []string
+ }{
+ {name: "missing file", args: []string{"approval", "attachment", "upload"}},
+ {name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}},
+ {name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ mockOAAttachmentPut(t)
+ caller := &scriptedToolCaller{}
+ err := executeOACommand(t, caller, test.args...)
+ if err == nil {
+ t.Fatalf("%s unexpectedly succeeded", test.name)
+ }
+ if caller.calls != 0 {
+ t.Fatalf("invalid upload made %d MCP call(s)", caller.calls)
+ }
+ })
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut 验证
+// --dry-run 在任何远程调用(init/PUT/commit)之前 early return:零 MCP 调用、
+// 零 PUT,且输出一个包含 3 个 planned step 的 plan 预览。
+func TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut(t *testing.T) {
+ const content = "dry-run-preview-content"
+ filePath, fileSize := writeOAAttachmentTempFile(t, "plan.pdf", content)
+ wantMD5, err := fileMD5Hex(filePath)
+ if err != nil {
+ t.Fatalf("compute expected md5: %v", err)
+ }
+ put := mockOAAttachmentPut(t)
+
+ // caller.dry 驱动 deps.Caller.DryRun()==true;同时传入 --dry-run 以镜像生产路径。
+ caller := &scriptedToolCaller{format: "json", dry: true}
+ stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ "--dry-run",
+ )
+ if err != nil {
+ t.Fatalf("execute command: %v", err)
+ }
+ if caller.calls != 0 {
+ t.Fatalf("dry-run made %d MCP call(s), want 0", caller.calls)
+ }
+ if put.calls != 0 {
+ t.Fatalf("dry-run made %d PUT call(s), want 0", put.calls)
+ }
+
+ var envelope map[string]any
+ if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
+ t.Fatalf("decode unified output: %v\noutput: %s", err, stdout)
+ }
+ data, ok := envelope["data"].(map[string]any)
+ if !ok {
+ t.Fatalf("unified data missing/invalid: %#v", envelope["data"])
+ }
+ if data["dry_run"] != true || data["executed"] != false || data["preview_kind"] != "plan" {
+ t.Fatalf("plan flags = %#v", data)
+ }
+ if data["operation"] != "attachment_upload" || data["source"] != "oa" {
+ t.Fatalf("plan operation/source = %#v", data)
+ }
+ if data["file_name"] != "plan.pdf" || data["file_size"] != float64(fileSize) || data["md5"] != wantMD5 {
+ t.Fatalf("plan file metadata = %#v (want file_name=plan.pdf size=%d md5=%s)", data, fileSize, wantMD5)
+ }
+ steps, ok := data["steps"].([]any)
+ if !ok || len(steps) != 3 {
+ t.Fatalf("plan steps = %#v, want 3 planned steps", data["steps"])
+ }
+ wantTools := []string{"oa/init_attachment_upload_info", "HTTP PUT", "oa/commit_attachment_upload_info"}
+ for i, raw := range steps {
+ step, ok := raw.(map[string]any)
+ if !ok {
+ t.Fatalf("step %d not an object: %#v", i, raw)
+ }
+ if step["tool"] != wantTools[i] {
+ t.Fatalf("step %d tool = %#v, want %q", i, step["tool"], wantTools[i])
+ }
+ if step["status"] != "planned" {
+ t.Fatalf("step %d status = %#v, want planned", i, step["status"])
+ }
+ }
+
+ // The commit step (index 2) must NOT contain uploadKey in args (remote-only field)
+ // and must declare the dependency via a "requires" entry.
+ commitStep := steps[2].(map[string]any)
+ commitArgs, _ := commitStep["args"].(map[string]any)
+ if _, hasUploadKey := commitArgs["uploadKey"]; hasUploadKey {
+ t.Fatalf("commit step args must not contain uploadKey, got: %#v", commitArgs)
+ }
+ requires, ok := commitStep["requires"].([]any)
+ if !ok || len(requires) == 0 {
+ t.Fatalf("commit step must have non-empty requires, got: %#v", commitStep["requires"])
+ }
+ found := false
+ for _, r := range requires {
+ if s, ok := r.(string); ok && (len(s) > 0 && strings.Contains(s, "uploadKey") && strings.Contains(s, "init")) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("commit step requires should mention uploadKey and init, got: %#v", requires)
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentUploadMD5Failure injects a failing computeFileMD5 to
+// cover runOAAttachmentUpload's MD5 failure branch on all platforms (including Windows).
+func TestCrossPlatformCoverageOAAttachmentUploadMD5Failure(t *testing.T) {
+ filePath, _ := writeOAAttachmentTempFile(t, "test.bin", "hello")
+ put := mockOAAttachmentPut(t)
+
+ testseam.Swap(t, &computeFileMD5, func(string) (string, error) {
+ return "", errors.New("permission denied")
+ })
+
+ caller := &scriptedToolCaller{format: "json"}
+ _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ )
+ if err == nil {
+ t.Fatal("expected error from MD5 failure, got nil")
+ }
+ if !strings.Contains(err.Error(), "MD5") {
+ t.Fatalf("error should mention MD5, got: %v", err)
+ }
+ if caller.calls != 0 {
+ t.Fatalf("expected 0 MCP calls when MD5 fails, got %d", caller.calls)
+ }
+ if put.calls != 0 {
+ t.Fatalf("expected 0 PUT calls when MD5 fails, got %d", put.calls)
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError 注入一个返回错误的 PUT,
+// 覆盖 runOAAttachmentUpload 中 httpPutFile 失败分支:init 后报错(1 次 MCP 调用)且不提交。
+func TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError(t *testing.T) {
+ filePath, _ := writeOAAttachmentTempFile(t, "put-fail.pdf", "data")
+ SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
+ return errors.New("oss put failed")
+ })
+ t.Cleanup(func() { SetHTTPPutFile(nil) })
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: oaUploadInitResponse},
+ // commit 不应被达到
+ {text: `{"result":{},"success":true}`},
+ }}
+ _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ "--file-name", "put-fail.pdf",
+ "--md5", "d41d8cd98f00b204e9800998ecf8427e",
+ )
+ if err == nil || !strings.Contains(err.Error(), "oss put failed") {
+ t.Fatalf("error = %v, want oss put failed", err)
+ }
+ if caller.calls != 1 {
+ t.Fatalf("MCP calls = %d, want 1 (init only, no commit after PUT failure)", caller.calls)
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse 校验 commit 返回
+// 缺少必需字段时命令报错(而不是包装为成功)。
+func TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse(t *testing.T) {
+ tests := []struct {
+ name string
+ commitResponse string
+ wantErr string
+ }{
+ {
+ name: "result is null",
+ commitResponse: `{"success":true,"result":null}`,
+ wantErr: "不是有效的 JSON 对象",
+ },
+ {
+ name: "result is empty object",
+ commitResponse: `{"success":true,"result":{}}`,
+ wantErr: "spaceId",
+ },
+ {
+ name: "missing fileId",
+ commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100}}`,
+ wantErr: "fileId",
+ },
+ {
+ name: "empty fileId",
+ commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100,"fileId":""}}`,
+ wantErr: "fileId",
+ },
+ {
+ name: "missing spaceId",
+ commitResponse: `{"success":true,"result":{"fileName":"a.pdf","fileSize":100,"fileId":"file-1"}}`,
+ wantErr: "spaceId",
+ },
+ {
+ name: "fileSize is zero",
+ commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":0,"fileId":"file-1"}}`,
+ wantErr: "fileSize",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ filePath, _ := writeOAAttachmentTempFile(t, "commit-validate.pdf", "data")
+ put := mockOAAttachmentPut(t)
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: oaUploadInitResponse},
+ {text: test.commitResponse},
+ }}
+ _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ "--file-name", "commit-validate.pdf",
+ "--md5", "d41d8cd98f00b204e9800998ecf8427e",
+ )
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", test.wantErr)
+ }
+ if !strings.Contains(err.Error(), test.wantErr) {
+ t.Fatalf("error = %v, want substring %q", err, test.wantErr)
+ }
+ // init + commit = 2 MCP calls
+ if caller.calls != 2 {
+ t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
+ }
+ // PUT should have been called (init succeeded)
+ if put.calls != 1 {
+ t.Fatalf("httpPutFile called %d times, want 1", put.calls)
+ }
+ })
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect 直接调用
+// validateOAAttachmentCommitResult 覆盖所有分支路径,包括通过 end-to-end 流不可达的
+// json.Number 类型分支。
+func TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect(t *testing.T) {
+ tests := []struct {
+ name string
+ input any
+ wantErr string // empty means expect nil error
+ }{
+ // result 不是 map
+ {name: "result is string", input: "hello", wantErr: "不是有效的 JSON 对象"},
+ {name: "result is array", input: []any{"x"}, wantErr: "不是有效的 JSON 对象"},
+ {name: "result is number", input: float64(42), wantErr: "不是有效的 JSON 对象"},
+ {name: "result is nil", input: nil, wantErr: "不是有效的 JSON 对象"},
+
+ // spaceId — 空字符串
+ {name: "spaceId empty string", input: map[string]any{
+ "spaceId": "", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
+ }, wantErr: "spaceId"},
+ {name: "spaceId whitespace only", input: map[string]any{
+ "spaceId": " ", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
+ }, wantErr: "spaceId"},
+
+ // spaceId — json.Number(通过 UseNumber 解码的数字)
+ {name: "spaceId json.Number", input: map[string]any{
+ "spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
+ }, wantErr: ""},
+
+ // spaceId — float64(正常路径)
+ {name: "spaceId float64", input: map[string]any{
+ "spaceId": float64(123), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
+ }, wantErr: ""},
+
+ // spaceId — 非法类型
+ {name: "spaceId bool", input: map[string]any{
+ "spaceId": true, "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1",
+ }, wantErr: "spaceId"},
+
+ // fileName — 非 string 类型
+ {name: "fileName is number", input: map[string]any{
+ "spaceId": "123", "fileName": float64(99), "fileSize": float64(100), "fileId": "file-1",
+ }, wantErr: "fileName"},
+ {name: "fileName is nil", input: map[string]any{
+ "spaceId": "123", "fileName": nil, "fileSize": float64(100), "fileId": "file-1",
+ }, wantErr: "fileName"},
+ {name: "fileName empty", input: map[string]any{
+ "spaceId": "123", "fileName": "", "fileSize": float64(100), "fileId": "file-1",
+ }, wantErr: "fileName"},
+
+ // fileSize — json.Number 有效
+ {name: "fileSize json.Number valid", input: map[string]any{
+ "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("200"), "fileId": "file-1",
+ }, wantErr: ""},
+
+ // fileSize — json.Number 无效(<= 0)
+ {name: "fileSize json.Number zero", input: map[string]any{
+ "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("0"), "fileId": "file-1",
+ }, wantErr: "fileSize"},
+ {name: "fileSize json.Number negative", input: map[string]any{
+ "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("-5"), "fileId": "file-1",
+ }, wantErr: "fileSize"},
+ {name: "fileSize json.Number invalid", input: map[string]any{
+ "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("abc"), "fileId": "file-1",
+ }, wantErr: "fileSize"},
+
+ // fileSize — 非法类型(default 分支)
+ {name: "fileSize is string", input: map[string]any{
+ "spaceId": "123", "fileName": "a.pdf", "fileSize": "100", "fileId": "file-1",
+ }, wantErr: "fileSize"},
+ {name: "fileSize is nil", input: map[string]any{
+ "spaceId": "123", "fileName": "a.pdf", "fileSize": nil, "fileId": "file-1",
+ }, wantErr: "fileSize"},
+
+ // fileId — 非 string 类型
+ {name: "fileId is number", input: map[string]any{
+ "spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": float64(99),
+ }, wantErr: "fileId"},
+ {name: "fileId whitespace", input: map[string]any{
+ "spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": " ",
+ }, wantErr: "fileId"},
+
+ // 全部通过
+ {name: "all valid string types", input: map[string]any{
+ "spaceId": "123", "fileName": "report.pdf", "fileSize": float64(512), "fileId": "file-abc",
+ }, wantErr: ""},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ _, err := validateOAAttachmentCommitResult(test.input)
+ if test.wantErr == "" {
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", test.wantErr)
+ }
+ if !strings.Contains(err.Error(), test.wantErr) {
+ t.Fatalf("error = %v, want substring %q", err, test.wantErr)
+ }
+ })
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath 覆盖 runOAAttachmentUpload
+// 中 --file 传了但值为空字符串的分支(cobra MarkFlagRequired 只拦截未传,不拦截空值)。
+func TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath(t *testing.T) {
+ mockOAAttachmentPut(t)
+ caller := &scriptedToolCaller{format: "json"}
+ _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", "",
+ )
+ if err == nil || !strings.Contains(err.Error(), "--file 不能为空") {
+ t.Fatalf("error = %v, want --file empty error", err)
+ }
+ if caller.calls != 0 {
+ t.Fatalf("MCP calls = %d, want 0", caller.calls)
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentUploadInitCallError 覆盖 runOAAttachmentUpload
+// 中 callMCPToolReturnTextOnServer(init 步)返回错误的分支。
+func TestCrossPlatformCoverageOAAttachmentUploadInitCallError(t *testing.T) {
+ filePath, _ := writeOAAttachmentTempFile(t, "init-err.pdf", "data")
+ put := mockOAAttachmentPut(t)
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {err: errors.New("injected init failure")},
+ }}
+ _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ "--file-name", "init-err.pdf",
+ "--md5", "d41d8cd98f00b204e9800998ecf8427e",
+ )
+ if err == nil {
+ t.Fatal("expected error from init call failure, got nil")
+ }
+ if caller.calls != 1 {
+ t.Fatalf("MCP calls = %d, want 1 (init attempted)", caller.calls)
+ }
+ if put.calls != 0 {
+ t.Fatalf("PUT calls = %d, want 0", put.calls)
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject 覆盖 runOAAttachmentUpload
+// 中 commitData 不是 map[string]any 的分支(line 348)。
+func TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject(t *testing.T) {
+ filePath, _ := writeOAAttachmentTempFile(t, "commit-nonobj.pdf", "data")
+ mockOAAttachmentPut(t)
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: oaUploadInitResponse},
+ // commit 返回一个 JSON 字符串而非对象
+ {text: `"not an object"`},
+ }}
+ _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ "--file-name", "commit-nonobj.pdf",
+ "--md5", "d41d8cd98f00b204e9800998ecf8427e",
+ )
+ if err == nil || !strings.Contains(err.Error(), "不是 JSON 对象") {
+ t.Fatalf("error = %v, want commit non-object error", err)
+ }
+ if caller.calls != 2 {
+ t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls)
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON 覆盖
+// parseOAAttachmentUploadInfo 中 json.Unmarshal 失败分支(init 返回非法 JSON)。
+func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON(t *testing.T) {
+ filePath, _ := writeOAAttachmentTempFile(t, "bad-init.pdf", "data")
+ put := mockOAAttachmentPut(t)
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: `{not valid json`},
+ }}
+ _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ "--file-name", "bad-init.pdf",
+ "--md5", "d41d8cd98f00b204e9800998ecf8427e",
+ )
+ if err == nil || !strings.Contains(err.Error(), "解析 init_attachment_upload_info 返回失败") {
+ t.Fatalf("error = %v, want parse failure", err)
+ }
+ if caller.calls != 1 {
+ t.Fatalf("MCP calls = %d, want 1 (init only)", caller.calls)
+ }
+ if put.calls != 0 {
+ t.Fatalf("PUT calls = %d, want 0", put.calls)
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization 验证
+// validateOAAttachmentCommitResult 对 spaceId/fileSize 的归一化行为。
+func TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization(t *testing.T) {
+ tests := []struct {
+ name string
+ input map[string]any
+ wantSpaceID any
+ wantSize any
+ wantErr string
+ }{
+ {
+ name: "string spaceId normalized to int64",
+ input: map[string]any{"spaceId": "27827223951", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
+ wantSpaceID: int64(27827223951),
+ wantSize: float64(100),
+ },
+ {
+ name: "non-numeric string spaceId returns error",
+ input: map[string]any{"spaceId": "abc", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
+ wantErr: "spaceId",
+ },
+ {
+ name: "float string spaceId returns error",
+ input: map[string]any{"spaceId": "12.5", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
+ wantErr: "spaceId",
+ },
+ {
+ name: "json.Number spaceId normalized to int64",
+ input: map[string]any{"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
+ wantSpaceID: int64(27827223951),
+ wantSize: float64(100),
+ },
+ {
+ name: "json.Number spaceId non-integer returns error",
+ input: map[string]any{"spaceId": json.Number("12.5"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
+ wantErr: "spaceId",
+ },
+ {
+ name: "json.Number fileSize normalized to int64",
+ input: map[string]any{"spaceId": float64(123), "fileName": "a.pdf", "fileSize": json.Number("2048"), "fileId": "file-1"},
+ wantSpaceID: float64(123),
+ wantSize: int64(2048),
+ },
+ {
+ name: "float64 spaceId unchanged",
+ input: map[string]any{"spaceId": float64(27827223951), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"},
+ wantSpaceID: float64(27827223951),
+ wantSize: float64(100),
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ normalized, err := validateOAAttachmentCommitResult(test.input)
+ if test.wantErr != "" {
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", test.wantErr)
+ }
+ if !strings.Contains(err.Error(), test.wantErr) {
+ t.Fatalf("error = %v, want substring %q", err, test.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if normalized["spaceId"] != test.wantSpaceID {
+ t.Fatalf("spaceId = %v (%T), want %v (%T)", normalized["spaceId"], normalized["spaceId"], test.wantSpaceID, test.wantSpaceID)
+ }
+ if normalized["fileSize"] != test.wantSize {
+ t.Fatalf("fileSize = %v (%T), want %v (%T)", normalized["fileSize"], normalized["fileSize"], test.wantSize, test.wantSize)
+ }
+ })
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentUploadOutputContract 验证端到端上传命令
+// 输出 JSON 中 spaceId 始终为 number(即使 commit 返回字符串 spaceId)。
+func TestCrossPlatformCoverageOAAttachmentUploadOutputContract(t *testing.T) {
+ tests := []struct {
+ name string
+ commitResponse string
+ wantSpaceID float64 // JSON decode 后 number → float64
+ }{
+ {
+ name: "spaceId as number",
+ commitResponse: `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
+ wantSpaceID: float64(27827223951),
+ },
+ {
+ name: "spaceId as string",
+ commitResponse: `{"result":{"spaceId":"27827223951","fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`,
+ wantSpaceID: float64(27827223951),
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ const content = "output-contract-data"
+ filePath, _ := writeOAAttachmentTempFile(t, "contract.pdf", content)
+ mockOAAttachmentPut(t)
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: oaUploadInitResponse},
+ {text: test.commitResponse},
+ }}
+ stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ "--file-name", "合同.pdf",
+ "--md5", "d41d8cd98f00b204e9800998ecf8427e",
+ )
+ if err != nil {
+ t.Fatalf("execute command: %v", err)
+ }
+
+ var envelope map[string]any
+ if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
+ t.Fatalf("decode unified output: %v", err)
+ }
+ if envelope["ok"] != true || envelope["outcome"] != "success" {
+ t.Fatalf("unified envelope = %#v", envelope)
+ }
+ data, ok := envelope["data"].(map[string]any)
+ if !ok {
+ t.Fatalf("unified data missing: %#v", envelope)
+ }
+ // spaceId must be a number in the JSON output
+ spaceID, ok := data["spaceId"].(float64)
+ if !ok {
+ t.Fatalf("spaceId is not a number: %v (%T)", data["spaceId"], data["spaceId"])
+ }
+ if spaceID != test.wantSpaceID {
+ t.Fatalf("spaceId = %v, want %v", spaceID, test.wantSpaceID)
+ }
+ // fileSize must also be a number
+ if _, ok := data["fileSize"].(float64); !ok {
+ t.Fatalf("fileSize is not a number: %v (%T)", data["fileSize"], data["fileSize"])
+ }
+ })
+ }
+}
+
+// TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、
+// commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支:
+// PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。
+func TestCrossPlatformCoverageOAAttachmentUploadCommitError(t *testing.T) {
+ filePath, _ := writeOAAttachmentTempFile(t, "commit-fail.pdf", "data")
+ put := mockOAAttachmentPut(t)
+
+ caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
+ {text: oaUploadInitResponse},
+ {err: errors.New("commit upload failed")},
+ }}
+ _, err := executeOAAttachmentCommandCapturingOutput(t, caller,
+ "approval", "attachment", "upload",
+ "--file", filePath,
+ "--file-name", "commit-fail.pdf",
+ "--md5", "d41d8cd98f00b204e9800998ecf8427e",
+ )
+ if err == nil || !strings.Contains(err.Error(), "commit upload failed") {
+ t.Fatalf("error = %v, want commit upload failed", err)
+ }
+ if put.calls != 1 {
+ t.Fatalf("PUT calls = %d, want 1", put.calls)
+ }
+ if caller.calls != 2 {
+ t.Fatalf("MCP calls = %d, want 2 (init + commit attempted)", caller.calls)
+ }
+ if caller.toolLog[0] != "init_attachment_upload_info" || caller.toolLog[1] != "commit_attachment_upload_info" {
+ t.Fatalf("tool sequence = %v, want init then commit", caller.toolLog)
+ }
+}
diff --git a/internal/helpers/oa_coverage_more_test.go b/internal/helpers/oa_coverage_more_test.go
index 70cb18cc..019b64e8 100644
--- a/internal/helpers/oa_coverage_more_test.go
+++ b/internal/helpers/oa_coverage_more_test.go
@@ -131,6 +131,51 @@ func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *tes
}
}
+func TestCrossPlatformCoverageOAApprovalListByAdminMapsSimpleOptions(t *testing.T) {
+ caller := &scriptedToolCaller{}
+ err := executeOACommand(t, caller,
+ "approval", "list-by-admin",
+ "--process-code", "PROC",
+ "--start", "2030-01-01T09:00:00+08:00",
+ "--end", "2030-01-01T10:00:00+08:00",
+ "--cursor", "5",
+ "--limit", "20",
+ "--user-ids", "user-1,user-2",
+ "--statuses", "RUNNING,COMPLETED",
+ )
+ if err != nil {
+ t.Fatalf("list by admin: %v", err)
+ }
+ if caller.server != "oa" || caller.tool != "get_process_instances_by_admin" {
+ t.Fatalf("called %s/%s, want oa/get_process_instances_by_admin", caller.server, caller.tool)
+ }
+ request, ok := caller.args["ProcessInstanceListQueryRequest"].(map[string]any)
+ if !ok {
+ t.Fatalf("request payload = %#v", caller.args)
+ }
+ if got := request["processCode"]; got != "PROC" {
+ t.Fatalf("processCode = %#v", got)
+ }
+ if got := request["cursor"]; got != float64(5) {
+ t.Fatalf("cursor = %#v", got)
+ }
+ if got := request["pageSize"]; got != float64(20) {
+ t.Fatalf("pageSize = %#v", got)
+ }
+ if got := request["startTime"]; got != "2030-01-01 09:00:00" {
+ t.Fatalf("startTime = %#v", got)
+ }
+ if got := request["endTime"]; got != "2030-01-01 10:00:00" {
+ t.Fatalf("endTime = %#v", got)
+ }
+ if got := request["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "user-1" || got.([]string)[1] != "user-2" {
+ t.Fatalf("userIds = %#v", got)
+ }
+ if got := request["statuses"]; len(got.([]string)) != 2 || got.([]string)[0] != "RUNNING" || got.([]string)[1] != "COMPLETED" {
+ t.Fatalf("statuses = %#v", got)
+ }
+}
+
func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) {
validCases := []struct {
name string
@@ -157,6 +202,26 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t
args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"},
tool: "start_process_instance",
},
+ {
+ name: "list-by-admin simple mode",
+ args: []string{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00"},
+ tool: "get_process_instances_by_admin",
+ },
+ {
+ name: "list-by-admin request mode",
+ args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
+ tool: "get_process_instances_by_admin",
+ },
+ {
+ name: "list-by-admin request mode without pageSize",
+ args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0}`},
+ tool: "get_process_instances_by_admin",
+ },
+ {
+ name: "list-by-admin request mode with endTime",
+ args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 23:59:59","cursor":0,"pageSize":20}`},
+ tool: "get_process_instances_by_admin",
+ },
}
for _, tc := range validCases {
t.Run(tc.name, func(t *testing.T) {
@@ -188,6 +253,31 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"},
+ {"approval", "list-by-admin"},
+ {"approval", "list-by-admin", "--process-code", "PROC"},
+ {"approval", "list-by-admin", "--process-code", "PROC", "--start", "bad"},
+ {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "bad"},
+ {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "2030-01-01T09:00:00+08:00"},
+ {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--cursor", "bad"},
+ {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "bad"},
+ {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "21"},
+ {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "0"},
+ {"approval", "list-by-admin", "--process-code", "PROC", "--start", ""},
+ {"approval", "list-by-admin", "--request", "{"},
+ {"approval", "list-by-admin", "--request", "null"},
+ {"approval", "list-by-admin", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"},
+ {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":21}`},
+ {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":"20"}`},
+ {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":1893459600000,"cursor":0,"pageSize":20}`},
+ {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01","cursor":0,"pageSize":20}`},
+ {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 10:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
+ {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":1893463200000,"cursor":0,"pageSize":20}`},
+ {"approval", "list-by-admin", "--request", `{"processCode":"PROC","endTime":"NOT-A-TIME","cursor":0,"pageSize":20}`},
+ {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01","cursor":0,"pageSize":20}`},
+ {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
+ {"approval", "list-by-admin", "--request", `{"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
+ {"approval", "list-by-admin", "--request", `{"processCode":"","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
+ {"approval", "list-by-admin", "--request", `{"processCode":123,"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`},
}
for _, args := range invalidCases {
caller := &scriptedToolCaller{}
diff --git a/internal/helpers/polling_coverage_more_test.go b/internal/helpers/polling_coverage_more_test.go
index 8b58bdbf..5cc02f26 100644
--- a/internal/helpers/polling_coverage_more_test.go
+++ b/internal/helpers/polling_coverage_more_test.go
@@ -27,6 +27,11 @@ type scriptedToolCaller struct {
server string
tool string
args map[string]any
+ // Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit)
+ // can assert each invocation instead of only the last one captured above.
+ serverLog []string
+ toolLog []string
+ argsLog []map[string]any
}
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
@@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri
c.server = serverID
c.tool = toolName
c.args = args
+ c.serverLog = append(c.serverLog, serverID)
+ c.toolLog = append(c.toolLog, toolName)
+ c.argsLog = append(c.argsLog, args)
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go
new file mode 100644
index 00000000..cb2979c9
--- /dev/null
+++ b/internal/helpers/recruit.go
@@ -0,0 +1,678 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+
+package helpers
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+)
+
+const (
+ recruitServerID = "recruit"
+ recruitCreateJobTool = "create_job"
+ recruitGetJobTool = "get_job_detail"
+ recruitListJobsTool = "list_jobs"
+)
+
+var recruitDryRun = &contract.DryRunSpec{
+ PreviewKind: contract.DryRunPreviewRequest,
+ RemoteReads: false,
+}
+
+var (
+ recruitListResult = &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果;续页信息位于 meta.pagination","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}}},"additionalProperties":true}`),
+ }
+ recruitJobDetailResult = &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位详情","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"description":{"type":"string","description":"职位描述"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}`),
+ }
+ recruitCreateJobResult = &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位创建结果","properties":{"jobId":{"type":"string","description":"新创建的职位 ID"}},"required":["jobId"],"additionalProperties":true}`),
+ }
+)
+
+func recruitCursorPagination() *contract.PaginationSpec {
+ return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"}
+}
+
+func newRecruitCommand() *cobra.Command {
+ contract.RegisterProductDecl(contract.ProductDecl{
+ ID: "recruit",
+ Selection: contract.ProductSelectionDecl{
+ AgentSummary: "查询和创建钉钉招聘职位",
+ UseWhen: []string{"需要查询招聘职位列表、查看职位详情或创建职位时"},
+ AvoidWhen: []string{"查询企业员工与组织架构使用 contact;查询人才池、职业历程或绩效使用 hrbrain"},
+ },
+ })
+
+ root := &cobra.Command{
+ Use: "recruit",
+ Short: "钉钉招聘",
+ Long: "查询和创建钉钉招聘中的职位信息。",
+ RunE: groupRunE,
+ }
+ job := &cobra.Command{
+ Use: "job",
+ Short: "招聘职位管理",
+ RunE: groupRunE,
+ }
+ job.AddCommand(
+ newRecruitJobListCommand(),
+ newRecruitJobGetCommand(),
+ newRecruitJobCreateCommand(),
+ )
+ root.AddCommand(job)
+ return root
+}
+
+func newRecruitJobListCommand() *cobra.Command {
+ return NewLeafCommand(LeafSpec{
+ Use: "list",
+ Short: "查询招聘职位列表",
+ Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。",
+ Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json",
+ Server: recruitServerID,
+ Tool: recruitListJobsTool,
+ Safety: recruitSafetyRead(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Flags: []LeafFlag{
+ {Name: "job-ids", Usage: "职位 ID,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "jobIds"},
+ {Name: "required-edu", Usage: "学历要求枚举值", Kind: LeafInt, Bind: "requiredEdu"},
+ {Name: "status", Usage: "职位状态:draft/open/invalid/closed,多个值用逗号分隔", Bind: "statusList", Trim: true, OmitEmpty: true, Transform: transformRecruitStatuses},
+ {Name: "job-nature", Usage: "职位性质", Bind: "jobNature", Trim: true, OmitEmpty: true},
+ {Name: "campus", Usage: "是否为校园招聘", Kind: LeafBool, Bind: "campus"},
+ {Name: "start-modified-time", Usage: "修改时间范围起点", Bind: "startModifiedTime", Trim: true, OmitEmpty: true},
+ {Name: "end-modified-time", Usage: "修改时间范围终点", Bind: "endModifiedTime", Trim: true, OmitEmpty: true},
+ {Name: "creator-user-ids", Usage: "创建人 userId,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "creatorUserIds"},
+ {Name: "keyword", Usage: "职位搜索关键词", Bind: "keyword", Trim: true, OmitEmpty: true},
+ {Name: "category", Usage: "职位分类", Bind: "category", Trim: true, OmitEmpty: true},
+ {Name: "cursor", Usage: "分页游标;首次查询不传,翻页时原样回填返回的 nextCursor", Bind: "cursor", Trim: true, OmitEmpty: true, Transform: transformRecruitCursor},
+ {Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", Default: "20", ArgDefault: "20"},
+ },
+ Validate: validateRecruitList,
+ ResultCall: recruitResultCall,
+ Contract: LeafContract{
+ Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"},
+ Description: "按条件分页查询招聘职位",
+ DryRun: recruitDryRun,
+ Result: recruitListResult,
+ Pagination: recruitCursorPagination(),
+ Interface: recruitMCPInterface(recruitListJobsTool),
+ Selection: contract.SelectionSpec{
+ AgentSummary: "按关键词、状态、创建人等条件分页查询招聘职位",
+ UseWhen: []string{"需要查找职位、筛选在招职位或取得 jobId 时"},
+ AvoidWhen: []string{"已经持有明确 jobId 且需要完整详情时使用 recruit job get"},
+ Examples: []string{`dws recruit job list --keyword "Java" --status open --size 20 --format json`},
+ },
+ Parameters: recruitListParamDecls(),
+ },
+ })
+}
+
+func recruitListToolArgs(args map[string]any) map[string]any {
+ params := map[string]any{"param": map[string]any{}, "size": args["size"]}
+ query := params["param"].(map[string]any)
+ for key, value := range args {
+ switch key {
+ case "cursor":
+ params[key] = value
+ case "size":
+ default:
+ query[key] = value
+ }
+ }
+ return params
+}
+
+func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
+ toolArgs := args
+ if tool == recruitListJobsTool {
+ toolArgs = recruitListToolArgs(args)
+ }
+ if deps.Caller.DryRun() {
+ return output.Success(map[string]any{
+ "tool": tool, "arguments": toolArgs, "executed": false,
+ }, output.WithDryRun()), nil
+ }
+ data, err := callRecruitMCPToolData(cmd.Context(), tool, toolArgs)
+ if err != nil {
+ return nil, err
+ }
+ clean, err := recruitBusinessResultData(data, tool)
+ if err != nil {
+ return recruitResponseFailure(err), nil
+ }
+ switch tool {
+ case recruitListJobsTool:
+ listData, meta, err := recruitListResultData(clean)
+ if err != nil {
+ return recruitInvalidResponse(err), nil
+ }
+ return output.Success(listData, output.WithMeta(meta)), nil
+ case recruitGetJobTool, recruitCreateJobTool:
+ if err := validateRecruitJobResult(clean, tool, args); err != nil {
+ return recruitInvalidResponse(err), nil
+ }
+ return output.Success(clean), nil
+ default:
+ return recruitInvalidResponse(fmt.Errorf("不支持校验 %s 的业务结果", tool)), nil
+ }
+}
+
+func validateRecruitJobResult(data map[string]any, tool string, args map[string]any) error {
+ jobID, ok := data["jobId"].(string)
+ if !ok || strings.TrimSpace(jobID) == "" {
+ return fmt.Errorf("%s 返回值缺少非空字符串字段 jobId", tool)
+ }
+ if tool != recruitGetJobTool {
+ return nil
+ }
+ requestedJobID, ok := args["jobId"].(string)
+ if !ok || strings.TrimSpace(requestedJobID) == "" {
+ return fmt.Errorf("%s 请求缺少非空字符串字段 jobId", tool)
+ }
+ if strings.TrimSpace(jobID) != strings.TrimSpace(requestedJobID) {
+ return fmt.Errorf("%s 返回的 jobId %q 与请求的 jobId %q 不一致", tool, jobID, requestedJobID)
+ }
+ return nil
+}
+
+func callRecruitMCPToolData(ctx context.Context, tool string, args map[string]any) (any, error) {
+ text, err := callMCPToolReturnTextOnServer(ctx, recruitServerID, tool, args)
+ if err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(text) == "" {
+ return map[string]any{}, nil
+ }
+ var data any
+ decoder := json.NewDecoder(strings.NewReader(text))
+ decoder.UseNumber()
+ if err := decoder.Decode(&data); err != nil {
+ return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
+ }
+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
+ if err == nil {
+ err = fmt.Errorf("存在多个 JSON 值")
+ }
+ return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
+ }
+ return data, nil
+}
+
+func recruitResponseFailure(err error) output.CommandResult {
+ var businessFailure *recruitBusinessFailure
+ if errors.As(err, &businessFailure) {
+ return output.Failure(&output.ErrorInfo{
+ Type: "api", Message: businessFailure.Error(),
+ })
+ }
+ return recruitInvalidResponse(err)
+}
+
+type recruitBusinessFailure struct {
+ message string
+}
+
+func (e *recruitBusinessFailure) Error() string {
+ return e.message
+}
+
+func recruitBusinessResultData(data any, tool string) (map[string]any, error) {
+ object, ok := data.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("%s 返回值必须是 JSON 对象", tool)
+ }
+ successValue, hasSuccess := object["success"]
+ resultValue, hasResult := object["result"]
+ if !hasSuccess && !hasResult {
+ return object, nil
+ }
+ if !hasSuccess || !hasResult {
+ return nil, fmt.Errorf("%s 返回的 Connector 信封必须同时包含 success 和 result", tool)
+ }
+ success, ok := successValue.(bool)
+ if !ok {
+ return nil, fmt.Errorf("%s 返回的 Connector 信封字段 success 必须是布尔值", tool)
+ }
+ if !success {
+ message, _ := object["message"].(string)
+ if strings.TrimSpace(message) == "" {
+ message = "Connector 返回 success=false"
+ }
+ return nil, &recruitBusinessFailure{message: fmt.Sprintf("%s 调用失败: %s", tool, message)}
+ }
+ result, ok := resultValue.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("%s 返回值的 result 必须是 JSON 对象", tool)
+ }
+ return result, nil
+}
+
+func recruitInvalidResponse(err error) output.CommandResult {
+ return output.Failure(&output.ErrorInfo{
+ Type: "api", Subtype: "invalid_response", Message: err.Error(),
+ Hint: "保留原始响应并停止后续操作;不要依据不完整结果继续处理。",
+ })
+}
+
+func recruitListResultData(data any) (any, *output.Meta, error) {
+ object, ok := data.(map[string]any)
+ if !ok {
+ return nil, nil, fmt.Errorf("%s 返回值必须是 JSON 对象", recruitListJobsTool)
+ }
+ // The Connector envelope has already been removed by recruitResultCall.
+ // Only normalize the list business payload here so fields named success or
+ // result inside that payload cannot be mistaken for another envelope.
+ hasMore, ok := object["hasMore"].(bool)
+ if !ok {
+ return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore")
+ }
+ nextToken := ""
+ if hasMore {
+ if raw, exists := object["nextCursor"]; exists && raw != nil {
+ var err error
+ nextToken, err = normalizeRecruitNextCursor(raw)
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+ if nextToken == "" {
+ return nil, nil, fmt.Errorf("list_jobs 返回 hasMore=true 但缺少 nextCursor")
+ }
+ }
+ clean := make(map[string]any, len(object))
+ for key, value := range object {
+ if key != "hasMore" && key != "nextCursor" {
+ clean[key] = value
+ }
+ }
+ if jobs, exists := clean["list"]; exists {
+ if _, alreadyNormalized := clean["jobs"]; !alreadyNormalized {
+ clean["jobs"] = jobs
+ }
+ delete(clean, "list")
+ }
+ pagination := &output.Pagination{EndpointExhausted: !hasMore, NextToken: nextToken, Pages: 1}
+ meta := &output.Meta{Pagination: pagination}
+ if jobs, exists := clean["jobs"].([]any); exists {
+ meta.Count = output.NewCount(len(jobs))
+ pagination.Items = len(jobs)
+ }
+ return clean, meta, nil
+}
+
+func normalizeRecruitNextCursor(raw any) (string, error) {
+ var value string
+ switch cursor := raw.(type) {
+ case string:
+ value = strings.TrimSpace(cursor)
+ case json.Number:
+ value = string(cursor)
+ case float64:
+ value = strconv.FormatFloat(cursor, 'f', -1, 64)
+ default:
+ return "", fmt.Errorf("list_jobs 的 nextCursor 必须是字符串或数字")
+ }
+ parsed, err := strconv.ParseInt(value, 10, 64)
+ if err != nil || parsed < 0 {
+ return "", fmt.Errorf("list_jobs 的 nextCursor 必须是可回填的非负十进制 int64 游标")
+ }
+ return strconv.FormatInt(parsed, 10), nil
+}
+
+func newRecruitJobGetCommand() *cobra.Command {
+ return NewLeafCommand(LeafSpec{
+ Use: "get",
+ Short: "查询招聘职位详情",
+ Long: "根据职位 ID 查询招聘职位详情。",
+ Example: " dws recruit job get --job-id JOB_ID --format json",
+ Server: recruitServerID,
+ Tool: recruitGetJobTool,
+ Safety: recruitSafetyRead(),
+ OutputRollout: output.RolloutUnifiedActive,
+ ResultCall: recruitResultCall,
+ Flags: []LeafFlag{{
+ Name: "job-id", Usage: "职位 ID(必填)", Bind: "jobId", Trim: true,
+ Required: true, RequiredHint: "--job-id 为必填",
+ }},
+ Contract: LeafContract{
+ Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitGetJobTool, CanonicalPath: "recruit.get_job_detail", CLIPath: "recruit job get", PrimaryCLIPath: "recruit job get"},
+ Description: "根据职位 ID 查询招聘职位详情",
+ DryRun: recruitDryRun,
+ Result: recruitJobDetailResult,
+ Interface: recruitMCPInterface(recruitGetJobTool),
+ Selection: contract.SelectionSpec{
+ AgentSummary: "获取指定招聘职位的完整信息",
+ UseWhen: []string{"已经持有明确 jobId,需要查看职位详情时"},
+ AvoidWhen: []string{"不知道 jobId 时先使用 recruit job list 查询"},
+ Examples: []string{"dws recruit job get --job-id --format json"},
+ },
+ Parameters: []contract.ParamDecl{{Name: "job-id", Property: "jobId", Required: boolPtr(true), InterfaceType: "string"}},
+ },
+ })
+}
+
+func newRecruitJobCreateCommand() *cobra.Command {
+ return NewLeafCommand(LeafSpec{
+ Use: "create",
+ Short: "创建招聘职位",
+ Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。",
+ Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json",
+ Server: recruitServerID,
+ Tool: recruitCreateJobTool,
+ Safety: recruitSafetyCreate(),
+ OutputRollout: output.RolloutUnifiedActive,
+ ResultCall: recruitResultCall,
+ Flags: []LeafFlag{{
+ Name: "from", Usage: "职位 JSON 文件路径(必填)", Bind: "atsAddJobParam", Trim: true,
+ Required: true, RequiredHint: "--from 为必填", Transform: loadRecruitJobFile,
+ }},
+ Contract: LeafContract{
+ Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitCreateJobTool, CanonicalPath: "recruit.create_job", CLIPath: "recruit job create", PrimaryCLIPath: "recruit job create"},
+ Description: "从 JSON 文件创建招聘职位",
+ DryRun: recruitDryRun,
+ Result: recruitCreateJobResult,
+ Interface: recruitMCPInterface(recruitCreateJobTool),
+ Selection: contract.SelectionSpec{
+ AgentSummary: "使用结构化 JSON 创建招聘职位",
+ UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、extData、creatorUserId;jobNature 固定为 FULL-TIME;creatorUserId 必须使用真实创建人 userId;ownerUserIds 可选"},
+ AvoidWhen: []string{"仅查询职位时使用 recruit job list 或 recruit job get"},
+ Examples: []string{"dws recruit job create --from ./job.json --dry-run --format json"},
+ },
+ Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 校验后原样作为 atsAddJobParam 对象发送;creatorUserId 为必填的创建人 userId,ownerUserIds 为可选的负责人 userId 字符串数组"}},
+ },
+ })
+}
+
+func recruitListParamDecls() []contract.ParamDecl {
+ return []contract.ParamDecl{
+ {Name: "job-ids", Property: "param.jobIds", InterfaceType: "array"},
+ {Name: "required-edu", Property: "param.requiredEdu", InterfaceType: "number"},
+ {Name: "status", Property: "param.statusList", InterfaceType: "array", Enum: []string{"draft", "open", "invalid", "closed"}},
+ {Name: "job-nature", Property: "param.jobNature", InterfaceType: "string"},
+ {Name: "campus", Property: "param.campus", InterfaceType: "boolean"},
+ {Name: "start-modified-time", Property: "param.startModifiedTime", InterfaceType: "string"},
+ {Name: "end-modified-time", Property: "param.endModifiedTime", InterfaceType: "string"},
+ {Name: "creator-user-ids", Property: "param.creatorUserIds", InterfaceType: "array"},
+ {Name: "keyword", Property: "param.keyword", InterfaceType: "string"},
+ {Name: "category", Property: "param.category", InterfaceType: "string"},
+ {Name: "cursor", Property: "cursor", InterfaceType: "number"},
+ {Name: "size", Property: "size", InterfaceType: "number"},
+ }
+}
+
+func recruitMCPInterface(tool string) *contract.InterfaceSpec {
+ return &contract.InterfaceSpec{
+ Mode: contract.InterfaceModeMCP,
+ Availability: contract.InterfaceAvailable,
+ Ref: &contract.InterfaceRefSpec{ProductID: recruitServerID, RPCName: tool},
+ }
+}
+
+func recruitSafetyRead() contract.SafetySpec {
+ return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
+}
+
+func recruitSafetyCreate() contract.SafetySpec {
+ return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent"}
+}
+
+func transformRecruitStatuses(raw string) (any, error) {
+ values := strings.Split(raw, ",")
+ statuses := make([]int, 0, len(values))
+ seen := make(map[int]bool, len(values))
+ lookup := map[string]int{"draft": 0, "open": 1, "invalid": 2, "closed": 3}
+ for _, value := range values {
+ name := strings.ToLower(strings.TrimSpace(value))
+ if name == "" {
+ continue
+ }
+ status, ok := lookup[name]
+ if !ok {
+ return nil, apperrors.NewValidation(fmt.Sprintf("--status 不支持 %q,可选 draft/open/invalid/closed", value))
+ }
+ if !seen[status] {
+ statuses = append(statuses, status)
+ seen[status] = true
+ }
+ }
+ return statuses, nil
+}
+
+func transformRecruitCursor(raw string) (any, error) {
+ value := strings.TrimSpace(raw)
+ cursor, err := strconv.ParseInt(value, 10, 64)
+ if err != nil || cursor < 0 {
+ return nil, apperrors.NewValidation("--cursor 必须是大于或等于 0 的整数")
+ }
+ return cursor, nil
+}
+
+func validateRecruitList(cmd *cobra.Command, _ []string) error {
+ size, _ := cmd.Flags().GetInt("size")
+ if cmd.Flags().Changed("size") && (size < 1 || size > 100) {
+ return apperrors.NewValidation("--size 必须在 1 到 100 之间")
+ }
+ requiredEdu, _ := cmd.Flags().GetInt("required-edu")
+ if cmd.Flags().Changed("required-edu") && (requiredEdu < 1 || requiredEdu > 9) {
+ return apperrors.NewValidation("--required-edu 必须在 1 到 9 之间")
+ }
+ return nil
+}
+
+func loadRecruitJobFile(path string) (any, error) {
+ data, err := os.ReadFile(strings.TrimSpace(path))
+ if err != nil {
+ return nil, fmt.Errorf("读取职位 JSON 失败: %w", err)
+ }
+ var job map[string]any
+ if err := json.Unmarshal(data, &job); err != nil {
+ return nil, apperrors.NewValidation(fmt.Sprintf("职位文件不是有效的 JSON 对象: %v", err))
+ }
+ if job == nil {
+ return nil, apperrors.NewValidation("职位 JSON 顶层必须是对象")
+ }
+ if err := validateRecruitJob(job); err != nil {
+ return nil, err
+ }
+ return job, nil
+}
+
+func validateRecruitJob(job map[string]any) error {
+ for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "extData", "creatorUserId"} {
+ value, ok := job[name]
+ if !ok || value == nil || (isString(value) && strings.TrimSpace(value.(string)) == "") {
+ return apperrors.NewValidation(fmt.Sprintf("职位 JSON 缺少必填字段 %s", name))
+ }
+ }
+ for _, name := range []string{"name", "description", "jobNature"} {
+ if !isString(job[name]) {
+ return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name))
+ }
+ }
+ if job["jobNature"].(string) != "FULL-TIME" {
+ return apperrors.NewValidation("职位 JSON 字段 jobNature 当前仅支持 FULL-TIME")
+ }
+ requiredEdu, ok := job["requiredEdu"].(float64)
+ if !ok {
+ return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是数字")
+ }
+ if requiredEdu < 1 || requiredEdu > 9 || requiredEdu != float64(int(requiredEdu)) {
+ return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是 1 到 9 的整数")
+ }
+
+ minSalary, hasMinSalary, err := optionalRecruitNumber(job, "minSalary")
+ if err != nil {
+ return err
+ }
+ maxSalary, hasMaxSalary, err := optionalRecruitNumber(job, "maxSalary")
+ if err != nil {
+ return err
+ }
+ if hasMinSalary && hasMaxSalary && minSalary > maxSalary {
+ return apperrors.NewValidation("职位 JSON 中 minSalary 不能大于 maxSalary")
+ }
+
+ for _, name := range []string{"province", "city", "district", "category"} {
+ if value, exists := job[name]; exists && value != nil && !isString(value) {
+ return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name))
+ }
+ }
+ if value, exists := job["campus"]; exists && value != nil {
+ if _, ok := value.(bool); !ok {
+ return apperrors.NewValidation("职位 JSON 字段 campus 必须是布尔值")
+ }
+ }
+ if err := validateRecruitIdentityFields(job); err != nil {
+ return err
+ }
+ if err := validateRecruitAddress(job); err != nil {
+ return err
+ }
+ extData, ok := job["extData"].(map[string]any)
+ if !ok {
+ return apperrors.NewValidation("职位 JSON 字段 extData 必须是对象")
+ }
+ if err := validateRecruitExtData(extData); err != nil {
+ return err
+ }
+ return nil
+}
+
+func optionalRecruitNumber(values map[string]any, name string) (float64, bool, error) {
+ value, exists := values[name]
+ if !exists || value == nil {
+ return 0, false, nil
+ }
+ number, ok := value.(float64)
+ if !ok {
+ return 0, false, apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是数字", name))
+ }
+ return number, true, nil
+}
+
+func validateRecruitIdentityFields(job map[string]any) error {
+ if _, ok := job["creatorUserId"].(string); !ok {
+ return apperrors.NewValidation("职位 JSON 字段 creatorUserId 必须是非空字符串")
+ }
+ if value, exists := job["ownerUserIds"]; exists && value != nil {
+ ownerUserIDs, ok := value.([]any)
+ if !ok {
+ return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 必须是字符串数组")
+ }
+ for _, owner := range ownerUserIDs {
+ ownerUserID, ok := owner.(string)
+ if !ok || strings.TrimSpace(ownerUserID) == "" {
+ return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 只能包含非空字符串")
+ }
+ }
+ }
+ return nil
+}
+
+func validateRecruitAddress(job map[string]any) error {
+ value, exists := job["address"]
+ if !exists || value == nil {
+ return nil
+ }
+ address, ok := value.(map[string]any)
+ if !ok {
+ return apperrors.NewValidation("职位 JSON 字段 address 必须是对象")
+ }
+ for _, name := range []string{"name", "detail", "longitude", "latitude"} {
+ field, exists := address[name]
+ text, ok := field.(string)
+ if !exists || !ok || strings.TrimSpace(text) == "" {
+ return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 address.%s 必须是非空字符串", name))
+ }
+ }
+ return nil
+}
+
+func validateRecruitExtData(extData map[string]any) error {
+ if value, exists := extData["headCount"]; exists && value != nil {
+ headCount, ok := value.(float64)
+ if !ok || headCount < 1 || headCount > 999 || headCount != float64(int(headCount)) {
+ return apperrors.NewValidation("职位 JSON 字段 extData.headCount 必须是 1 到 999 的整数")
+ }
+ }
+ if value, exists := extData["source"]; exists && value != nil && !isString(value) {
+ return apperrors.NewValidation("职位 JSON 字段 extData.source 必须是字符串")
+ }
+ if value, exists := extData["fullTimeExtData"]; exists && value != nil {
+ fullTime, ok := value.(map[string]any)
+ if !ok {
+ return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData 必须是对象")
+ }
+ if err := validateRecruitFullTimeExtData(fullTime); err != nil {
+ return err
+ }
+ }
+ if value, exists := extData["tags"]; exists && value != nil {
+ tags, ok := value.([]any)
+ if !ok {
+ return apperrors.NewValidation("职位 JSON 字段 extData.tags 必须是数组")
+ }
+ for _, value := range tags {
+ tag, ok := value.(map[string]any)
+ if !ok {
+ return apperrors.NewValidation("职位 JSON 字段 extData.tags 只能包含对象")
+ }
+ name, ok := tag["name"].(string)
+ if !ok || strings.TrimSpace(name) == "" {
+ return apperrors.NewValidation("职位 JSON 字段 extData.tags[].name 必须是非空字符串")
+ }
+ }
+ }
+ return nil
+}
+
+func validateRecruitFullTimeExtData(fullTime map[string]any) error {
+ salaryMonth, hasSalaryMonth, err := optionalRecruitNumber(fullTime, "salaryMonth")
+ if err != nil {
+ return err
+ }
+ if hasSalaryMonth && (salaryMonth < 12 || salaryMonth > 24 || salaryMonth != float64(int(salaryMonth))) {
+ return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.salaryMonth 必须是 12 到 24 的整数")
+ }
+ minExperience, hasMinExperience, err := optionalRecruitNumber(fullTime, "minJobExperience")
+ if err != nil {
+ return err
+ }
+ maxExperience, hasMaxExperience, err := optionalRecruitNumber(fullTime, "maxJobExperience")
+ if err != nil {
+ return err
+ }
+ if hasMinExperience && minExperience < 0 {
+ return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.minJobExperience 不能小于 0")
+ }
+ if hasMaxExperience && maxExperience < 0 {
+ return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.maxJobExperience 不能小于 0")
+ }
+ if hasMinExperience && hasMaxExperience && minExperience > maxExperience {
+ return apperrors.NewValidation("职位 JSON 中 minJobExperience 不能大于 maxJobExperience")
+ }
+ return nil
+}
+
+func isString(value any) bool {
+ _, ok := value.(string)
+ return ok
+}
diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go
new file mode 100644
index 00000000..bb8879b4
--- /dev/null
+++ b/internal/helpers/recruit_test.go
@@ -0,0 +1,1050 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+
+package helpers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ stderrors "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+type recruitCapturedCall struct {
+ productID string
+ tool string
+ args map[string]any
+}
+
+type recruitCaptureCaller struct {
+ productID string
+ tool string
+ args map[string]any
+ calls []recruitCapturedCall
+ err error
+ text string
+ dryRun bool
+}
+
+func (c *recruitCaptureCaller) CallTool(_ context.Context, productID, tool string, args map[string]any) (*edition.ToolResult, error) {
+ c.productID = productID
+ c.tool = tool
+ c.args = args
+ c.calls = append(c.calls, recruitCapturedCall{productID: productID, tool: tool, args: args})
+ if c.err != nil {
+ return nil, c.err
+ }
+ text := c.text
+ if text == "" {
+ text = `{}`
+ switch tool {
+ case recruitListJobsTool:
+ text = `{"jobs":[],"hasMore":false}`
+ case recruitCreateJobTool:
+ text = `{"jobId":"created-job"}`
+ }
+ }
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
+}
+
+func (c *recruitCaptureCaller) Format() string { return "json" }
+func (c *recruitCaptureCaller) DryRun() bool { return c.dryRun }
+func (c *recruitCaptureCaller) Fields() string { return "" }
+func (c *recruitCaptureCaller) JQ() string { return "" }
+
+func withRecruitCaller(t *testing.T) *recruitCaptureCaller {
+ t.Helper()
+ caller := &recruitCaptureCaller{}
+ InitDepsForTest(t, caller)
+ deps.Out.w = io.Discard
+ return caller
+}
+
+func prepareRecruitTestCommand(cmd *cobra.Command) *cobra.Command {
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ return cmd
+}
+
+func TestRecruitJobListWrapsQueryParameters(t *testing.T) {
+ caller := withRecruitCaller(t)
+ cmd := prepareRecruitTestCommand(newRecruitJobListCommand())
+ cmd.SetArgs([]string{
+ "--keyword", "Java", "--status", "open,draft", "--creator-user-ids", "u1,u2",
+ "--campus=false", "--required-edu", "9", "--cursor", "10", "--size", "30",
+ })
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ if caller.productID != recruitServerID || caller.tool != recruitListJobsTool {
+ t.Fatalf("dispatch = %s/%s, want %s/%s", caller.productID, caller.tool, recruitServerID, recruitListJobsTool)
+ }
+ if caller.args["cursor"] != int64(10) || caller.args["size"] != 30 {
+ t.Fatalf("pagination args = %#v", caller.args)
+ }
+ param, ok := caller.args["param"].(map[string]any)
+ if !ok {
+ t.Fatalf("param = %#v, want object", caller.args["param"])
+ }
+ if param["keyword"] != "Java" || param["campus"] != false || param["requiredEdu"] != 9 {
+ t.Fatalf("param = %#v", param)
+ }
+ statuses, ok := param["statusList"].([]int)
+ if !ok || len(statuses) != 2 || statuses[0] != 1 || statuses[1] != 0 {
+ t.Fatalf("statusList = %#v", param["statusList"])
+ }
+ creators, ok := param["creatorUserIds"].([]string)
+ if !ok || len(creators) != 2 || creators[0] != "u1" || creators[1] != "u2" {
+ t.Fatalf("creatorUserIds = %#v", param["creatorUserIds"])
+ }
+}
+
+func TestRecruitJobListCursorRoundTrip(t *testing.T) {
+ caller := withRecruitCaller(t)
+ cmd := prepareRecruitTestCommand(newRecruitJobListCommand())
+ cmd.SetArgs([]string{"--cursor", "9223372036854775807"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ if got := caller.args["cursor"]; got != int64(9223372036854775807) {
+ t.Fatalf("cursor = %#v (%T), want max int64", got, got)
+ }
+
+ for _, cursor := range []string{"not-a-cursor", "9223372036854775808"} {
+ invalid := prepareRecruitTestCommand(newRecruitJobListCommand())
+ invalid.SetArgs([]string{"--cursor", cursor})
+ if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0 的整数") {
+ t.Fatalf("cursor %q error = %v", cursor, err)
+ }
+ }
+}
+
+func TestRecruitJobListResponseCursorRoundTrip(t *testing.T) {
+ caller := withRecruitCaller(t)
+ caller.text = `{"success":true,"result":{"list":[],"hasMore":true,"nextCursor":9223372036854775807}}`
+
+ cmd := prepareRecruitTestCommand(newRecruitJobListCommand())
+ result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20})
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope, err := output.EnvelopeFromResult(result)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if envelope.Outcome != output.OutcomeSuccess || envelope.Meta == nil || envelope.Meta.Pagination == nil {
+ t.Fatalf("envelope = %#v", envelope)
+ }
+ meta := envelope.Meta
+ if got := meta.Pagination.NextToken; got != "9223372036854775807" {
+ t.Fatalf("next token = %q, want lossless max int64", got)
+ }
+
+ caller.text = `{"jobs":[],"hasMore":false}`
+ cmd = prepareRecruitTestCommand(newRecruitJobListCommand())
+ cmd.SetArgs([]string{"--cursor", meta.Pagination.NextToken})
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ if got := caller.args["cursor"]; got != int64(9223372036854775807) {
+ t.Fatalf("replayed cursor = %#v (%T), want max int64", got, got)
+ }
+}
+
+func TestRecruitJobListDefaultsAndValidation(t *testing.T) {
+ caller := withRecruitCaller(t)
+ cmd := prepareRecruitTestCommand(newRecruitJobListCommand())
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ if caller.args["size"] != 20 {
+ t.Fatalf("size = %#v, want 20", caller.args["size"])
+ }
+ if _, ok := caller.args["cursor"]; ok {
+ t.Fatalf("first page unexpectedly sent cursor: %#v", caller.args)
+ }
+
+ badStatus := prepareRecruitTestCommand(newRecruitJobListCommand())
+ badStatus.SetArgs([]string{"--status", "unknown"})
+ if err := badStatus.Execute(); err == nil || !strings.Contains(err.Error(), "draft/open/invalid/closed") {
+ t.Fatalf("invalid status error = %v", err)
+ }
+
+ badSize := prepareRecruitTestCommand(newRecruitJobListCommand())
+ badSize.SetArgs([]string{"--size", "101"})
+ if err := badSize.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 100") {
+ t.Fatalf("invalid size error = %v", err)
+ }
+
+ for _, requiredEdu := range []string{"0", "10"} {
+ t.Run("required edu "+requiredEdu, func(t *testing.T) {
+ caller := withRecruitCaller(t)
+ invalid := prepareRecruitTestCommand(newRecruitJobListCommand())
+ invalid.SetArgs([]string{"--required-edu", requiredEdu})
+ if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 9") {
+ t.Fatalf("required edu %s error = %v", requiredEdu, err)
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("MCP calls with invalid required edu = %d, want 0", len(caller.calls))
+ }
+ })
+ }
+}
+
+func TestRecruitJobGetDispatchesJobID(t *testing.T) {
+ caller := withRecruitCaller(t)
+ cmd := prepareRecruitTestCommand(newRecruitJobGetCommand())
+ cmd.SetArgs([]string{"--job-id", "job-1"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ if caller.productID != recruitServerID || caller.tool != recruitGetJobTool || caller.args["jobId"] != "job-1" {
+ t.Fatalf("captured call = %s/%s %#v", caller.productID, caller.tool, caller.args)
+ }
+}
+
+func writeRecruitJobFixture(t *testing.T) (string, map[string]any) {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "job.json")
+ job := map[string]any{
+ "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME",
+ "requiredEdu": 6, "minSalary": 20000, "maxSalary": 35000,
+ "creatorUserId": "creator-user-id", "ownerUserIds": []string{"owner-user-id-1", "owner-user-id-2"},
+ "extData": map[string]any{
+ "headCount": 1,
+ "fullTimeExtData": map[string]any{"salaryMonth": 12, "minJobExperience": 1, "maxJobExperience": 3},
+ },
+ }
+ data, err := json.Marshal(job)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path, job
+}
+
+func TestRecruitJobCreateRequiresConfirmationBeforeRemoteCall(t *testing.T) {
+ caller := withRecruitCaller(t)
+ path, _ := writeRecruitJobFixture(t)
+ cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand())
+ cmd.Flags().Bool("yes", false, "确认创建")
+ cmd.SetIn(strings.NewReader(""))
+ cmd.SetArgs([]string{"--from", path})
+ err := cmd.Execute()
+ var appErr *apperrors.Error
+ if !stderrors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
+ t.Fatalf("create without confirmation error = %T %v, want confirmation_required", err, err)
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("MCP calls before confirmation = %d: %#v, want none", len(caller.calls), caller.calls)
+ }
+}
+
+func TestRecruitJobCreateCallsRemoteOnceWhenConfirmed(t *testing.T) {
+ caller := withRecruitCaller(t)
+ path, _ := writeRecruitJobFixture(t)
+ cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand())
+ cmd.Flags().Bool("yes", false, "确认创建")
+ cmd.SetArgs([]string{"--from", path, "--yes"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("MCP calls after confirmation = %d: %#v, want exactly one", len(caller.calls), caller.calls)
+ }
+ call := caller.calls[0]
+ if call.productID != recruitServerID || call.tool != recruitCreateJobTool {
+ t.Fatalf("dispatch = %s/%s, want %s/%s", call.productID, call.tool, recruitServerID, recruitCreateJobTool)
+ }
+ wantArgs := map[string]any{"atsAddJobParam": map[string]any{
+ "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME",
+ "requiredEdu": float64(6), "minSalary": float64(20000), "maxSalary": float64(35000),
+ "creatorUserId": "creator-user-id", "ownerUserIds": []any{"owner-user-id-1", "owner-user-id-2"},
+ "extData": map[string]any{
+ "headCount": float64(1),
+ "fullTimeExtData": map[string]any{"salaryMonth": float64(12), "minJobExperience": float64(1), "maxJobExperience": float64(3)},
+ },
+ }}
+ if !reflect.DeepEqual(call.args, wantArgs) {
+ t.Fatalf("args = %#v, want %#v", call.args, wantArgs)
+ }
+}
+
+func TestRecruitCreateValidatesJobFile(t *testing.T) {
+ withRecruitCaller(t)
+ badPath := filepath.Join(t.TempDir(), "bad-job.json")
+ if err := os.WriteFile(badPath, []byte(`{"name":"缺字段"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ bad := prepareRecruitTestCommand(newRecruitJobCreateCommand())
+ bad.Flags().Bool("yes", false, "确认创建")
+ bad.SetArgs([]string{"--from", badPath, "--yes"})
+ if err := bad.Execute(); err == nil || !strings.Contains(err.Error(), "description") {
+ t.Fatalf("missing field error = %v", err)
+ }
+}
+
+func TestRecruitCreateRejectsInvalidCreatorBeforeRemoteCall(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ creator string
+ }{
+ {name: "missing", creator: ""},
+ {name: "null", creator: `,"creatorUserId":null`},
+ {name: "blank", creator: `,"creatorUserId":" "`},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ caller := withRecruitCaller(t)
+ path := filepath.Join(t.TempDir(), "job.json")
+ payload := `{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"extData":{}` + test.creator + `}`
+ if err := os.WriteFile(path, []byte(payload), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand())
+ cmd.Flags().Bool("yes", false, "确认创建")
+ cmd.SetArgs([]string{"--from", path, "--yes"})
+ if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "creatorUserId") {
+ t.Fatalf("creator validation error = %v, want creatorUserId error", err)
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("MCP calls with invalid creator = %d, want 0", len(caller.calls))
+ }
+ })
+ }
+}
+
+func TestRecruitCreateAllowsOptionalOwners(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ owner string
+ }{
+ {name: "missing"},
+ {name: "null", owner: `,"ownerUserIds":null`},
+ {name: "empty array", owner: `,"ownerUserIds":[]`},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ caller := withRecruitCaller(t)
+ path := filepath.Join(t.TempDir(), "job.json")
+ payload := `{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"extData":{},"creatorUserId":"creator-user-id"` + test.owner + `}`
+ if err := os.WriteFile(path, []byte(payload), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand())
+ cmd.Flags().Bool("yes", false, "确认创建")
+ cmd.SetArgs([]string{"--from", path, "--yes"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("optional owner create error = %v, want nil", err)
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("MCP calls with optional owner = %d, want 1", len(caller.calls))
+ }
+ })
+ }
+}
+
+func TestRecruitValidationBranches(t *testing.T) {
+ statuses, err := transformRecruitStatuses(" open, ,open,closed ")
+ if err != nil || !reflect.DeepEqual(statuses, []int{1, 3}) {
+ t.Fatalf("statuses = %#v, err = %v", statuses, err)
+ }
+
+ negativeCursor := prepareRecruitTestCommand(newRecruitJobListCommand())
+ negativeCursor.SetArgs([]string{"--cursor", "-1"})
+ if err := negativeCursor.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0") {
+ t.Fatalf("negative cursor error = %v", err)
+ }
+
+ nullPath := filepath.Join(t.TempDir(), "null.json")
+ if err := os.WriteFile(nullPath, []byte("null"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := loadRecruitJobFile(nullPath); err == nil || !strings.Contains(err.Error(), "顶层必须是对象") {
+ t.Fatalf("null job error = %v", err)
+ }
+ if _, err := loadRecruitJobFile(filepath.Join(t.TempDir(), "missing.json")); err == nil || !strings.Contains(err.Error(), "读取职位 JSON 失败") {
+ t.Fatalf("missing job file error = %v", err)
+ }
+ invalidPath := filepath.Join(t.TempDir(), "invalid.json")
+ if err := os.WriteFile(invalidPath, []byte("{"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := loadRecruitJobFile(invalidPath); err == nil || !strings.Contains(err.Error(), "不是有效的 JSON 对象") {
+ t.Fatalf("invalid job JSON error = %v", err)
+ }
+
+ base := map[string]any{
+ "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME",
+ "requiredEdu": float64(6), "minSalary": float64(20000), "maxSalary": float64(35000),
+ "creatorUserId": "creator-user-id", "ownerUserIds": []any{"owner-user-id"},
+ "extData": map[string]any{
+ "headCount": float64(1),
+ "fullTimeExtData": map[string]any{"salaryMonth": float64(12), "minJobExperience": float64(1), "maxJobExperience": float64(3)},
+ },
+ }
+ for _, test := range []struct {
+ name string
+ mutate func(map[string]any)
+ want string
+ }{
+ {name: "string type", mutate: func(job map[string]any) { job["name"] = 1 }, want: "name 必须是字符串"},
+ {name: "number type", mutate: func(job map[string]any) { job["requiredEdu"] = "本科" }, want: "requiredEdu 必须是数字"},
+ {name: "missing ext data", mutate: func(job map[string]any) { delete(job, "extData") }, want: "缺少必填字段 extData"},
+ {name: "missing creator", mutate: func(job map[string]any) { delete(job, "creatorUserId") }, want: "缺少必填字段 creatorUserId"},
+ {name: "null creator", mutate: func(job map[string]any) { job["creatorUserId"] = nil }, want: "缺少必填字段 creatorUserId"},
+ {name: "ext data type", mutate: func(job map[string]any) { job["extData"] = "invalid" }, want: "extData 必须是对象"},
+ {name: "job nature enum", mutate: func(job map[string]any) { job["jobNature"] = "FULL_TIME" }, want: "仅支持 FULL-TIME"},
+ {name: "education range", mutate: func(job map[string]any) { job["requiredEdu"] = float64(10) }, want: "1 到 9 的整数"},
+ {name: "salary range", mutate: func(job map[string]any) { job["minSalary"] = float64(40000) }, want: "minSalary 不能大于 maxSalary"},
+ {name: "min salary type", mutate: func(job map[string]any) { job["minSalary"] = "20000" }, want: "minSalary 必须是数字"},
+ {name: "salary type", mutate: func(job map[string]any) { job["maxSalary"] = "35000" }, want: "maxSalary 必须是数字"},
+ {name: "creator type", mutate: func(job map[string]any) { job["creatorUserId"] = " " }, want: "缺少必填字段 creatorUserId"},
+ {name: "creator non-string", mutate: func(job map[string]any) { job["creatorUserId"] = float64(123) }, want: "creatorUserId 必须是非空字符串"},
+ {name: "owners type", mutate: func(job map[string]any) { job["ownerUserIds"] = "owner-user-id" }, want: "ownerUserIds 必须是字符串数组"},
+ {name: "owner item", mutate: func(job map[string]any) { job["ownerUserIds"] = []any{""} }, want: "ownerUserIds 只能包含非空字符串"},
+ {name: "campus type", mutate: func(job map[string]any) { job["campus"] = "false" }, want: "campus 必须是布尔值"},
+ {name: "region type", mutate: func(job map[string]any) { job["city"] = 330100 }, want: "city 必须是字符串"},
+ {name: "address type", mutate: func(job map[string]any) { job["address"] = "杭州" }, want: "address 必须是对象"},
+ {name: "address required field", mutate: func(job map[string]any) {
+ job["address"] = map[string]any{"name": "亲橙Park", "detail": "杭州市余杭区", "longitude": "120.0"}
+ }, want: "address.latitude 必须是非空字符串"},
+ {name: "head count range", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"headCount": float64(0)}
+ }, want: "headCount 必须是 1 到 999 的整数"},
+ {name: "source type", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"source": true}
+ }, want: "extData.source 必须是字符串"},
+ {name: "full time ext data type", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"fullTimeExtData": "invalid"}
+ }, want: "fullTimeExtData 必须是对象"},
+ {name: "salary month range", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"salaryMonth": float64(25)}}
+ }, want: "salaryMonth 必须是 12 到 24 的整数"},
+ {name: "salary month type", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"salaryMonth": "12"}}
+ }, want: "salaryMonth 必须是数字"},
+ {name: "min experience type", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": "1"}}
+ }, want: "minJobExperience 必须是数字"},
+ {name: "max experience type", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"maxJobExperience": "3"}}
+ }, want: "maxJobExperience 必须是数字"},
+ {name: "negative min experience", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": float64(-1)}}
+ }, want: "minJobExperience 不能小于 0"},
+ {name: "negative max experience", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"maxJobExperience": float64(-1)}}
+ }, want: "maxJobExperience 不能小于 0"},
+ {name: "experience range", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": float64(5), "maxJobExperience": float64(3)}}
+ }, want: "minJobExperience 不能大于 maxJobExperience"},
+ {name: "tags type", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"tags": "五险一金"}
+ }, want: "extData.tags 必须是数组"},
+ {name: "tag item type", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"tags": []any{"五险一金"}}
+ }, want: "extData.tags 只能包含对象"},
+ {name: "tag name", mutate: func(job map[string]any) {
+ job["extData"] = map[string]any{"tags": []any{map[string]any{"name": ""}}}
+ }, want: "tags[].name 必须是非空字符串"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ job := make(map[string]any, len(base))
+ for key, value := range base {
+ job[key] = value
+ }
+ test.mutate(job)
+ if err := validateRecruitJob(job); err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("validation error = %v, want %q", err, test.want)
+ }
+ })
+ }
+
+ withoutSalary := make(map[string]any, len(base))
+ for key, value := range base {
+ withoutSalary[key] = value
+ }
+ delete(withoutSalary, "minSalary")
+ delete(withoutSalary, "maxSalary")
+ if err := validateRecruitJob(withoutSalary); err != nil {
+ t.Fatalf("optional salary validation error = %v, want nil", err)
+ }
+
+ completeAddress := make(map[string]any, len(base)+1)
+ for key, value := range base {
+ completeAddress[key] = value
+ }
+ completeAddress["address"] = map[string]any{
+ "name": "亲橙Park", "detail": "浙江省杭州市余杭区", "longitude": "120.008822", "latitude": "30.270739",
+ }
+ completeAddress["campus"] = false
+ completeAddress["province"] = "330000"
+ completeAddress["city"] = "330100"
+ completeAddress["district"] = "330110"
+ if err := validateRecruitJob(completeAddress); err != nil {
+ t.Fatalf("complete job validation error = %v, want nil", err)
+ }
+}
+
+func TestRecruitListResultData(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ data any
+ want string
+ }{
+ {name: "not object", data: []any{}, want: "必须是 JSON 对象"},
+ {name: "missing hasMore", data: map[string]any{}, want: "缺少布尔字段 hasMore"},
+ {name: "invalid cursor", data: map[string]any{"hasMore": true, "nextCursor": true}, want: "必须是字符串或数字"},
+ {name: "missing cursor", data: map[string]any{"hasMore": true}, want: "缺少 nextCursor"},
+ {name: "exponent cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("1e3")}, want: "非负十进制 int64"},
+ {name: "decimal cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("1.5")}, want: "非负十进制 int64"},
+ {name: "overflow cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("9223372036854775808")}, want: "非负十进制 int64"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ if _, _, err := recruitListResultData(test.data); err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("error = %v, want %q", err, test.want)
+ }
+ })
+ }
+
+ data, meta, err := recruitListResultData(map[string]any{
+ "jobs": []any{map[string]any{"jobId": "job-1"}}, "hasMore": true, "nextCursor": float64(12),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if meta.Count == nil || *meta.Count != 1 || meta.Pagination.EndpointExhausted ||
+ meta.Pagination.NextToken != "12" || meta.Pagination.Pages != 1 || meta.Pagination.Items != 1 {
+ t.Fatalf("meta = %#v", meta)
+ }
+ clean := data.(map[string]any)
+ if _, exists := clean["hasMore"]; exists || clean["jobs"] == nil {
+ t.Fatalf("clean data = %#v", clean)
+ }
+
+ _, normalizedCursor, err := recruitListResultData(map[string]any{
+ "jobs": []any{}, "hasMore": true, "nextCursor": "00012",
+ })
+ if err != nil || normalizedCursor.Pagination.NextToken != "12" {
+ t.Fatalf("normalized cursor meta = %#v, err = %v", normalizedCursor, err)
+ }
+
+ _, terminal, err := recruitListResultData(map[string]any{"jobs": []any{}, "hasMore": false, "nextCursor": "ignored"})
+ if err != nil || !terminal.Pagination.EndpointExhausted || terminal.Pagination.NextToken != "" {
+ t.Fatalf("terminal meta = %#v, err = %v", terminal, err)
+ }
+
+ normalized, normalizedMeta, err := recruitListResultData(map[string]any{
+ "list": []any{map[string]any{"jobId": "job-2", "name": "测试工程师"}},
+ "hasMore": false,
+ "nextCursor": nil,
+ "totalCount": float64(1),
+ "success": true,
+ "result": map[string]any{"source": "business-payload"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ normalizedData := normalized.(map[string]any)
+ jobs, ok := normalizedData["jobs"].([]any)
+ if !ok || len(jobs) != 1 || normalizedData["totalCount"] != float64(1) {
+ t.Fatalf("normalized data = %#v", normalizedData)
+ }
+ if _, exists := normalizedData["list"]; exists {
+ t.Fatalf("normalized data retained Connector list field: %#v", normalizedData)
+ }
+ if normalizedData["success"] != true || !reflect.DeepEqual(
+ normalizedData["result"], map[string]any{"source": "business-payload"},
+ ) {
+ t.Fatalf("business success/result fields were unwrapped: %#v", normalizedData)
+ }
+ if normalizedMeta.Count == nil || *normalizedMeta.Count != 1 || !normalizedMeta.Pagination.EndpointExhausted {
+ t.Fatalf("normalized meta = %#v", normalizedMeta)
+ }
+}
+
+func TestRecruitListResultDataAcceptsMinimalTerminalPage(t *testing.T) {
+ data, meta, err := recruitListResultData(map[string]any{"hasMore": false})
+ if err != nil {
+ t.Fatal(err)
+ }
+ clean, ok := data.(map[string]any)
+ if !ok || len(clean) != 0 {
+ t.Fatalf("data = %#v, want empty object", data)
+ }
+ if meta == nil || meta.Pagination == nil || !meta.Pagination.EndpointExhausted ||
+ meta.Pagination.NextToken != "" || meta.Pagination.Pages != 1 || meta.Pagination.Items != 0 {
+ t.Fatalf("meta = %#v, want exhausted terminal page", meta)
+ }
+ if meta.Count != nil {
+ t.Fatalf("count = %#v, want nil when jobs is absent", meta.Count)
+ }
+
+ caller := withRecruitCaller(t)
+ caller.text = `{"success":true,"result":{"hasMore":false}}`
+ cmd := prepareRecruitTestCommand(newRecruitJobListCommand())
+ result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20})
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope, err := output.EnvelopeFromResult(result)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if envelope.Outcome != output.OutcomeSuccess || envelope.Meta == nil ||
+ envelope.Meta.Pagination == nil || !envelope.Meta.Pagination.EndpointExhausted {
+ t.Fatalf("envelope = %#v, want successful exhausted terminal page", envelope)
+ }
+}
+
+func TestRecruitResultCallPropagatesMCPError(t *testing.T) {
+ caller := withRecruitCaller(t)
+ caller.err = stderrors.New("transport failed")
+ cmd := prepareRecruitTestCommand(newRecruitJobGetCommand())
+ if _, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}); err == nil {
+ t.Fatal("expected MCP error")
+ }
+}
+
+func TestRecruitMCPDataDecoderIsLosslessAndStrict(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ text string
+ wantErr string
+ }{
+ {name: "empty", text: " "},
+ {name: "invalid JSON", text: "{", wantErr: "解析 list_jobs 返回失败"},
+ {name: "multiple values", text: `{} {}`, wantErr: "存在多个 JSON 值"},
+ {name: "invalid trailing value", text: `{} {`, wantErr: "解析 list_jobs 返回失败"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ caller := withRecruitCaller(t)
+ caller.text = test.text
+ data, err := callRecruitMCPToolData(context.Background(), recruitListJobsTool, map[string]any{})
+ if test.wantErr != "" {
+ if err == nil || !strings.Contains(err.Error(), test.wantErr) {
+ t.Fatalf("error = %v, want %q", err, test.wantErr)
+ }
+ return
+ }
+ if err != nil || len(data.(map[string]any)) != 0 {
+ t.Fatalf("empty response data = %#v, err = %v", data, err)
+ }
+ })
+ }
+
+ caller := withRecruitCaller(t)
+ caller.text = `{"nextCursor":9223372036854775807}`
+ data, err := callRecruitMCPToolData(context.Background(), recruitListJobsTool, map[string]any{})
+ if err != nil || data.(map[string]any)["nextCursor"] != json.Number("9223372036854775807") {
+ t.Fatalf("lossless recruit number data = %#v, err = %v", data, err)
+ }
+}
+
+func TestRecruitResultCallUnwrapsConnectorEnvelopeForGetAndCreate(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ tool string
+ text string
+ want map[string]any
+ }{
+ {
+ name: "get detail",
+ tool: recruitGetJobTool,
+ text: `{"success":true,"result":{"jobId":"job-detail","name":"Java 工程师","status":1}}`,
+ want: map[string]any{"jobId": "job-detail", "name": "Java 工程师", "status": json.Number("1")},
+ },
+ {
+ name: "create job",
+ tool: recruitCreateJobTool,
+ text: `{"success":true,"result":{"jobId":"job-created"}}`,
+ want: map[string]any{"jobId": "job-created"},
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ caller := withRecruitCaller(t)
+ caller.text = test.text
+ data, err := callRecruitMCPToolData(context.Background(), test.tool, map[string]any{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ clean, err := recruitBusinessResultData(data, test.tool)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(clean, test.want) {
+ t.Fatalf("normalized data = %#v, want %#v", clean, test.want)
+ }
+ if _, hasSuccess := clean["success"]; hasSuccess {
+ t.Fatalf("normalized data retained Connector envelope: %#v", clean)
+ }
+ if _, hasResult := clean["result"]; hasResult {
+ t.Fatalf("normalized data retained nested result: %#v", clean)
+ }
+ })
+ }
+}
+
+func TestRecruitCommandsPublishUnwrappedConnectorResult(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ leaf func() *cobra.Command
+ args func(*testing.T) []string
+ text string
+ wantID string
+ }{
+ {
+ name: "get detail",
+ leaf: newRecruitJobGetCommand,
+ args: func(*testing.T) []string { return []string{"--job-id", "job-detail"} },
+ text: `{"success":true,"result":{"jobId":"job-detail","name":"Java 工程师","status":1}}`,
+ wantID: "job-detail",
+ },
+ {
+ name: "create job",
+ leaf: newRecruitJobCreateCommand,
+ args: func(t *testing.T) []string {
+ path, _ := writeRecruitJobFixture(t)
+ return []string{"--from", path, "--yes"}
+ },
+ text: `{"success":true,"result":{"jobId":"job-created"}}`,
+ wantID: "job-created",
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ caller := withRecruitCaller(t)
+ caller.text = test.text
+ root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
+ ctx, _ := output.WithResultStore(context.Background())
+ root.SetContext(ctx)
+ root.PersistentFlags().String("format", "json", "")
+ root.PersistentFlags().String("fields", "", "")
+ root.PersistentFlags().String("jq", "", "")
+ root.PersistentFlags().Bool("dry-run", false, "")
+ root.PersistentFlags().Bool("yes", false, "")
+ root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error {
+ _, _, err := output.EmitStoredResult(cmd)
+ return err
+ }
+ leaf := test.leaf()
+ root.AddCommand(leaf)
+ var stdout bytes.Buffer
+ root.SetOut(&stdout)
+ root.SetArgs(append([]string{leaf.Name()}, test.args(t)...))
+ if err := root.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ var envelope struct {
+ Data map[string]any `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode output %q: %v", stdout.String(), err)
+ }
+ if envelope.Data["jobId"] != test.wantID {
+ t.Fatalf("data = %#v, want top-level jobId %q", envelope.Data, test.wantID)
+ }
+ if _, exists := envelope.Data["success"]; exists {
+ t.Fatalf("public data retained Connector success: %#v", envelope.Data)
+ }
+ if _, exists := envelope.Data["result"]; exists {
+ t.Fatalf("public data retained Connector result: %#v", envelope.Data)
+ }
+ })
+ }
+}
+
+func TestRecruitBusinessResultDataRejectsInvalidConnectorEnvelope(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ data any
+ want string
+ }{
+ {name: "not object", data: []any{}, want: "必须是 JSON 对象"},
+ {name: "missing result", data: map[string]any{"success": true}, want: "同时包含 success 和 result"},
+ {name: "missing success", data: map[string]any{"result": map[string]any{}}, want: "同时包含 success 和 result"},
+ {name: "invalid success", data: map[string]any{"success": "true", "result": map[string]any{}}, want: "success 必须是布尔值"},
+ {name: "business failure", data: map[string]any{"success": false, "message": "职位不存在", "result": map[string]any{}}, want: "职位不存在"},
+ {name: "business failure without message", data: map[string]any{"success": false, "result": map[string]any{}}, want: "Connector 返回 success=false"},
+ {name: "invalid result", data: map[string]any{"success": true, "result": nil}, want: "result 必须是 JSON 对象"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ _, err := recruitBusinessResultData(test.data, recruitGetJobTool)
+ if err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("error = %v, want %q", err, test.want)
+ }
+ var businessFailure *recruitBusinessFailure
+ isBusinessFailure := stderrors.As(err, &businessFailure)
+ wantBusinessFailure := strings.HasPrefix(test.name, "business failure")
+ if isBusinessFailure != wantBusinessFailure {
+ t.Fatalf("business failure classification = %t, want %t", isBusinessFailure, wantBusinessFailure)
+ }
+ })
+ }
+}
+
+func TestRecruitResultCallClassifiesBusinessAndMalformedFailures(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ text string
+ wantSubtype string
+ wantMessage string
+ wantHint bool
+ }{
+ {
+ name: "business failure",
+ text: `{"success":false,"message":"职位不存在","result":{}}`,
+ wantMessage: "职位不存在",
+ },
+ {
+ name: "malformed envelope",
+ text: `{"success":true}`,
+ wantSubtype: "invalid_response",
+ wantMessage: "同时包含 success 和 result",
+ wantHint: true,
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ var payload any
+ if err := json.Unmarshal([]byte(test.text), &payload); err != nil {
+ t.Fatal(err)
+ }
+ _, responseErr := recruitBusinessResultData(payload, recruitGetJobTool)
+ if responseErr == nil {
+ t.Fatal("expected response error")
+ }
+ result := recruitResponseFailure(responseErr)
+ envelope, err := output.EnvelopeFromResult(result)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil {
+ t.Fatalf("envelope = %#v", envelope)
+ }
+ if envelope.Error.Type != "api" || envelope.Error.Subtype != test.wantSubtype ||
+ !strings.Contains(envelope.Error.Message, test.wantMessage) {
+ t.Fatalf("error = %#v", envelope.Error)
+ }
+ if (envelope.Error.Hint != "") != test.wantHint {
+ t.Fatalf("hint = %q, want present %t", envelope.Error.Hint, test.wantHint)
+ }
+ })
+ }
+}
+
+func TestRecruitResultCallRejectsInconsistentPagination(t *testing.T) {
+ for _, response := range []string{
+ `{"jobs":[],"hasMore":true}`,
+ `{"jobs":[],"hasMore":true,"nextCursor":1e3}`,
+ `{"jobs":[],"hasMore":true,"nextCursor":1.5}`,
+ `{"jobs":[],"hasMore":true,"nextCursor":9223372036854775808}`,
+ } {
+ t.Run(response, func(t *testing.T) {
+ caller := withRecruitCaller(t)
+ caller.text = response
+ cmd := prepareRecruitTestCommand(newRecruitJobListCommand())
+ result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20})
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope, err := output.EnvelopeFromResult(result)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil ||
+ envelope.Error.Subtype != "invalid_response" {
+ t.Fatalf("envelope = %#v, want invalid_response failure", envelope)
+ }
+ })
+ }
+}
+
+func TestRecruitResultCallRejectsInvalidJobResults(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ tool string
+ args map[string]any
+ text string
+ wantMessage string
+ }{
+ {
+ name: "create empty response",
+ tool: recruitCreateJobTool,
+ text: " ",
+ wantMessage: "缺少非空字符串字段 jobId",
+ },
+ {
+ name: "create empty object",
+ tool: recruitCreateJobTool,
+ text: `{}`,
+ wantMessage: "缺少非空字符串字段 jobId",
+ },
+ {
+ name: "create envelope missing job id",
+ tool: recruitCreateJobTool,
+ text: `{"success":true,"result":{}}`,
+ wantMessage: "缺少非空字符串字段 jobId",
+ },
+ {
+ name: "create empty job id",
+ tool: recruitCreateJobTool,
+ text: `{"success":true,"result":{"jobId":" "}}`,
+ wantMessage: "缺少非空字符串字段 jobId",
+ },
+ {
+ name: "create non-string job id",
+ tool: recruitCreateJobTool,
+ text: `{"success":true,"result":{"jobId":123}}`,
+ wantMessage: "缺少非空字符串字段 jobId",
+ },
+ {
+ name: "get missing job id",
+ tool: recruitGetJobTool,
+ args: map[string]any{"jobId": "job-requested"},
+ text: `{"success":true,"result":{"name":"Java 工程师"}}`,
+ wantMessage: "缺少非空字符串字段 jobId",
+ },
+ {
+ name: "get request missing job id",
+ tool: recruitGetJobTool,
+ args: map[string]any{},
+ text: `{"success":true,"result":{"jobId":"job-returned"}}`,
+ wantMessage: "请求缺少非空字符串字段 jobId",
+ },
+ {
+ name: "get mismatched job id",
+ tool: recruitGetJobTool,
+ args: map[string]any{"jobId": "job-requested"},
+ text: `{"success":true,"result":{"jobId":"job-other"}}`,
+ wantMessage: "与请求的 jobId",
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ caller := withRecruitCaller(t)
+ caller.text = test.text
+ result, err := recruitResultCall(prepareRecruitTestCommand(&cobra.Command{}), test.tool, test.args)
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope, err := output.EnvelopeFromResult(result)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil {
+ t.Fatalf("envelope = %#v, want failure", envelope)
+ }
+ if envelope.Error.Type != "api" || envelope.Error.Subtype != "invalid_response" ||
+ !strings.Contains(envelope.Error.Message, test.wantMessage) {
+ t.Fatalf("error = %#v, want invalid_response containing %q", envelope.Error, test.wantMessage)
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("MCP calls = %d, want exactly one", len(caller.calls))
+ }
+ })
+ }
+}
+
+func TestRecruitResultCallRejectsToolWithoutResultValidator(t *testing.T) {
+ caller := withRecruitCaller(t)
+ caller.text = `{"jobId":"job-created"}`
+ result, err := recruitResultCall(prepareRecruitTestCommand(&cobra.Command{}), "future_write_tool", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope, err := output.EnvelopeFromResult(result)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil ||
+ envelope.Error.Subtype != "invalid_response" || !strings.Contains(envelope.Error.Message, "不支持校验") {
+ t.Fatalf("envelope = %#v, want unsupported validator failure", envelope)
+ }
+}
+
+func TestRecruitResultCallDryRun(t *testing.T) {
+ caller := withRecruitCaller(t)
+ caller.dryRun = true
+ cmd := prepareRecruitTestCommand(newRecruitJobGetCommand())
+ result, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.Outcome() != output.OutcomeSuccess || len(caller.calls) != 0 {
+ t.Fatalf("outcome = %s, calls = %d", result.Outcome(), len(caller.calls))
+ }
+}
+
+func TestRecruitCommandTree(t *testing.T) {
+ root := newRecruitCommand()
+ for _, path := range [][]string{{"job", "list"}, {"job", "get"}, {"job", "create"}} {
+ cmd, _, err := root.Find(path)
+ if err != nil || cmd == root || cmd.Name() != path[len(path)-1] {
+ t.Fatalf("Find(%v) = %v, %v", path, cmd, err)
+ }
+ }
+}
+
+func TestRecruitResultAndPaginationContractsReachContractFinal(t *testing.T) {
+ root := newRecruitCommand()
+ tests := []struct {
+ path []string
+ canonical string
+ resultField string
+ pagination bool
+ }{
+ {path: []string{"job", "list"}, canonical: "recruit.list_jobs", resultField: "jobs", pagination: true},
+ {path: []string{"job", "get"}, canonical: "recruit.get_job_detail", resultField: "jobId"},
+ {path: []string{"job", "create"}, canonical: "recruit.create_job", resultField: "jobId"},
+ }
+ for _, test := range tests {
+ t.Run(test.canonical, func(t *testing.T) {
+ leaf, _, err := root.Find(test.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ final, ok := contractfinal.RuntimeContractFinal(leaf)
+ if !ok || final.Identity == nil || final.Identity.CanonicalPath != test.canonical {
+ t.Fatalf("final identity = %#v, found = %v", final.Identity, ok)
+ }
+ if final.Result == nil {
+ t.Fatal("final Result is nil")
+ }
+ wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
+ if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) {
+ t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes)
+ }
+ var schema struct {
+ Type string `json:"type"`
+ Properties map[string]json.RawMessage `json:"properties"`
+ }
+ if err := json.Unmarshal(final.Result.DataSchema, &schema); err != nil {
+ t.Fatalf("unmarshal result data_schema: %v", err)
+ }
+ if schema.Type != "object" || len(schema.Properties[test.resultField]) == 0 {
+ t.Fatalf("result data_schema = %s, want object property %q", final.Result.DataSchema, test.resultField)
+ }
+ if test.pagination {
+ if final.Pagination == nil || final.Pagination.Kind != contract.PaginationKindCursor ||
+ final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath {
+ t.Fatalf("pagination = %#v, want cursor contract", final.Pagination)
+ }
+ } else if final.Pagination != nil {
+ t.Fatalf("pagination = %#v, want nil", final.Pagination)
+ }
+ })
+ }
+}
diff --git a/internal/helpers/register_products_test.go b/internal/helpers/register_products_test.go
index 7e89a565..16933d43 100644
--- a/internal/helpers/register_products_test.go
+++ b/internal/helpers/register_products_test.go
@@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t
for _, want := range []string{
"agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
"contact", "devdoc", "ding", "doc", "drive", "live", "mail",
- "markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
+ "markdown", "minutes", "oa", "recruit", "report", "sheet", "todo", "wiki", "whiteboard",
} {
if !seenProducts[want] {
t.Errorf("public product %q was not registered", want)
diff --git a/internal/helpers/register_recruit.go b/internal/helpers/register_recruit.go
new file mode 100644
index 00000000..3ba9ceb2
--- /dev/null
+++ b/internal/helpers/register_recruit.go
@@ -0,0 +1,11 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+
+package helpers
+
+// 招聘是开源库显式维护的公开命令,不依赖生成式产品注册表。
+func init() {
+ RegisterPublic(func() Handler {
+ return wukongHandler{name: "recruit", buildFn: newRecruitCommand}
+ })
+}
diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go
index 01246531..682c4f4f 100644
--- a/internal/helpers/sheet.go
+++ b/internal/helpers/sheet.go
@@ -26,10 +26,11 @@ func newSheetCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "sheet",
Selection: contract.ProductSelectionDecl{
- AgentSummary: "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
+ AgentSummary: "导入本地 Excel,或创建、读取、编辑、导出和审计钉钉在线电子表格(axls),并管理工作表、区域、历史 revision、筛选、图表、图片与格式。",
UseWhen: []string{
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时",
+ "用户要查询工作簿当前 revision 或复核两个 revision 之间的 changeset 时",
},
AvoidWhen: []string{
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
@@ -110,6 +111,8 @@ func newSheetCommand() *cobra.Command {
dws sheet chart create 创建浮动图表
dws sheet chart update 更新浮动图表
dws sheet chart delete 删除浮动图表
+ dws sheet revision-get 获取工作簿当前 revision
+ dws sheet changeset-get 获取工作簿 revision 区间内的 changeset
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
dws sheet import 导入 xlsx/xls 为在线电子表格
@@ -169,6 +172,7 @@ func newSheetCommand() *cobra.Command {
templateCmd := newSheetTemplateCmd()
tableCmds := newTableCmds()
pivotTableCmd := newPivotTableCmd()
+ revisionCmds := newSheetRevisionCmds()
batchUpdateCmd := newBatchUpdateCmd()
DeclareLeafMetadata(batchUpdateCmd, LeafSpec{
@@ -244,6 +248,7 @@ func newSheetCommand() *cobra.Command {
standaloneCmds = append(standaloneCmds, mediaCmds...)
standaloneCmds = append(standaloneCmds, floatImageCmds...)
standaloneCmds = append(standaloneCmds, tableCmds...)
+ standaloneCmds = append(standaloneCmds, revisionCmds...)
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
// Register cross-product aliases
diff --git a/internal/helpers/sheet_comment.go b/internal/helpers/sheet_comment.go
index c0b62612..b16ddf33 100644
--- a/internal/helpers/sheet_comment.go
+++ b/internal/helpers/sheet_comment.go
@@ -1,6 +1,7 @@
package helpers
import (
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -154,6 +155,9 @@ func newSheetCommentCmd() *cobra.Command {
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
}
if v, _ := cmd.Flags().GetBool("emoji"); v {
+ if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil {
+ return err
+ }
toolArgs["emoji"] = true
}
if v, _ := cmd.Flags().GetString("mention"); v != "" {
diff --git a/internal/helpers/sheet_ports_coverage_test.go b/internal/helpers/sheet_ports_coverage_test.go
index c8aa0fc5..7ce43f29 100644
--- a/internal/helpers/sheet_ports_coverage_test.go
+++ b/internal/helpers/sheet_ports_coverage_test.go
@@ -93,7 +93,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
"comment", "reply",
"--node", "node-1",
"--comment-key", "ck-1",
- "--content", "heart",
+ "--content", "比心",
"--emoji",
"--mention", "uid1",
},
@@ -102,7 +102,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) {
toolName: "reply_comment",
args: map[string]any{
"nodeId": "node-1",
- "content": "heart",
+ "content": "比心",
"replyCommentKey": "ck-1",
"emoji": true,
"mentionedUserIds": []string{"uid1"},
diff --git a/internal/helpers/sheet_revision.go b/internal/helpers/sheet_revision.go
new file mode 100644
index 00000000..04f1cb16
--- /dev/null
+++ b/internal/helpers/sheet_revision.go
@@ -0,0 +1,783 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/spf13/cobra"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+)
+
+const (
+ sheetRevisionGetRemoteTool = "get_sheet_revision"
+ sheetChangesetGetRemoteTool = "get_sheet_changeset"
+ sheetChangesetMaxSpan = int64(20)
+ sheetChangesetJSONMaxBytes = 2 * 1024 * 1024
+)
+
+var sheetRevisionResult = &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{
+ "type":"object",
+ "description":"工作簿当前持久化 Delta revision",
+ "properties":{
+ "success":{"type":"boolean","description":"服务端业务调用是否成功"},
+ "logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"},
+ "revision":{"type":"number","description":"当前工作簿 revision;空工作簿为 0"}
+ },
+ "required":["success","logId","revision"],
+ "additionalProperties":true
+ }`),
+}
+
+var sheetChangesetResult = &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{
+ "type":"object",
+ "description":"工作簿 revision 区间内连续、按 revision 升序排列的 V2 前向语义 changeset",
+ "properties":{
+ "success":{"type":"boolean","description":"服务端业务调用是否成功"},
+ "logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"},
+ "schemaVersion":{"type":"number","description":"changeset 业务响应的语义版本;当前固定为 2","enum":[2]},
+ "changeSemantics":{"type":"string","description":"变更详情只描述当时提交的前向效果,不提供统一 old/current 值;当前固定为 FORWARD_ONLY","enum":["FORWARD_ONLY"]},
+ "latestRevision":{"type":"number","description":"请求开始时观测并固定的工作簿最新 revision"},
+ "startRevision":{"type":"number","description":"查询基线 revision;结果不包含该 revision"},
+ "endRevision":{"type":"number","description":"本次实际查询的结束 revision;结果包含该 revision"},
+ "summary":{
+ "type":"object",
+ "description":"本次区间内语义 change 的完整性和影响范围汇总",
+ "properties":{
+ "changeCount":{"type":"number","description":"全部 changesets 中 change 对象的总数;STATE_RESET 不计作普通 change"},
+ "completeChangeCount":{"type":"number","description":"detailsStatus 为 COMPLETE 的 change 数量"},
+ "partialChangeCount":{"type":"number","description":"detailsStatus 为 PARTIAL 的 change 数量"},
+ "unsupportedChangeCount":{"type":"number","description":"type 为 UNSUPPORTED_CHANGE 的 change 数量"},
+ "containsStateReset":{"type":"boolean","description":"区间内是否包含 STATE_RESET 事件"},
+ "containsIncompleteChanges":{"type":"boolean","description":"区间内是否包含 PARTIAL、UNAVAILABLE 或 UNSUPPORTED_CHANGE"},
+ "affectedSheets":{
+ "type":"array",
+ "description":"由 AFFECTED 或 DESTINATION target 去重排序得到的工作表与 A1 范围摘要;SOURCE 不计入",
+ "items":{
+ "type":"object",
+ "properties":{
+ "sheetId":{"type":"string","description":"受影响工作表的稳定 ID"},
+ "sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称;缺失时不得由 sheetId 猜测"},
+ "ranges":{"type":"array","description":"去重排序后的 1-based A1 范围;工作表级变更为空数组","items":{"type":"string","description":"1-based A1 范围;整行或整列可写为 1:3 或 A:C"}}
+ },
+ "required":["sheetId","ranges"],
+ "additionalProperties":true
+ }
+ }
+ },
+ "required":["changeCount","completeChangeCount","partialChangeCount","unsupportedChangeCount","containsStateReset","containsIncompleteChanges","affectedSheets"],
+ "additionalProperties":true
+ },
+ "changesets":{
+ "type":"array",
+ "description":"区间 (startRevision, endRevision] 内按 revision 升序排列的完整事件",
+ "items":{
+ "type":"object",
+ "properties":{
+ "revision":{"type":"number","description":"该 changeset 对应的 Delta revision"},
+ "createTime":{"type":"string","description":"该 revision 的创建时间"},
+ "isSelfEdit":{"type":"boolean","description":"该 Delta 是否由当前请求用户提交;false 不能用于识别具体编辑者"},
+ "eventType":{"type":"string","description":"事件类型;EDIT 为普通前向编辑,UNDO 为撤销提交产生的前向效果,STATE_RESET 为全量状态替换点","enum":["EDIT","UNDO","STATE_RESET"]},
+ "detailsStatus":{"type":"string","description":"该事件内所有 change 的最差详情完整度;COMPLETE、PARTIAL 或 UNAVAILABLE","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]},
+ "reset":{
+ "type":"object",
+ "description":"STATE_RESET 的状态替换信息;EDIT 和 UNDO 不返回该对象",
+ "properties":{
+ "type":{"type":"string","description":"状态替换原因类型","enum":["ROLLBACK","OVERWRITE","UPGRADE","TEMPLATE","PRETTIFY","UNKNOWN_RESET"]},
+ "targetRevision":{"type":"number","description":"targetStatus 为 KNOWN 时的目标 revision;0 是合法空基线"},
+ "targetStatus":{"type":"string","description":"目标 revision 状态;KNOWN 表示已返回,NOT_APPLICABLE 表示该 reset 没有目标,UNAVAILABLE 表示应有目标但无法确认","enum":["KNOWN","NOT_APPLICABLE","UNAVAILABLE"]}
+ },
+ "required":["type","targetStatus"],
+ "additionalProperties":true
+ },
+ "changes":{
+ "type":"array",
+ "description":"该 revision 内按提交顺序排列的前向语义 change;STATE_RESET 固定为空数组",
+ "items":{
+ "type":"object",
+ "properties":{
+ "type":{"type":"string","description":"Agent 可解释的前向变更类型","enum":["ROWS_INSERTED","ROWS_DELETED","ROWS_UPDATED","COLUMNS_INSERTED","COLUMNS_DELETED","COLUMNS_UPDATED","SHEET_CREATED","SHEET_DELETED","SHEET_UPDATED","CUSTOM_TAB_ADDED","CUSTOM_TAB_DELETED","CUSTOM_TAB_UPDATED","CELLS_INSERTED","CELLS_DELETED","RANGE_PASTED","RANGE_AUTOFILLED","RANGE_CLEARED","RANGE_CONTENT_SET","RANGE_BORDER_SET","RANGE_STYLE_SET","RANGE_TAG_SET","RANGE_SORTED","CELLS_CONTENT_SET","CELLS_STYLE_SET","CELLS_TAG_SET","DIMENSION_GROUP_ADDED","DIMENSION_GROUP_REMOVED","DIMENSION_GROUP_UPDATED","DATA_VALIDATION_SET","DATA_VALIDATION_CLEARED","CELLS_MERGED","CELLS_UNMERGED","NAMED_RANGE_SET","NAMED_RANGE_CLEARED","FEATURE_ADDED","FEATURE_DELETED","FEATURE_UPDATED","WORKBOOK_SETTING_UPDATED","EXTERNAL_REFERENCES_REPLACED","UNSUPPORTED_CHANGE"]},
+ "targets":{
+ "type":"array",
+ "description":"该 change 涉及的工作簿、工作表或范围;源和目的范围用 role 区分",
+ "items":{
+ "type":"object",
+ "properties":{
+ "scope":{"type":"string","description":"定位层级","enum":["WORKBOOK","SHEET","RANGE"]},
+ "sheetId":{"type":"string","description":"SHEET 或 RANGE target 的稳定工作表 ID"},
+ "sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称"},
+ "sheetNameSource":{"type":"string","description":"工作表名称来源;AT_CHANGE 为变更时名称,CURRENT_STATE 为当前状态映射,UNKNOWN 为无法确认","enum":["AT_CHANGE","CURRENT_STATE","UNKNOWN"]},
+ "a1Range":{"type":"string","description":"RANGE target 的 1-based A1 范围"},
+ "role":{"type":"string","description":"target 在变更中的角色;省略时等价于 AFFECTED","enum":["AFFECTED","SOURCE","DESTINATION"]}
+ },
+ "required":["scope"],
+ "additionalProperties":true
+ }
+ },
+ "details":{
+ "type":"object",
+ "description":"按 change.type 归一后的前向详情;字段集合见 Sheet Skill,未返回的旧值或当前值不得推断",
+ "properties":{
+ "cell":{
+ "type":"object",
+ "description":"前向单元格内容;整体清除时为 {cleared:true}",
+ "properties":{
+ "cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},
+ "value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},
+ "formula":{"type":"string","description":"本次修改写入的可读公式文本"},
+ "formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},
+ "cellType":{"type":"object","description":"单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true},
+ "link":{"type":"object","description":"工作簿内范围链接;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}
+ },
+ "additionalProperties":true
+ },
+ "cellType":{"type":"object","description":"相对内容变更的单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true},
+ "tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false},
+ "border":{"type":"object","description":"前向边框;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个边框对象被清除","enum":[true]},"color":{"description":"边框颜色;已知属性为 null 表示清除该属性"},"style":{"description":"边框线型;已知属性为 null 表示清除该属性"}},"additionalProperties":true},
+ "style":{"type":"object","description":"前向样式白名单;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true},
+ "properties":{"type":"object","description":"行列、工作表或自定义 Tab 的前向属性;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个属性对象被清除","enum":[true]}},"additionalProperties":true},
+ "changes":{"type":"object","description":"工作簿设置的前向字段;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个设置对象被清除","enum":[true]},"mode":{"type":"string","description":"CALCULATION 的重算模式;default 表示恢复默认模式","enum":["auto","autoNoTable","manual","default"]},"iterate":{"type":["boolean","null"],"description":"CALCULATION 是否启用迭代计算;null 表示清除该设置"},"iterateCount":{"type":["number","null"],"description":"CALCULATION 的最大迭代次数;null 表示清除该设置"},"iterateDelta":{"type":["number","null"],"description":"CALCULATION 的迭代收敛阈值;null 表示清除该设置"},"enableDynamicArray":{"type":["boolean","null"],"description":"CALCULATION 是否启用动态数组;null 表示清除该设置"},"date1904":{"type":["boolean","null"],"description":"CALCULATION 是否使用 1904 日期系统;null 表示清除该设置"},"image":{"type":"object","description":"BACKGROUND 图片设置;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示背景图片设置被清除","enum":[true]},"opacity":{"type":["number","null"],"description":"背景图片不透明度;null 表示清除该属性"}},"additionalProperties":true}},"additionalProperties":true},
+ "fillMode":{"type":"string","description":"RANGE_AUTOFILLED 的填充模式","enum":["copy","series","trend","predict","none"]},
+ "copyStyle":{"type":"boolean","description":"RANGE_AUTOFILLED 是否复制样式;当前协议的 COMPLETE change 必有"},
+ "styleMode":{"type":"string","description":"CELLS_STYLE_SET 的样式作用模式;coverStyle 未传 mode 时服务端显式返回 cell","enum":["sheet","row","col","cell"]},
+ "step":{"type":"object","description":"粘贴或自动填充模式块大小","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"}},"required":["rows","columns"],"additionalProperties":true},
+ "pasteMode":{"type":"string","description":"RANGE_PASTED 的粘贴作用模式","enum":["cell","row","col","sheet"]},
+ "isCut":{"type":"boolean","description":"RANGE_PASTED 是否来自剪切"},
+ "iterateMode":{"type":"string","description":"RANGE_PASTED 如何重复应用内容模式","enum":["step","flex"]},
+ "includedParts":{"type":"array","description":"RANGE_PASTED 实际包含非空数据的变更类别;空切片不计入","items":{"type":"string","description":"实际包含的变更类别","enum":["CONTENT","STYLE","MERGES","CELL_TYPES","CONDITIONAL_FORMATTING","TABLES","PIVOT_TABLES","COMMENTS","REMINDERS","MENTIONS","DATA_VALIDATION","FILTERS","LOCKS","FOLLOWERS","PROTECTION_RANGES","DIMENSION_METADATA","REACTIONS","RANGE_TAGS"]}},
+ "contentPattern":{"type":"object","description":"粘贴内容模式的安全化前向表示","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"},"cells":{"type":"array","description":"相对模式块的单元格内容","items":{"type":"object","properties":{"rowOffset":{"type":"number","description":"相对模式块左上角的 0-based 行偏移"},"columnOffset":{"type":"number","description":"相对模式块左上角的 0-based 列偏移"},"cleared":{"type":"boolean","description":"true 表示整个模式单元格被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"required":["rowOffset","columnOffset"],"additionalProperties":true}}},"required":["cells"],"additionalProperties":true},
+ "clearParts":{"type":"array","description":"RANGE_CLEARED 实际清除的内容类别","items":{"type":"string","description":"被清除的内容类别","enum":["VALUES","FORMULAS","MERGES","STYLES","CELL_TYPES","CONDITIONAL_FORMATTING","DATA_VALIDATION","DATA_VALIDATION_LIST","COLUMN_TYPES","LINKS","TABLES","COMMENTS","REMINDERS","REACTIONS"]}},
+ "preservedCellTypes":{"type":"array","description":"清除 CELL_TYPES 时明确保留的类型;空数组表示不保留 select 或 checkbox","items":{"type":"string","description":"保留的单元格类型","enum":["SELECT","CHECKBOX"]}},
+ "relativeChanges":{"type":"array","description":"相对 target 定位的前向行列属性、内容、样式或 tag 变更;sheet 级项可没有坐标","items":{"type":"object","properties":{"offset":{"type":"number","description":"ROWS_UPDATED 或 COLUMNS_UPDATED 中相对行列区间起点的 0-based 偏移"},"properties":{"type":"object","description":"offset 对应的前向行列属性或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个行列属性对象被清除","enum":[true]},"size":{"type":["number","null"],"description":"行高或列宽;null 表示清除该属性"},"customSize":{"type":["boolean","null"],"description":"是否使用自定义尺寸;null 表示清除该属性"},"hidden":{"type":["boolean","null"],"description":"是否隐藏;null 表示清除该属性"},"sticky":{"type":["boolean","null"],"description":"是否冻结;null 表示清除该属性"},"cellType":{"type":"object","description":"行列默认单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}},"additionalProperties":true},"rowOffset":{"type":"number","description":"0-based 行偏移;行级或单元格级项使用"},"columnOffset":{"type":"number","description":"0-based 列偏移;列级或单元格级项使用"},"cell":{"type":"object","description":"前向单元格内容或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"additionalProperties":true},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]}},"additionalProperties":true},"styleId":{"type":"string","description":"引用同一 change 的 styles[] 条目的 ID"},"styleCleared":{"type":"boolean","description":"true 表示该相对位置的样式被清除","enum":[true]},"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false}},"additionalProperties":true}},
+ "styles":{"type":"array","description":"同一 change 内可由 relativeChanges.styleId 引用的具名样式条目","items":{"type":"object","properties":{"styleId":{"type":"string","description":"样式引用 ID"},"style":{"type":"object","description":"样式白名单对象;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true}},"required":["styleId","style"],"additionalProperties":true}},
+ "dataValidation":{
+ "type":"object",
+ "description":"DATA_VALIDATION_SET 的归一化验证规则;敏感或无法安全解释的内部字段不会返回",
+ "properties":{
+ "type":{"type":"string","description":"数据验证规则类型"},
+ "templateId":{"type":"number","description":"列表验证使用的模板 ID;存在时不代表已返回内联选项或来源区域"},
+ "sourceType":{"type":"string","description":"下拉选项来源;inline 为内联选项,sourceRange 为区域引用","enum":["inline","sourceRange"]},
+ "options":{"type":"array","description":"安全化后的下拉选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}},
+ "sourceRange":{"type":"object","description":"解析成功时的下拉来源区域","properties":{"sheetId":{"type":"string","description":"来源工作表 ID"},"sheetName":{"type":"string","description":"可用时的来源工作表名称"},"a1Notation":{"type":"string","description":"来源区域的 1-based A1 表示"}},"additionalProperties":true},
+ "sourceRangeStatus":{"type":"string","description":"来源区域解析状态","enum":["RESOLVED","UNRESOLVED","INVALID"]},
+ "sourceRangeExpression":{"type":"string","description":"安全来源区域表达式;解析为 RESOLVED 时也可能保留原表达式"},
+ "enableMultiSelect":{"type":"boolean","description":"下拉是否允许多选"},
+ "criteria":{"type":"object","description":"非下拉规则可安全公开的具名条件参数","properties":{"operator":{"type":"string","description":"条件运算符"},"value1":{"type":"object","description":"第一个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"value2":{"type":"object","description":"第二个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"条件公式"}},"additionalProperties":true},
+ "settings":{"type":"object","description":"可安全公开的数据验证通用行为设置;已知属性为 null 表示清除该属性","properties":{"allowBlank":{"type":["boolean","null"],"description":"是否允许空值;null 表示清除该设置"},"errorStyle":{"type":["string","null"],"description":"验证失败时的错误样式;null 表示清除该设置"},"showInputMessage":{"type":["boolean","null"],"description":"是否显示输入提示;null 表示清除该设置"},"showErrorMessage":{"type":["boolean","null"],"description":"是否显示错误提示;null 表示清除该设置"},"prompt":{"type":["string","null"],"description":"输入提示文本;null 表示清除该设置"},"error":{"type":["string","null"],"description":"验证失败提示文本;null 表示清除该设置"},"requiredInRow":{"type":["boolean","null"],"description":"是否要求行内必填;null 表示清除该设置"},"showDropDown":{"type":["boolean","null"],"description":"是否显示下拉控件;null 表示清除该设置"},"columnType":{"type":["boolean","null"],"description":"是否启用列类型行为;null 表示清除该设置"},"promptTitle":{"type":["string","null"],"description":"输入提示标题;null 表示清除该设置"},"errorTitle":{"type":["string","null"],"description":"验证失败提示标题;null 表示清除该设置"},"imeMode":{"type":["string","null"],"description":"输入法模式;null 表示清除该设置"}},"additionalProperties":true}
+ },
+ "required":["type"],
+ "additionalProperties":true
+ }
+ },
+ "additionalProperties":true
+ },
+ "detailsStatus":{"type":"string","description":"details 的完整度;COMPLETE 仍只代表前向提交详情,不代表 old/current 状态完整","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]},
+ "omissions":{
+ "type":"array",
+ "description":"PARTIAL 或 UNAVAILABLE 时的稳定省略原因;COMPLETE 时固定为空数组",
+ "items":{"type":"object","properties":{"code":{"type":"string","description":"稳定的省略原因码","enum":["MISSING_REQUIRED_FIELD","PLUGIN_OPERATION","UNSUPPORTED_WRAPPER","UNSUPPORTED_PROTOCOL_VERSION","UNKNOWN_ACTION","UNKNOWN_VARIANT","DETAILS_NOT_FULLY_INTERPRETED","INVALID_TARGET","VALUE_NOT_AGENT_READABLE","FORMULA_TEXT_UNAVAILABLE","SOURCE_RANGE_UNRESOLVED","SOURCE_RANGE_INVALID","SENSITIVE_DETAILS_OMITTED"]},"fields":{"type":"array","description":"受该原因影响的 details 字段路径","items":{"type":"string","description":"被省略或不完整的字段路径"}}},"required":["code"],"additionalProperties":true}
+ }
+ },
+ "required":["type","targets","details","detailsStatus","omissions"],
+ "additionalProperties":true
+ }
+ }
+ },
+ "required":["revision","createTime","isSelfEdit","eventType","detailsStatus","changes"],
+ "additionalProperties":true
+ }
+ }
+ },
+ "required":["success","logId","schemaVersion","changeSemantics","latestRevision","startRevision","endRevision","summary","changesets"],
+ "additionalProperties":true
+ }`),
+}
+
+type sheetPublishedResultSchemaCache struct {
+ once sync.Once
+ schema map[string]any
+ err error
+}
+
+var (
+ sheetRevisionPublishedSchemaCache sheetPublishedResultSchemaCache
+ sheetChangesetPublishedSchemaCache sheetPublishedResultSchemaCache
+)
+
+func newSheetRevisionCmds() []*cobra.Command {
+ return []*cobra.Command{newSheetRevisionGetCmd(), newSheetChangesetGetCmd()}
+}
+
+func newSheetRevisionGetCmd() *cobra.Command {
+ return NewLeafCommand(LeafSpec{
+ Use: "revision-get",
+ Short: "获取表格工作簿当前 revision",
+ Long: "获取在线电子表格工作簿当前持久化 revision。该能力是工作簿级的,不接收 --sheet-id;--node 可传文档 ID 或完整 URL。服务端业务 JSON 原样放入统一输出的 data。",
+ Example: " dws sheet revision-get --node --format json",
+ OutputRollout: output.RolloutUnifiedActive,
+ Server: "sheet",
+ Tool: sheetRevisionGetRemoteTool,
+ Flags: []LeafFlag{
+ {Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"},
+ },
+ Safety: contract.SafetySpec{
+ Effect: "read", Risk: "low",
+ Confirmation: "not_required", Idempotency: "idempotent",
+ },
+ Contract: LeafContract{
+ Identity: contract.ToolIdentitySpec{
+ ProductID: "sheet",
+ Name: sheetRevisionGetRemoteTool,
+ CanonicalPath: "sheet." + sheetRevisionGetRemoteTool,
+ CLIPath: "sheet revision-get",
+ PrimaryCLIPath: "sheet revision-get",
+ },
+ Description: "获取在线电子表格工作簿当前持久化 revision;结果为后续 changeset 区间查询的 revision 锚点。",
+ DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
+ Result: sheetRevisionResult,
+ Interface: &contract.InterfaceSpec{
+ Mode: contract.InterfaceModeMCP,
+ Availability: contract.InterfaceAvailable,
+ Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetRevisionGetRemoteTool},
+ },
+ Selection: contract.SelectionSpec{
+ AgentSummary: "获取在线电子表格工作簿当前 Delta revision,作为 changeset 查询锚点。",
+ UseWhen: []string{"需要知道当前 revision,或准备按 revision 区间复核工作簿编辑时"},
+ AvoidWhen: []string{"要查看可命名/可回滚的历史快照时用 sheet version list;要读当前单元格值时用 csv-get、table-get 或 range read"},
+ Examples: []string{"dws sheet revision-get --node --format json"},
+ },
+ Parameters: []contract.ParamDecl{
+ {Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"},
+ },
+ },
+ ResultCall: callSheetRevisionResult,
+ })
+}
+
+func newSheetChangesetGetCmd() *cobra.Command {
+ return NewLeafCommand(LeafSpec{
+ Use: "changeset-get",
+ Short: "获取表格工作簿 revision 区间内的 changeset",
+ Long: `获取在线电子表格工作簿在 (startRevision, endRevision] 区间内的连续、Agent 可解释的前向 changeset。
+
+该能力是工作簿级的,不接收 --sheet-id。--end-revision 省略时由服务端在请求开始时固定为 latestRevision;单次区间最多 20 个 revision。统一输出 data.changesets 始终是可直接遍历的 JSON 数组;CLI 只解码服务端传输格式,不改写语义化 change。changeset 只描述当时提交的前向变更,不提供统一 old/current 值;确认最终状态必须另行回读。`,
+ Example: ` dws sheet changeset-get --node --start-revision 120 --end-revision 121 --format json
+ dws sheet changeset-get --node --start-revision 120 --format json`,
+ OutputRollout: output.RolloutUnifiedActive,
+ Server: "sheet",
+ Tool: sheetChangesetGetRemoteTool,
+ Flags: []LeafFlag{
+ {Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"},
+ {
+ Name: "start-revision", Usage: "查询基线 revision (必填,非负;结果不包含该 revision)", Bind: "startRevision",
+ Required: true, Trim: true, Example: "120", Transform: sheetRevisionNumberArg,
+ },
+ {
+ Name: "end-revision", Usage: "查询结束 revision (可选,非负;结果包含该 revision)", Bind: "endRevision",
+ Trim: true, OmitEmpty: true, Example: "121", Transform: sheetRevisionNumberArg,
+ },
+ },
+ Safety: contract.SafetySpec{
+ Effect: "read", Risk: "low",
+ Confirmation: "not_required", Idempotency: "idempotent",
+ },
+ Contract: LeafContract{
+ Identity: contract.ToolIdentitySpec{
+ ProductID: "sheet",
+ Name: sheetChangesetGetRemoteTool,
+ CanonicalPath: "sheet." + sheetChangesetGetRemoteTool,
+ CLIPath: "sheet changeset-get",
+ PrimaryCLIPath: "sheet changeset-get",
+ },
+ Description: "获取在线电子表格工作簿 revision 区间内连续、语义化的前向 changeset;区间语义为 (startRevision, endRevision]。",
+ DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false},
+ Result: sheetChangesetResult,
+ Interface: &contract.InterfaceSpec{
+ Mode: contract.InterfaceModeMCP,
+ Availability: contract.InterfaceAvailable,
+ Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetChangesetGetRemoteTool},
+ },
+ Selection: contract.SelectionSpec{
+ AgentSummary: "读取工作簿两个 revision 之间的语义化前向 changeset,区分 EDIT、UNDO 与 STATE_RESET。",
+ UseWhen: []string{"已知起始 revision,需要复核之后发生了哪些工作簿级编辑或回滚时"},
+ AvoidWhen: []string{"要读取单元格当前最终值时用 csv-get、table-get 或 range read;要查看或回滚命名历史快照时用 sheet version list/revert"},
+ Examples: []string{"dws sheet changeset-get --node --start-revision 120 --end-revision 121 --format json"},
+ },
+ Parameters: []contract.ParamDecl{
+ {Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"},
+ {Name: "start-revision", Property: "startRevision", InterfaceType: "number", Description: "非负查询基线;返回区间不包含该 revision"},
+ {Name: "end-revision", Property: "endRevision", InterfaceType: "number", Description: "可选非负结束 revision;返回区间包含该 revision"},
+ },
+ },
+ Validate: validateSheetChangesetRange,
+ ResultCall: callSheetRevisionResult,
+ })
+}
+
+func callSheetRevisionResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
+ if deps.Caller.DryRun() {
+ return output.Success(map[string]any{
+ "executed": false,
+ "tool": tool,
+ "arguments": args,
+ }, output.WithDryRun()), nil
+ }
+ raw, err := callMCPToolReturnTextOnServer(cmd.Context(), "sheet", tool, args)
+ if err != nil {
+ return nil, err
+ }
+ data, err := decodeSheetRevisionResult(tool, args, raw)
+ if err != nil {
+ return nil, err
+ }
+ return output.Success(data), nil
+}
+
+func decodeSheetRevisionResult(tool string, request map[string]any, raw string) (any, error) {
+ if strings.TrimSpace(raw) == "" {
+ return nil, invalidSheetRevisionResponse(tool,
+ "MCP sheet read tool returned no non-empty text content",
+ "empty_tool_response",
+ true,
+ )
+ }
+ data, err := decodeSheetSingleJSON(raw)
+ if err != nil {
+ return nil, invalidSheetRevisionResponse(tool,
+ fmt.Sprintf("MCP sheet read tool returned invalid JSON: %v", err),
+ "invalid_tool_response",
+ false,
+ )
+ }
+ object, ok := data.(map[string]any)
+ if !ok {
+ return nil, invalidSheetRevisionResponse(tool,
+ "MCP sheet read tool returned a non-object business result",
+ "invalid_tool_response",
+ false,
+ )
+ }
+ if isBusinessError(object) {
+ return nil, &CLIError{
+ Code: CodeMCPToolError,
+ Message: raw,
+ Suggestion: suggestForBusinessError(object),
+ Operation: "sheet/" + tool,
+ }
+ }
+ success, ok := object["success"].(bool)
+ if !ok || !success {
+ return nil, invalidSheetRevisionResponse(tool,
+ "MCP sheet read tool returned a business result without success=true",
+ "invalid_tool_response",
+ false,
+ )
+ }
+ if tool == sheetChangesetGetRemoteTool {
+ if err := normalizeSheetChangesetTransport(object); err != nil {
+ return nil, invalidSheetRevisionResponse(tool,
+ fmt.Sprintf("MCP sheet read tool returned invalid changeset data: %v%s",
+ err, sheetResultLogIDSuffix(object)),
+ "invalid_tool_response",
+ false,
+ )
+ }
+ }
+ if err := validateSheetPublishedResult(tool, object); err != nil {
+ return nil, invalidSheetRevisionResponse(tool,
+ fmt.Sprintf("MCP sheet read tool returned data that does not match its published result contract: %v%s",
+ err, sheetResultLogIDSuffix(object)),
+ "invalid_tool_response",
+ false,
+ )
+ }
+ if tool == sheetChangesetGetRemoteTool {
+ if err := validateSheetChangesetAudit(request, object); err != nil {
+ return nil, invalidSheetRevisionResponse(tool,
+ fmt.Sprintf("MCP sheet read tool returned changeset data that does not match the requested interval or its summary: %v%s",
+ err, sheetResultLogIDSuffix(object)),
+ "invalid_tool_response",
+ false,
+ )
+ }
+ }
+ return object, nil
+}
+
+func invalidSheetRevisionResponse(tool, message, reason string, retryable bool) error {
+ return apperrors.NewAPI(message,
+ apperrors.WithOperation("sheet/"+tool),
+ apperrors.WithOrigin("mcp"),
+ apperrors.WithFailureStage("response_validation"),
+ apperrors.WithRetryable(retryable),
+ apperrors.WithReason(reason),
+ )
+}
+
+func sheetResultLogIDSuffix(object map[string]any) string {
+ logID, ok := object["logId"].(string)
+ if !ok || strings.TrimSpace(logID) == "" {
+ return ""
+ }
+ return fmt.Sprintf(" (logId=%s)", strings.TrimSpace(logID))
+}
+
+func decodeSheetSingleJSON(raw string) (any, error) {
+ decoder := json.NewDecoder(strings.NewReader(raw))
+ decoder.UseNumber()
+ var data any
+ if err := decoder.Decode(&data); err != nil {
+ return nil, err
+ }
+ var trailing any
+ if err := decoder.Decode(&trailing); err != io.EOF {
+ if err == nil {
+ err = fmt.Errorf("包含多个 JSON 值")
+ }
+ return nil, err
+ }
+ return data, nil
+}
+
+func normalizeSheetChangesetTransport(object map[string]any) error {
+ encoded, hasEncoded := object["changesetsJson"]
+ if hasEncoded {
+ changesetsJSON, ok := encoded.(string)
+ if !ok {
+ return fmt.Errorf("changesetsJson 不是字符串")
+ }
+ if strings.TrimSpace(changesetsJSON) == "" {
+ return fmt.Errorf("changesetsJson 为空")
+ }
+ if len(changesetsJSON) > sheetChangesetJSONMaxBytes {
+ return fmt.Errorf("changesetsJson 超过 %d 字节", sheetChangesetJSONMaxBytes)
+ }
+ decoded, err := decodeSheetSingleJSON(changesetsJSON)
+ if err != nil {
+ return fmt.Errorf("changesetsJson 不是完整 JSON: %v", err)
+ }
+ changesets, ok := decoded.([]any)
+ if !ok {
+ return fmt.Errorf("changesetsJson 根节点不是数组")
+ }
+ object["changesets"] = changesets
+ delete(object, "changesetsJson")
+ return nil
+ }
+
+ if legacy, exists := object["changesets"]; exists {
+ if _, ok := legacy.([]any); !ok {
+ return fmt.Errorf("changesets 不是数组")
+ }
+ return nil
+ }
+
+ return fmt.Errorf("成功响应缺少 changesetsJson")
+}
+
+func validateSheetPublishedResult(tool string, object map[string]any) error {
+ var rawSchema json.RawMessage
+ var cache *sheetPublishedResultSchemaCache
+ switch tool {
+ case sheetRevisionGetRemoteTool:
+ rawSchema = sheetRevisionResult.DataSchema
+ cache = &sheetRevisionPublishedSchemaCache
+ case sheetChangesetGetRemoteTool:
+ rawSchema = sheetChangesetResult.DataSchema
+ cache = &sheetChangesetPublishedSchemaCache
+ default:
+ return fmt.Errorf("未知工具 %q", tool)
+ }
+ return validateSheetPublishedResultWithSchema(tool, object, rawSchema, cache)
+}
+
+func validateSheetPublishedResultWithSchema(tool string, object map[string]any,
+ rawSchema json.RawMessage, cache *sheetPublishedResultSchemaCache,
+) error {
+ cache.once.Do(func() { cache.err = json.Unmarshal(rawSchema, &cache.schema) })
+ if cache.err != nil {
+ return fmt.Errorf("读取已发布 Result Schema 失败: %v", cache.err)
+ }
+ schema := cache.schema
+ if err := cli.ValidateJSONSchemaValue(object, schema); err != nil {
+ return err
+ }
+ if strings.TrimSpace(object["logId"].(string)) == "" {
+ return fmt.Errorf("$.logId 不能为空")
+ }
+
+ switch tool {
+ case sheetRevisionGetRemoteTool:
+ if _, err := sheetNonNegativeInteger(object["revision"]); err != nil {
+ return fmt.Errorf("$.revision %v", err)
+ }
+ case sheetChangesetGetRemoteTool:
+ latest, err := sheetNonNegativeInteger(object["latestRevision"])
+ if err != nil {
+ return fmt.Errorf("$.latestRevision %v", err)
+ }
+ start, err := sheetNonNegativeInteger(object["startRevision"])
+ if err != nil {
+ return fmt.Errorf("$.startRevision %v", err)
+ }
+ end, err := sheetNonNegativeInteger(object["endRevision"])
+ if err != nil {
+ return fmt.Errorf("$.endRevision %v", err)
+ }
+ if start > end {
+ return fmt.Errorf("$.startRevision 不能大于 $.endRevision")
+ }
+ if end > latest {
+ return fmt.Errorf("$.endRevision 不能大于 $.latestRevision")
+ }
+ }
+ return nil
+}
+
+type sheetChangesetSummaryAudit struct {
+ changeCount int64
+ completeChangeCount int64
+ partialChangeCount int64
+ unsupportedChangeCount int64
+ containsStateReset bool
+ containsIncompleteChanges bool
+ affectedSheets []sheetChangesetAffectedSheetAudit
+}
+
+type sheetChangesetAffectedSheetAudit struct {
+ sheetID string
+ sheetName string
+ ranges []string
+}
+
+type sheetChangesetAffectedSheetAccumulator struct {
+ sheetName string
+ ranges map[string]struct{}
+}
+
+func validateSheetChangesetAudit(request, object map[string]any) error {
+ requestStart := request["startRevision"].(int64)
+ responseStart, _ := sheetNonNegativeInteger(object["startRevision"])
+ responseEnd, _ := sheetNonNegativeInteger(object["endRevision"])
+ latest, _ := sheetNonNegativeInteger(object["latestRevision"])
+ if responseStart != requestStart {
+ return fmt.Errorf("$.startRevision=%d,与请求值 %d 不一致", responseStart, requestStart)
+ }
+ if requestEnd, exists := request["endRevision"]; exists {
+ if responseEnd != requestEnd.(int64) {
+ return fmt.Errorf("$.endRevision=%d,与请求值 %d 不一致", responseEnd, requestEnd)
+ }
+ } else if responseEnd != latest {
+ return fmt.Errorf("省略 endRevision 时 $.endRevision=%d,必须等于 $.latestRevision=%d", responseEnd, latest)
+ }
+ if responseEnd-responseStart > sheetChangesetMaxSpan {
+ return fmt.Errorf("响应区间超过 %d 个 revision", sheetChangesetMaxSpan)
+ }
+
+ changesets := object["changesets"].([]any)
+ wantCount := responseEnd - responseStart
+ if int64(len(changesets)) != wantCount {
+ return fmt.Errorf("$.changesets 数量为 %d,无法完整覆盖 (%d,%d]", len(changesets), responseStart, responseEnd)
+ }
+ for index, rawChangeset := range changesets {
+ changeset := rawChangeset.(map[string]any)
+ revision, err := sheetNonNegativeInteger(changeset["revision"])
+ if err != nil {
+ return fmt.Errorf("$.changesets[%d].revision %v", index, err)
+ }
+ expected := responseStart + int64(index) + 1
+ if revision != expected {
+ return fmt.Errorf("$.changesets[%d].revision=%d,期望连续 revision %d", index, revision, expected)
+ }
+ }
+
+ actualSummary, err := parseSheetChangesetSummaryAudit(object["summary"].(map[string]any))
+ if err != nil {
+ return err
+ }
+ expectedSummary := summarizeSheetChangesetsForAudit(changesets)
+ if !reflect.DeepEqual(actualSummary, expectedSummary) {
+ return fmt.Errorf("$.summary 与 $.changesets 复算结果不一致")
+ }
+ return nil
+}
+
+func parseSheetChangesetSummaryAudit(raw map[string]any) (sheetChangesetSummaryAudit, error) {
+ fields := []string{"changeCount", "completeChangeCount", "partialChangeCount", "unsupportedChangeCount"}
+ counts := make([]int64, len(fields))
+ for index, field := range fields {
+ value, err := sheetNonNegativeInteger(raw[field])
+ if err != nil {
+ return sheetChangesetSummaryAudit{}, fmt.Errorf("$.summary.%s %v", field, err)
+ }
+ counts[index] = value
+ }
+
+ result := sheetChangesetSummaryAudit{
+ changeCount: counts[0],
+ completeChangeCount: counts[1],
+ partialChangeCount: counts[2],
+ unsupportedChangeCount: counts[3],
+ containsStateReset: raw["containsStateReset"].(bool),
+ containsIncompleteChanges: raw["containsIncompleteChanges"].(bool),
+ }
+ for _, rawSheet := range raw["affectedSheets"].([]any) {
+ sheet := rawSheet.(map[string]any)
+ rawRanges := sheet["ranges"].([]any)
+ affected := sheetChangesetAffectedSheetAudit{
+ sheetID: sheet["sheetId"].(string),
+ ranges: make([]string, 0, len(rawRanges)),
+ }
+ affected.sheetName, _ = sheet["sheetName"].(string)
+ for _, rawRange := range rawRanges {
+ affected.ranges = append(affected.ranges, rawRange.(string))
+ }
+ result.affectedSheets = append(result.affectedSheets, affected)
+ }
+ return result, nil
+}
+
+func summarizeSheetChangesetsForAudit(changesets []any) sheetChangesetSummaryAudit {
+ result := sheetChangesetSummaryAudit{}
+ affected := map[string]*sheetChangesetAffectedSheetAccumulator{}
+ for _, rawChangeset := range changesets {
+ changeset := rawChangeset.(map[string]any)
+ result.containsStateReset = result.containsStateReset || changeset["eventType"] == "STATE_RESET"
+ result.containsIncompleteChanges = result.containsIncompleteChanges || changeset["detailsStatus"] != "COMPLETE"
+ for _, rawChange := range changeset["changes"].([]any) {
+ change := rawChange.(map[string]any)
+ result.changeCount++
+ switch change["detailsStatus"] {
+ case "COMPLETE":
+ result.completeChangeCount++
+ case "PARTIAL":
+ result.partialChangeCount++
+ }
+ if change["type"] == "UNSUPPORTED_CHANGE" {
+ result.unsupportedChangeCount++
+ }
+ for _, rawTarget := range change["targets"].([]any) {
+ target := rawTarget.(map[string]any)
+ if target["role"] == "SOURCE" {
+ continue
+ }
+ sheetID, _ := target["sheetId"].(string)
+ if strings.TrimSpace(sheetID) == "" {
+ continue
+ }
+ accumulator := affected[sheetID]
+ if accumulator == nil {
+ accumulator = &sheetChangesetAffectedSheetAccumulator{ranges: map[string]struct{}{}}
+ affected[sheetID] = accumulator
+ }
+ if sheetName, ok := target["sheetName"].(string); ok && strings.TrimSpace(sheetName) != "" {
+ accumulator.sheetName = sheetName
+ }
+ if a1Range, ok := target["a1Range"].(string); ok && strings.TrimSpace(a1Range) != "" {
+ accumulator.ranges[a1Range] = struct{}{}
+ }
+ }
+ }
+ }
+
+ sheetIDs := make([]string, 0, len(affected))
+ for sheetID := range affected {
+ sheetIDs = append(sheetIDs, sheetID)
+ }
+ sort.Strings(sheetIDs)
+ for _, sheetID := range sheetIDs {
+ accumulator := affected[sheetID]
+ ranges := make([]string, 0, len(accumulator.ranges))
+ for a1Range := range accumulator.ranges {
+ ranges = append(ranges, a1Range)
+ }
+ sort.Strings(ranges)
+ result.affectedSheets = append(result.affectedSheets, sheetChangesetAffectedSheetAudit{
+ sheetID: sheetID, sheetName: accumulator.sheetName, ranges: ranges,
+ })
+ }
+ return result
+}
+
+func sheetNonNegativeInteger(value any) (int64, error) {
+ number, ok := value.(json.Number)
+ if !ok {
+ return 0, fmt.Errorf("必须是非负整数")
+ }
+ parsed, err := strconv.ParseInt(number.String(), 10, 64)
+ if err != nil || parsed < 0 {
+ return 0, fmt.Errorf("必须是非负整数")
+ }
+ return parsed, nil
+}
+
+func sheetRevisionNumberArg(raw string) (any, error) {
+ return strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
+}
+
+func validateSheetChangesetRange(cmd *cobra.Command, _ []string) error {
+ start, err := parseSheetRevisionFlag(cmd, "start-revision")
+ if err != nil {
+ return err
+ }
+ if start < 0 {
+ return apperrors.NewValidation("--start-revision 必须是非负整数")
+ }
+
+ endRaw, _ := cmd.Flags().GetString("end-revision")
+ if strings.TrimSpace(endRaw) == "" {
+ return nil
+ }
+ end, err := parseSheetRevisionFlag(cmd, "end-revision")
+ if err != nil {
+ return err
+ }
+ if end < 0 {
+ return apperrors.NewValidation("--end-revision 必须是非负整数")
+ }
+ if end < start {
+ return apperrors.NewValidation("--end-revision 必须大于或等于 --start-revision")
+ }
+ if end-start > sheetChangesetMaxSpan {
+ return apperrors.NewValidation(fmt.Sprintf(
+ "单次最多查询 %d 个 revision;请把 --end-revision 调整为不大于 %d",
+ sheetChangesetMaxSpan, start+sheetChangesetMaxSpan,
+ ))
+ }
+ return nil
+}
+
+func parseSheetRevisionFlag(cmd *cobra.Command, name string) (int64, error) {
+ raw, _ := cmd.Flags().GetString(name)
+ value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
+ if err != nil {
+ return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 64 位整数", name))
+ }
+ return value, nil
+}
diff --git a/internal/helpers/sheet_revision_test.go b/internal/helpers/sheet_revision_test.go
new file mode 100644
index 00000000..6be2ba14
--- /dev/null
+++ b/internal/helpers/sheet_revision_test.go
@@ -0,0 +1,1092 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cobra"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+type sheetRevisionTestCall struct {
+ server string
+ tool string
+ args map[string]any
+}
+
+type sheetRevisionTestCaller struct {
+ responses map[string]string
+ calls []sheetRevisionTestCall
+ dryRun bool
+}
+
+func (c *sheetRevisionTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
+ cloned := make(map[string]any, len(args))
+ for key, value := range args {
+ cloned[key] = value
+ }
+ c.calls = append(c.calls, sheetRevisionTestCall{server: server, tool: tool, args: cloned})
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.responses[tool]}}}, nil
+}
+
+func (*sheetRevisionTestCaller) Format() string { return "json" }
+func (c *sheetRevisionTestCaller) DryRun() bool { return c.dryRun }
+func (*sheetRevisionTestCaller) Fields() string { return "" }
+func (*sheetRevisionTestCaller) JQ() string { return "" }
+
+func executeSheetRevisionCommand(t *testing.T, caller *sheetRevisionTestCaller, command *cobra.Command, args ...string) (string, error) {
+ t.Helper()
+ previousDeps := deps
+ t.Cleanup(func() { deps = previousDeps })
+
+ InitDeps(caller)
+ var stdout bytes.Buffer
+ deps.Out.w = &stdout
+ deps.Out.errW = io.Discard
+ command = prepareUnifiedTestCommand(command)
+ command.SetOut(&stdout)
+ command.SetErr(io.Discard)
+ command.SilenceErrors = true
+ command.SilenceUsage = true
+ command.SetArgs(args)
+ err := command.Execute()
+ return stdout.String(), err
+}
+
+func assertSheetRevisionJSONEqual(t *testing.T, got, want string) {
+ t.Helper()
+ var envelope map[string]any
+ if err := json.Unmarshal([]byte(got), &envelope); err != nil {
+ t.Fatalf("decode command output: %v\noutput: %s", err, got)
+ }
+ if envelope["ok"] != true || envelope["outcome"] != "success" {
+ t.Fatalf("command envelope = %#v, want success", envelope)
+ }
+ var wantValue any
+ if err := json.Unmarshal([]byte(want), &wantValue); err != nil {
+ t.Fatalf("decode expected JSON: %v", err)
+ }
+ if !reflect.DeepEqual(envelope["data"], wantValue) {
+ t.Fatalf("command data = %#v, want raw MCP business JSON %#v", envelope["data"], wantValue)
+ }
+}
+
+func encodeSheetChangesetTransport(t *testing.T, logical string) string {
+ t.Helper()
+ decoder := json.NewDecoder(strings.NewReader(logical))
+ decoder.UseNumber()
+ var object map[string]any
+ if err := decoder.Decode(&object); err != nil {
+ t.Fatalf("decode logical changeset fixture: %v", err)
+ }
+ return encodeSheetChangesetTransportObject(t, object)
+}
+
+func encodeSheetChangesetTransportObject(t *testing.T, object map[string]any) string {
+ t.Helper()
+ changesets, ok := object["changesets"]
+ if !ok {
+ t.Fatal("logical changeset fixture missing changesets")
+ }
+ encodedChangesets, err := json.Marshal(changesets)
+ if err != nil {
+ t.Fatalf("encode changesets fixture: %v", err)
+ }
+ delete(object, "changesets")
+ object["changesetsJson"] = string(encodedChangesets)
+ encoded, err := json.Marshal(object)
+ if err != nil {
+ t.Fatalf("encode transport fixture: %v", err)
+ }
+ return string(encoded)
+}
+
+func decodeSheetChangesetLogicalObject(t *testing.T, logical string) map[string]any {
+ t.Helper()
+ decoder := json.NewDecoder(strings.NewReader(logical))
+ decoder.UseNumber()
+ var object map[string]any
+ if err := decoder.Decode(&object); err != nil {
+ t.Fatalf("decode logical changeset fixture: %v", err)
+ }
+ return object
+}
+
+const sheetChangesetAuditLogicalResponse = `{
+ "success":true,
+ "logId":"trace-audit",
+ "schemaVersion":2,
+ "changeSemantics":"FORWARD_ONLY",
+ "latestRevision":12,
+ "startRevision":10,
+ "endRevision":12,
+ "summary":{
+ "changeCount":3,
+ "completeChangeCount":1,
+ "partialChangeCount":1,
+ "unsupportedChangeCount":1,
+ "containsStateReset":true,
+ "containsIncompleteChanges":true,
+ "affectedSheets":[
+ {"sheetId":"sheet-a","sheetName":"Alpha","ranges":["C3"]},
+ {"sheetId":"sheet-b","sheetName":"Beta","ranges":["A1","B2"]}
+ ]
+ },
+ "changesets":[
+ {
+ "revision":11,
+ "createTime":"2026-08-21T10:00:00+08:00",
+ "isSelfEdit":true,
+ "eventType":"EDIT",
+ "detailsStatus":"UNAVAILABLE",
+ "changes":[
+ {
+ "type":"RANGE_CONTENT_SET",
+ "targets":[
+ {"scope":"RANGE","sheetId":"sheet-b","sheetName":"Beta","a1Range":"B2","role":"AFFECTED"},
+ {"scope":"RANGE","sheetId":"sheet-b","sheetName":"Beta","a1Range":"A1","role":"DESTINATION"},
+ {"scope":"WORKBOOK","role":"AFFECTED"}
+ ],
+ "details":{},
+ "detailsStatus":"COMPLETE",
+ "omissions":[]
+ },
+ {
+ "type":"RANGE_STYLE_SET",
+ "targets":[{"scope":"RANGE","sheetId":"sheet-a","sheetName":"Alpha","a1Range":"C3","role":"AFFECTED"}],
+ "details":{},
+ "detailsStatus":"PARTIAL",
+ "omissions":[{"code":"DETAILS_NOT_FULLY_INTERPRETED"}]
+ },
+ {
+ "type":"UNSUPPORTED_CHANGE",
+ "targets":[{"scope":"RANGE","sheetId":"sheet-source","a1Range":"D4","role":"SOURCE"}],
+ "details":{},
+ "detailsStatus":"UNAVAILABLE",
+ "omissions":[{"code":"UNKNOWN_ACTION"}]
+ }
+ ]
+ },
+ {
+ "revision":12,
+ "createTime":"2026-08-21T10:01:00+08:00",
+ "isSelfEdit":false,
+ "eventType":"STATE_RESET",
+ "detailsStatus":"COMPLETE",
+ "reset":{"type":"OVERWRITE","targetStatus":"NOT_APPLICABLE"},
+ "changes":[]
+ }
+ ]
+}`
+
+func TestCrossPlatformCoverageSheetRevisionGetPassesNodeURLAndPreservesJSONResult(t *testing.T) {
+ const response = `{"success":true,"logId":"trace-revision","revision":142,"futureField":{"kept":true,"largeInteger":9007199254740993}}`
+ caller := &sheetRevisionTestCaller{responses: map[string]string{sheetRevisionGetRemoteTool: response}}
+ nodeURL := "https://alidocs.dingtalk.com/spreadsheetv2/key/path?sheet=one"
+
+ got, err := executeSheetRevisionCommand(t, caller, newSheetRevisionGetCmd(), "--node", nodeURL)
+ if err != nil {
+ t.Fatalf("revision-get returned error: %v", err)
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("calls = %#v, want one call", caller.calls)
+ }
+ call := caller.calls[0]
+ if call.server != "sheet" || call.tool != sheetRevisionGetRemoteTool {
+ t.Fatalf("dispatch = %s/%s, want sheet/%s", call.server, call.tool, sheetRevisionGetRemoteTool)
+ }
+ if !reflect.DeepEqual(call.args, map[string]any{"nodeId": nodeURL}) {
+ t.Fatalf("args = %#v, want node URL passed through", call.args)
+ }
+ assertSheetRevisionJSONEqual(t, got, response)
+ if !strings.Contains(got, "9007199254740993") {
+ t.Fatalf("large JSON integer lost precision: %s", got)
+ }
+}
+
+func TestCrossPlatformCoverageSheetChangesetGetAcceptsZeroStartAndOmitsOptionalEnd(t *testing.T) {
+ const logicalResponse = `{
+ "success":true,
+ "logId":"trace-changeset",
+ "schemaVersion":2,
+ "changeSemantics":"FORWARD_ONLY",
+ "latestRevision":1,
+ "startRevision":0,
+ "endRevision":1,
+ "summary":{
+ "changeCount":1,
+ "completeChangeCount":1,
+ "partialChangeCount":0,
+ "unsupportedChangeCount":0,
+ "containsStateReset":false,
+ "containsIncompleteChanges":false,
+ "affectedSheets":[{"sheetId":"sheet-id","sheetName":"项目","ranges":["C2"]}]
+ },
+ "changesets":[{
+ "revision":1,
+ "createTime":"2026-08-18T10:00:00+08:00",
+ "isSelfEdit":true,
+ "eventType":"EDIT",
+ "detailsStatus":"COMPLETE",
+ "changes":[{
+ "type":"RANGE_CONTENT_SET",
+ "targets":[{"scope":"RANGE","sheetId":"sheet-id","sheetName":"项目","sheetNameSource":"AT_CHANGE","a1Range":"C2","role":"AFFECTED"}],
+ "details":{"cell":{"value":{"kind":"STRING","stringValue":"完成"}},"futureSafeField":{"x":1}},
+ "detailsStatus":"COMPLETE",
+ "omissions":[]
+ }]
+ }]
+ }`
+ response := encodeSheetChangesetTransport(t, logicalResponse)
+ caller := &sheetRevisionTestCaller{responses: map[string]string{sheetChangesetGetRemoteTool: response}}
+
+ got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(),
+ "--node", "node-1", "--start-revision", "0")
+ if err != nil {
+ t.Fatalf("changeset-get returned error: %v", err)
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("calls = %#v, want one call", caller.calls)
+ }
+ call := caller.calls[0]
+ if call.server != "sheet" || call.tool != sheetChangesetGetRemoteTool {
+ t.Fatalf("dispatch = %s/%s, want sheet/%s", call.server, call.tool, sheetChangesetGetRemoteTool)
+ }
+ if gotStart, exists := call.args["startRevision"]; !exists || gotStart != int64(0) {
+ t.Fatalf("startRevision = %#v (exists=%v), want int64(0)", gotStart, exists)
+ }
+ if _, exists := call.args["endRevision"]; exists {
+ t.Fatalf("optional endRevision unexpectedly sent: %#v", call.args)
+ }
+ if call.args["nodeId"] != "node-1" {
+ t.Fatalf("nodeId = %#v", call.args["nodeId"])
+ }
+ assertSheetRevisionJSONEqual(t, got, logicalResponse)
+ if strings.Contains(got, "changesetsJson") {
+ t.Fatalf("transport field leaked into Agent output: %s", got)
+ }
+}
+
+func TestCrossPlatformCoverageSheetChangesetGetSendsNumericEndRevision(t *testing.T) {
+ caller := &sheetRevisionTestCaller{responses: map[string]string{
+ sheetChangesetGetRemoteTool: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-changeset","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":121,"startRevision":120,"endRevision":121,"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]},"changesets":[{"revision":121,"createTime":"2026-08-21T10:00:00+08:00","isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}`),
+ }}
+ _, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(),
+ "--node", "node-1", "--start-revision", "120", "--end-revision", "121")
+ if err != nil {
+ t.Fatalf("changeset-get returned error: %v", err)
+ }
+ want := map[string]any{"nodeId": "node-1", "startRevision": int64(120), "endRevision": int64(121)}
+ if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) {
+ t.Fatalf("calls = %#v, want args %#v", caller.calls, want)
+ }
+}
+
+func TestCrossPlatformCoverageSheetChangesetGetAcceptsConsistentIntervalAndSummary(t *testing.T) {
+ caller := &sheetRevisionTestCaller{responses: map[string]string{
+ sheetChangesetGetRemoteTool: encodeSheetChangesetTransport(t, sheetChangesetAuditLogicalResponse),
+ }}
+ got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(),
+ "--node", "node-1", "--start-revision", "10", "--end-revision", "12")
+ if err != nil {
+ t.Fatalf("consistent changeset response returned error: %v", err)
+ }
+ assertSheetRevisionJSONEqual(t, got, sheetChangesetAuditLogicalResponse)
+}
+
+func TestCrossPlatformCoverageSheetChangesetGetRejectsIntervalAndSummaryDrift(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ mutate func(map[string]any)
+ want string
+ }{
+ {
+ name: "wrong response start", want: "与请求值 10 不一致",
+ args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"},
+ mutate: func(object map[string]any) { object["startRevision"] = json.Number("9") },
+ },
+ {
+ name: "wrong explicit response end", want: "与请求值 12 不一致",
+ args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"},
+ mutate: func(object map[string]any) { object["endRevision"] = json.Number("11") },
+ },
+ {
+ name: "omitted end is not latest", want: "必须等于 $.latestRevision",
+ args: []string{"--node", "node-1", "--start-revision", "10"},
+ mutate: func(object map[string]any) { object["endRevision"] = json.Number("11") },
+ },
+ {
+ name: "response span too large", want: "响应区间超过 20 个 revision",
+ args: []string{"--node", "node-1", "--start-revision", "10"},
+ mutate: func(object map[string]any) {
+ object["endRevision"] = json.Number("31")
+ object["latestRevision"] = json.Number("31")
+ },
+ },
+ {
+ name: "missing revision", want: "无法完整覆盖 (10,12]",
+ args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"},
+ mutate: func(object map[string]any) {
+ changesets := object["changesets"].([]any)
+ object["changesets"] = changesets[1:]
+ },
+ },
+ {
+ name: "out of order revisions", want: "期望连续 revision 11",
+ args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"},
+ mutate: func(object map[string]any) {
+ changesets := object["changesets"].([]any)
+ object["changesets"] = []any{changesets[1], changesets[0]}
+ },
+ },
+ {
+ name: "fractional changeset revision", want: "必须是非负整数",
+ args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"},
+ mutate: func(object map[string]any) {
+ object["changesets"].([]any)[0].(map[string]any)["revision"] = json.Number("11.5")
+ },
+ },
+ {
+ name: "fractional summary count", want: "$.summary.changeCount 必须是非负整数",
+ args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"},
+ mutate: func(object map[string]any) {
+ object["summary"].(map[string]any)["changeCount"] = json.Number("3.5")
+ },
+ },
+ {
+ name: "forged summary count", want: "复算结果不一致",
+ args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"},
+ mutate: func(object map[string]any) {
+ object["summary"].(map[string]any)["changeCount"] = json.Number("2")
+ },
+ },
+ {
+ name: "forged incomplete flag", want: "复算结果不一致",
+ args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"},
+ mutate: func(object map[string]any) {
+ object["summary"].(map[string]any)["containsIncompleteChanges"] = false
+ },
+ },
+ {
+ name: "forged affected sheets", want: "复算结果不一致",
+ args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"},
+ mutate: func(object map[string]any) {
+ object["summary"].(map[string]any)["affectedSheets"] = []any{}
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ object := decodeSheetChangesetLogicalObject(t, sheetChangesetAuditLogicalResponse)
+ test.mutate(object)
+ caller := &sheetRevisionTestCaller{responses: map[string]string{
+ sheetChangesetGetRemoteTool: encodeSheetChangesetTransportObject(t, object),
+ }}
+ got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), test.args...)
+ if err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("error = %v, want containing %q", err, test.want)
+ }
+ var apiErr *apperrors.Error
+ if !errors.As(err, &apiErr) || apiErr.Reason != "invalid_tool_response" ||
+ apiErr.Operation != "sheet/"+sheetChangesetGetRemoteTool || apiErr.FailureStage != "response_validation" {
+ t.Fatalf("audit drift error = %#v", err)
+ }
+ if strings.TrimSpace(got) != "" {
+ t.Fatalf("audit drift emitted a success envelope: %s", got)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageDecodeSheetChangesetTransportAcceptsLegacyArray(t *testing.T) {
+ const response = `{"success":true,"logId":"trace-legacy","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":9007199254740993,"startRevision":9007199254740992,"endRevision":9007199254740993,"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]},"changesets":[{"revision":9007199254740993,"createTime":"2026-08-20T10:00:00+08:00","isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}`
+ decoded, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, map[string]any{
+ "startRevision": int64(9007199254740992),
+ "endRevision": int64(9007199254740993),
+ }, response)
+ if err != nil {
+ t.Fatalf("decode legacy array: %v", err)
+ }
+ object := decoded.(map[string]any)
+ changesets := object["changesets"].([]any)
+ revision := changesets[0].(map[string]any)["revision"].(json.Number)
+ if revision.String() != "9007199254740993" {
+ t.Fatalf("legacy revision = %s, want exact integer", revision)
+ }
+}
+
+func TestCrossPlatformCoverageDecodeSheetChangesetTransportRejectsInvalidJSONStrings(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ }{
+ {name: "not string", raw: `{"success":true,"changesetsJson":[]}`},
+ {name: "empty", raw: `{"success":true,"changesetsJson":""}`},
+ {name: "not json", raw: `{"success":true,"changesetsJson":"not-json"}`},
+ {name: "object root", raw: `{"success":true,"changesetsJson":"{}"}`},
+ {name: "multiple values", raw: `{"success":true,"changesetsJson":"[] []"}`},
+ {name: "missing", raw: `{"success":true}`},
+ {name: "non object response", raw: `[]`},
+ {name: "legacy changesets not array", raw: `{"success":true,"changesets":{}}`},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ if _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool,
+ map[string]any{"startRevision": int64(0)}, test.raw); err == nil {
+ t.Fatalf("decode %s unexpectedly succeeded", test.raw)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageDecodeSheetRevisionProtocolFailuresHaveMCPResponseMetadata(t *testing.T) {
+ tests := []struct {
+ name string
+ tool string
+ response string
+ wantLogID string
+ }{
+ {
+ name: "malformed outer json", tool: sheetRevisionGetRemoteTool,
+ response: `{"success":true`,
+ },
+ {
+ name: "malformed changesets json", tool: sheetChangesetGetRemoteTool,
+ response: `{"success":true,"logId":"trace-malformed-changeset","changesetsJson":"not-json"}`,
+ wantLogID: "trace-malformed-changeset",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ _, err := decodeSheetRevisionResult(test.tool, map[string]any{"startRevision": int64(0)}, test.response)
+ var apiErr *apperrors.Error
+ if !errors.As(err, &apiErr) || apiErr.Origin != "mcp" ||
+ apiErr.FailureStage != "response_validation" || apiErr.Reason != "invalid_tool_response" ||
+ apiErr.Operation != "sheet/"+test.tool || !apiErr.RetryableSet || apiErr.Retryable {
+ t.Fatalf("protocol failure error = %#v", err)
+ }
+ if test.wantLogID != "" && !strings.Contains(err.Error(), "logId="+test.wantLogID) {
+ t.Fatalf("protocol failure error = %v, want backend logId", err)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageDecodeSheetChangesetTransportRejectsBusinessFailure(t *testing.T) {
+ const response = `{"success":false,"logId":"trace","errorCode":"UPSTREAM","errorMsg":"failed"}`
+ _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool,
+ map[string]any{"startRevision": int64(0)}, response)
+ var cliErr *CLIError
+ if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError ||
+ cliErr.Operation != "sheet/"+sheetChangesetGetRemoteTool {
+ t.Fatalf("decode failure error = %#v, want sheet MCP business failure", err)
+ }
+ if !strings.Contains(cliErr.Message, `"errorCode":"UPSTREAM"`) {
+ t.Fatalf("business failure message = %q, want backend error payload", cliErr.Message)
+ }
+}
+
+func TestCrossPlatformCoverageSheetRevisionCommandsRejectInvalidResponsesWithoutSuccessEnvelope(t *testing.T) {
+ tests := []struct {
+ name string
+ tool string
+ response string
+ build func() *cobra.Command
+ args []string
+ }{
+ {
+ name: "revision empty response", tool: sheetRevisionGetRemoteTool, response: " \n ",
+ build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"},
+ },
+ {
+ name: "changeset empty response", tool: sheetChangesetGetRemoteTool, response: "",
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "revision non-object response", tool: sheetRevisionGetRemoteTool, response: `[]`,
+ build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"},
+ },
+ {
+ name: "changeset missing success", tool: sheetChangesetGetRemoteTool, response: `{"changesetsJson":"[]"}`,
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "revision business failure", tool: sheetRevisionGetRemoteTool,
+ response: `{"success":false,"errorCode":"UPSTREAM","errorMsg":"failed"}`,
+ build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"},
+ },
+ {
+ name: "changeset business failure", tool: sheetChangesetGetRemoteTool,
+ response: `{"success":false,"errorCode":"UPSTREAM","errorMsg":"failed"}`,
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ caller := &sheetRevisionTestCaller{responses: map[string]string{test.tool: test.response}}
+ got, err := executeSheetRevisionCommand(t, caller, test.build(), test.args...)
+ if err == nil {
+ t.Fatalf("command unexpectedly succeeded with output %q", got)
+ }
+ if strings.TrimSpace(got) != "" {
+ t.Fatalf("failure path emitted a success envelope: %s", got)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageSheetRevisionCommandsRejectPublishedResultContractDrift(t *testing.T) {
+ validSummary := `"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]}`
+ validChangesetPrefix := `"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":1,`
+ validChangesets := `"changesets":[]`
+
+ tests := []struct {
+ name string
+ tool string
+ response string
+ build func() *cobra.Command
+ args []string
+ }{
+ {
+ name: "revision missing required field", tool: sheetRevisionGetRemoteTool,
+ response: `{"success":true,"logId":"trace-contract"}`,
+ build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"},
+ },
+ {
+ name: "revision wrong field type", tool: sheetRevisionGetRemoteTool,
+ response: `{"success":true,"logId":"trace-contract","revision":"1"}`,
+ build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"},
+ },
+ {
+ name: "revision empty log id", tool: sheetRevisionGetRemoteTool,
+ response: `{"success":true,"logId":" ","revision":1}`,
+ build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"},
+ },
+ {
+ name: "revision negative integer", tool: sheetRevisionGetRemoteTool,
+ response: `{"success":true,"logId":"trace-contract","revision":-1}`,
+ build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"},
+ },
+ {
+ name: "changeset missing summary", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+validChangesets+`}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "changeset wrong revision type", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":"1","startRevision":0,"endRevision":1,`+validSummary+`,`+validChangesets+`}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "changeset wrong schema version", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":3,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":1,`+validSummary+`,`+validChangesets+`}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "changeset wrong semantics", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"BIDIRECTIONAL","latestRevision":1,"startRevision":0,"endRevision":1,`+validSummary+`,`+validChangesets+`}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "changeset malformed summary", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+`"summary":{"changeCount":0},`+validChangesets+`}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "changeset negative latest revision", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":-1,"startRevision":0,"endRevision":0,`+validSummary+`,`+validChangesets+`}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "changeset negative start revision", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":-1,"endRevision":0,`+validSummary+`,`+validChangesets+`}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "changeset negative end revision", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":-1,`+validSummary+`,`+validChangesets+`}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "changeset start after end", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":2,"startRevision":2,"endRevision":1,`+validSummary+`,`+validChangesets+`}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "changeset end after latest", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":2,`+validSummary+`,`+validChangesets+`}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ {
+ name: "changeset item missing required field", tool: sheetChangesetGetRemoteTool,
+ response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+validSummary+`,"changesets":[{"revision":1,"isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}`),
+ build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"},
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ caller := &sheetRevisionTestCaller{responses: map[string]string{test.tool: test.response}}
+ got, err := executeSheetRevisionCommand(t, caller, test.build(), test.args...)
+ if err == nil {
+ t.Fatalf("command unexpectedly accepted contract drift with output %q", got)
+ }
+ var apiErr *apperrors.Error
+ if !errors.As(err, &apiErr) || apiErr.Reason != "invalid_tool_response" ||
+ apiErr.Operation != "sheet/"+test.tool || apiErr.FailureStage != "response_validation" {
+ t.Fatalf("contract drift error = %#v", err)
+ }
+ if strings.TrimSpace(got) != "" {
+ t.Fatalf("contract drift emitted a success envelope: %s", got)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageValidateSheetPublishedResultCoversDefensiveFailures(t *testing.T) {
+ if err := validateSheetPublishedResult("unknown-tool", map[string]any{}); err == nil {
+ t.Fatal("unknown tool unexpectedly accepted")
+ }
+
+ cache := &sheetPublishedResultSchemaCache{}
+ if err := validateSheetPublishedResultWithSchema(sheetRevisionGetRemoteTool, map[string]any{},
+ json.RawMessage(`{`), cache); err == nil {
+ t.Fatal("invalid published schema unexpectedly accepted")
+ }
+
+ if _, err := sheetNonNegativeInteger(int64(1)); err == nil {
+ t.Fatal("non-json number unexpectedly accepted")
+ }
+}
+
+func TestCrossPlatformCoverageDecodeSheetRevisionEmptyResponseHasStructuredRetryableError(t *testing.T) {
+ _, err := decodeSheetRevisionResult(sheetRevisionGetRemoteTool, nil, " \n ")
+ var apiErr *apperrors.Error
+ if !errors.As(err, &apiErr) || apiErr.Reason != "empty_tool_response" ||
+ apiErr.Operation != "sheet/"+sheetRevisionGetRemoteTool ||
+ apiErr.FailureStage != "response_validation" || !apiErr.RetryableSet || !apiErr.Retryable {
+ t.Fatalf("empty response error = %#v", err)
+ }
+}
+
+func TestCrossPlatformCoverageNormalizeSheetChangesetTransportRejectsOversizedString(t *testing.T) {
+ data := map[string]any{
+ "success": true,
+ "changesetsJson": strings.Repeat("x", sheetChangesetJSONMaxBytes+1),
+ }
+ if err := normalizeSheetChangesetTransport(data); err == nil || !strings.Contains(err.Error(), "超过") {
+ t.Fatalf("oversized changesetsJson error = %v", err)
+ }
+}
+
+func TestCrossPlatformCoverageSheetChangesetGetDryRunPreviewsWithoutCallingMCP(t *testing.T) {
+ caller := &sheetRevisionTestCaller{dryRun: true, responses: map[string]string{}}
+ got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(),
+ "--node", "node-1", "--start-revision", "0", "--dry-run")
+ if err != nil {
+ t.Fatalf("changeset-get dry-run returned error: %v", err)
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("dry-run dispatched MCP calls: %#v", caller.calls)
+ }
+ var envelope map[string]any
+ if err := json.Unmarshal([]byte(got), &envelope); err != nil {
+ t.Fatalf("decode dry-run output: %v\n%s", err, got)
+ }
+ if envelope["dry_run"] != true || envelope["outcome"] != "success" {
+ t.Fatalf("dry-run envelope = %#v", envelope)
+ }
+ data, ok := envelope["data"].(map[string]any)
+ if !ok || data["executed"] != false || data["tool"] != sheetChangesetGetRemoteTool {
+ t.Fatalf("dry-run data = %#v", envelope["data"])
+ }
+ arguments, ok := data["arguments"].(map[string]any)
+ if !ok || arguments["startRevision"] != float64(0) || arguments["nodeId"] != "node-1" {
+ t.Fatalf("dry-run arguments = %#v", data["arguments"])
+ }
+}
+
+func TestCrossPlatformCoverageSheetChangesetGetRejectsInvalidRangesBeforeDispatch(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ want string
+ }{
+ {name: "missing node", args: []string{"--start-revision", "0"}, want: "--node"},
+ {name: "missing start", args: []string{"--node", "node-1"}, want: "--start-revision"},
+ {name: "non integer start", args: []string{"--node", "node-1", "--start-revision", "1.5"}, want: "--start-revision 必须是 64 位整数"},
+ {name: "non integer end", args: []string{"--node", "node-1", "--start-revision", "0", "--end-revision", "1.5"}, want: "--end-revision 必须是 64 位整数"},
+ {name: "negative start", args: []string{"--node", "node-1", "--start-revision", "-1"}, want: "--start-revision 必须是非负整数"},
+ {name: "negative end", args: []string{"--node", "node-1", "--start-revision", "0", "--end-revision", "-1"}, want: "--end-revision 必须是非负整数"},
+ {name: "end before start", args: []string{"--node", "node-1", "--start-revision", "9", "--end-revision", "8"}, want: "必须大于或等于"},
+ {name: "span too large", args: []string{"--node", "node-1", "--start-revision", "9", "--end-revision", "30"}, want: "单次最多查询 20 个 revision"},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ caller := &sheetRevisionTestCaller{responses: map[string]string{}}
+ _, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), test.args...)
+ if err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("error = %v, want containing %q", err, test.want)
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("invalid input dispatched calls: %#v", caller.calls)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageSheetRevisionCommandsPublishHelpAndReviewedContracts(t *testing.T) {
+ root := newSheetCommand()
+ for _, name := range []string{"revision-get", "changeset-get"} {
+ command, _, err := root.Find([]string{name})
+ if err != nil || command == nil || command.Name() != name {
+ t.Fatalf("sheet %s lookup = command %#v, err %v", name, command, err)
+ }
+ if command.Flags().Lookup("sheet-id") != nil {
+ t.Fatalf("sheet %s unexpectedly accepts --sheet-id", name)
+ }
+ if !strings.Contains(command.Long, "工作簿级") {
+ t.Fatalf("sheet %s Long = %q, want workbook-level scope", name, command.Long)
+ }
+
+ final, ok := contractfinal.RuntimeContractFinal(command)
+ if !ok {
+ t.Fatalf("sheet %s missing ContractFinal", name)
+ }
+ if final.Safety == nil || final.Safety.Effect != "read" || final.Safety.Confirmation != "not_required" {
+ t.Fatalf("sheet %s safety = %#v", name, final.Safety)
+ }
+ if final.Interface == nil || final.Interface.Ref == nil || final.Interface.Ref.ProductID != "sheet" {
+ t.Fatalf("sheet %s interface = %#v", name, final.Interface)
+ }
+ if final.Result == nil || len(final.Result.DataSchema) == 0 {
+ t.Fatalf("sheet %s result = %#v", name, final.Result)
+ }
+ if final.DryRun == nil || final.DryRun.PreviewKind != contract.DryRunPreviewRequest {
+ t.Fatalf("sheet %s dry-run = %#v, want request preview", name, final.DryRun)
+ }
+ if output.CommandRollout(command) != output.RolloutUnifiedActive {
+ t.Fatalf("sheet %s rollout = %s, want unified_active so Result is Agent-visible", name, output.CommandRollout(command))
+ }
+ if final.Selection == nil || len(final.Selection.UseWhen) == 0 || len(final.Selection.AvoidWhen) == 0 {
+ t.Fatalf("sheet %s selection = %#v", name, final.Selection)
+ }
+ }
+
+ changeset, _, _ := root.Find([]string{"changeset-get"})
+ if !strings.Contains(changeset.Long, "(startRevision, endRevision]") ||
+ !strings.Contains(changeset.Long, "data.changesets") ||
+ !strings.Contains(changeset.Long, "JSON 数组") ||
+ !strings.Contains(changeset.Long, "只解码服务端传输格式") ||
+ !strings.Contains(changeset.Long, "前向") || !strings.Contains(changeset.Long, "old/current") ||
+ !strings.Contains(changeset.Long, "最终状态必须另行回读") {
+ t.Fatalf("changeset help does not explain interval/decoded output: %q", changeset.Long)
+ }
+ final, _ := contractfinal.RuntimeContractFinal(changeset)
+ if len(final.Parameters) != 3 || final.Parameters[1].InterfaceType != "number" || final.Parameters[2].InterfaceType != "number" {
+ t.Fatalf("changeset parameters = %#v, want numeric revision interface types", final.Parameters)
+ }
+}
+
+func TestCrossPlatformCoverageSheetRevisionResultSchemasUseMCPNumberAndChangesetV2(t *testing.T) {
+ var revisionSchema map[string]any
+ if err := json.Unmarshal(sheetRevisionResult.DataSchema, &revisionSchema); err != nil {
+ t.Fatalf("decode revision Result schema: %v", err)
+ }
+ revisionProperties := mustSheetSchemaMap(t, revisionSchema, "properties")
+ for _, name := range []string{"success", "logId", "revision"} {
+ mustSheetSchemaMap(t, revisionProperties, name)
+ }
+ for _, name := range []string{"success", "logId", "revision"} {
+ assertSheetSchemaRequired(t, revisionSchema, name)
+ }
+ if got := mustSheetSchemaMap(t, revisionProperties, "revision")["type"]; got != "number" {
+ t.Fatalf("revision schema type = %#v, want MCP number", got)
+ }
+
+ var changesetSchema map[string]any
+ if err := json.Unmarshal(sheetChangesetResult.DataSchema, &changesetSchema); err != nil {
+ t.Fatalf("decode changeset Result schema: %v", err)
+ }
+ properties := mustSheetSchemaMap(t, changesetSchema, "properties")
+ for _, name := range []string{"success", "logId", "schemaVersion", "changeSemantics", "latestRevision", "startRevision", "endRevision", "summary", "changesets"} {
+ mustSheetSchemaMap(t, properties, name)
+ }
+ for _, name := range []string{"success", "logId", "schemaVersion", "changeSemantics", "latestRevision", "startRevision", "endRevision", "summary", "changesets"} {
+ assertSheetSchemaRequired(t, changesetSchema, name)
+ }
+ for _, name := range []string{"schemaVersion", "latestRevision", "startRevision", "endRevision"} {
+ if got := mustSheetSchemaMap(t, properties, name)["type"]; got != "number" {
+ t.Fatalf("changeset %s schema type = %#v, want MCP number", name, got)
+ }
+ }
+
+ changesetItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, properties, "changesets"))
+ changesetProperties := mustSheetSchemaMap(t, changesetItem, "properties")
+ for _, name := range []string{"revision", "createTime", "isSelfEdit", "eventType", "detailsStatus", "reset", "changes"} {
+ mustSheetSchemaMap(t, changesetProperties, name)
+ }
+ assertSheetSchemaNotRequired(t, changesetItem, "reset")
+ if got := mustSheetSchemaMap(t, changesetProperties, "reset")["type"]; got != "object" {
+ t.Fatalf("changeset reset schema type = %#v, want optional object", got)
+ }
+ changeItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, changesetProperties, "changes"))
+ changeProperties := mustSheetSchemaMap(t, changeItem, "properties")
+ for _, name := range []string{"type", "targets", "details", "detailsStatus", "omissions"} {
+ mustSheetSchemaMap(t, changeProperties, name)
+ assertSheetSchemaRequired(t, changeItem, name)
+ }
+ assertSheetSchemaEnum(t, mustSheetSchemaMap(t, changeProperties, "type"), 40, "UNSUPPORTED_CHANGE")
+ detailsProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, changeProperties, "details"), "properties")
+ assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "fillMode"), 5, "predict")
+ if got := mustSheetSchemaMap(t, detailsProperties, "copyStyle")["type"]; got != "boolean" {
+ t.Fatalf("copyStyle schema type = %#v, want boolean", got)
+ }
+ assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "styleMode"), 4, "cell")
+ assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "pasteMode"), 4, "sheet")
+ assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "iterateMode"), 2, "flex")
+ if got := mustSheetSchemaMap(t, detailsProperties, "isCut")["type"]; got != "boolean" {
+ t.Fatalf("isCut schema type = %#v, want boolean", got)
+ }
+ assertSheetSchemaEnum(t, mustSheetSchemaMap(t,
+ mustSheetSchemaMap(t, detailsProperties, "includedParts"), "items"), 18, "CONTENT")
+ assertSheetSchemaEnum(t, mustSheetSchemaMap(t,
+ mustSheetSchemaMap(t, detailsProperties, "clearParts"), "items"), 14, "CELL_TYPES")
+ assertSheetSchemaEnum(t, mustSheetSchemaMap(t,
+ mustSheetSchemaMap(t, detailsProperties, "preservedCellTypes"), "items"), 2, "SELECT")
+ for _, name := range []string{"cell", "cellType", "tag", "border", "style", "properties", "changes"} {
+ objectProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, name), "properties")
+ mustSheetSchemaMap(t, objectProperties, "cleared")
+ }
+ cellProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "cell"), "properties")
+ if got := mustSheetSchemaMap(t, cellProperties, "formula")["type"]; got != "string" {
+ t.Fatalf("cell formula schema type = %#v, want string", got)
+ }
+ mustSheetSchemaMap(t, cellProperties, "formulaCleared")
+ assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, cellProperties, "value"), false)
+ cellLink := mustSheetSchemaMap(t, cellProperties, "link")
+ for _, name := range []string{"cleared", "type", "sheetId", "a1Range", "absolute"} {
+ mustSheetSchemaMap(t, mustSheetSchemaMap(t, cellLink, "properties"), name)
+ }
+ if required, exists := cellLink["required"]; exists {
+ t.Fatalf("cell.link schema required = %#v, want clear marker to be valid without range fields", required)
+ }
+ relativeChangeItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, detailsProperties, "relativeChanges"))
+ relativeChangeProperties := mustSheetSchemaMap(t, relativeChangeItem, "properties")
+ if got := mustSheetSchemaMap(t, relativeChangeProperties, "offset")["type"]; got != "number" {
+ t.Fatalf("relativeChanges offset schema type = %#v, want MCP number", got)
+ }
+ relativeDimensionProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeChangeProperties, "properties"), "properties")
+ for _, name := range []string{"cleared", "size", "customSize", "hidden", "sticky", "cellType"} {
+ mustSheetSchemaMap(t, relativeDimensionProperties, name)
+ }
+ for _, name := range []string{"size", "customSize", "hidden", "sticky"} {
+ assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, relativeDimensionProperties, name), "null")
+ }
+ if required, exists := relativeChangeItem["required"]; exists {
+ t.Fatalf("relativeChanges item required = %#v, want refs.sheet item valid without offsets", required)
+ }
+ mustSheetSchemaMap(t, relativeChangeProperties, "styleCleared")
+ mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeChangeProperties, "tag"), "properties")
+ relativeCellProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeChangeProperties, "cell"), "properties")
+ if got := mustSheetSchemaMap(t, relativeCellProperties, "formula")["type"]; got != "string" {
+ t.Fatalf("relative cell formula schema type = %#v, want string", got)
+ }
+ mustSheetSchemaMap(t, relativeCellProperties, "formulaCleared")
+ assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, relativeCellProperties, "value"), false)
+ relativeCellLink := mustSheetSchemaMap(t, relativeCellProperties, "link")
+ for _, name := range []string{"cleared", "type", "sheetId", "a1Range", "absolute"} {
+ mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeCellLink, "properties"), name)
+ }
+ contentPatternProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "contentPattern"), "properties")
+ contentPatternCell := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, contentPatternProperties, "cells"))
+ contentPatternCellProperties := mustSheetSchemaMap(t, contentPatternCell, "properties")
+ mustSheetSchemaMap(t, contentPatternCellProperties, "cleared")
+ if got := mustSheetSchemaMap(t, contentPatternCellProperties, "formula")["type"]; got != "string" {
+ t.Fatalf("content pattern formula schema type = %#v, want string", got)
+ }
+ mustSheetSchemaMap(t, contentPatternCellProperties, "formulaCleared")
+ assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, contentPatternCellProperties, "value"), false)
+ contentPatternLink := mustSheetSchemaMap(t, contentPatternCellProperties, "link")
+ for _, name := range []string{"cleared", "type", "sheetId", "a1Range", "absolute"} {
+ mustSheetSchemaMap(t, mustSheetSchemaMap(t, contentPatternLink, "properties"), name)
+ }
+ if required, exists := contentPatternLink["required"]; exists {
+ t.Fatalf("contentPattern cell link schema required = %#v, want clear marker to be valid", required)
+ }
+ styles := mustSheetSchemaMap(t, detailsProperties, "styles")
+ if got := styles["type"]; got != "array" {
+ t.Fatalf("styles schema type = %#v, want named entry array", got)
+ }
+ styleEntry := mustSheetSchemaArrayItem(t, styles)
+ styleEntryProperties := mustSheetSchemaMap(t, styleEntry, "properties")
+ mustSheetSchemaMap(t, styleEntryProperties, "styleId")
+ mustSheetSchemaMap(t, styleEntryProperties, "style")
+ assertSheetSchemaRequired(t, styleEntry, "styleId")
+ assertSheetSchemaRequired(t, styleEntry, "style")
+ dataValidationProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "dataValidation"), "properties")
+ if got := mustSheetSchemaMap(t, dataValidationProperties, "templateId")["type"]; got != "number" {
+ t.Fatalf("dataValidation templateId schema type = %#v, want MCP number", got)
+ }
+ if got := mustSheetSchemaMap(t, dataValidationProperties, "options")["type"]; got != "array" {
+ t.Fatalf("dataValidation options schema type = %#v, want dropdown array", got)
+ }
+ settingsProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, dataValidationProperties, "settings"), "properties")
+ for _, name := range []string{"allowBlank", "errorStyle", "showInputMessage", "showErrorMessage", "prompt", "error", "requiredInRow", "showDropDown", "columnType", "promptTitle", "errorTitle", "imeMode"} {
+ assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, settingsProperties, name), "null")
+ }
+ criteriaProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, dataValidationProperties, "criteria"), "properties")
+ mustSheetSchemaMap(t, criteriaProperties, "operator")
+ assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, criteriaProperties, "value1"), true)
+ assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, criteriaProperties, "value2"), true)
+ mustSheetSchemaMap(t, criteriaProperties, "formula")
+ settingChangesProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "changes"), "properties")
+ settingMode := mustSheetSchemaMap(t, settingChangesProperties, "mode")
+ assertSheetSchemaEnum(t, settingMode, 4, "default")
+ if got := settingMode["type"]; got != "string" {
+ t.Fatalf("setting mode schema type = %#v, want string", got)
+ }
+ for _, name := range []string{"cleared", "iterate", "iterateCount", "iterateDelta", "enableDynamicArray", "date1904", "image"} {
+ mustSheetSchemaMap(t, settingChangesProperties, name)
+ }
+ for _, name := range []string{"iterate", "iterateCount", "iterateDelta", "enableDynamicArray", "date1904"} {
+ assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, settingChangesProperties, name), "null")
+ }
+ settingImageProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, settingChangesProperties, "image"), "properties")
+ mustSheetSchemaMap(t, settingImageProperties, "cleared")
+ assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, settingImageProperties, "opacity"), "null")
+ omissionItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, changeProperties, "omissions"))
+ omissionCode := mustSheetSchemaMap(t, mustSheetSchemaMap(t, omissionItem, "properties"), "code")
+ assertSheetSchemaEnum(t, omissionCode, 13, "SOURCE_RANGE_UNRESOLVED")
+ targetItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, changeProperties, "targets"))
+ targetProperties := mustSheetSchemaMap(t, targetItem, "properties")
+ for _, name := range []string{"scope", "sheetId", "sheetName", "sheetNameSource", "a1Range", "role"} {
+ mustSheetSchemaMap(t, targetProperties, name)
+ }
+
+ for _, forbidden := range []string{
+ `"actions"`, `"operationKind"`, `"operationVersion"`, `"action"`, `"payload"`,
+ `"understood"`, `"payloadOmitted"`, `"sourceOperationIndex"`, `"pluginVersion"`,
+ `"targetId"`, `"targetType"`, `"changeKind"`, `"sourceDeltaType"`, `"resetType"`,
+ `"resetTarget"`, `"targetAvailable"`, `"warnings"`, `"changesetsJson"`,
+ } {
+ if bytes.Contains(sheetChangesetResult.DataSchema, []byte(forbidden)) {
+ t.Fatalf("changeset Result schema still exposes forbidden V1 key %s", forbidden)
+ }
+ }
+}
+
+func assertSheetTypedValueSchema(t *testing.T, schema map[string]any, allowFormula bool) {
+ t.Helper()
+ if got := schema["type"]; got != "object" {
+ t.Fatalf("typed value schema type = %#v, want object", got)
+ }
+ properties := mustSheetSchemaMap(t, schema, "properties")
+ for _, name := range []string{"kind", "stringValue", "numberValue", "booleanValue"} {
+ mustSheetSchemaMap(t, properties, name)
+ }
+ if allowFormula {
+ mustSheetSchemaMap(t, properties, "formula")
+ } else {
+ mustSheetSchemaMap(t, properties, "values")
+ }
+ assertSheetSchemaRequired(t, schema, "kind")
+}
+
+func mustSheetSchemaMap(t *testing.T, parent map[string]any, key string) map[string]any {
+ t.Helper()
+ value, ok := parent[key].(map[string]any)
+ if !ok {
+ t.Fatalf("schema key %q = %#v, want object", key, parent[key])
+ }
+ return value
+}
+
+func mustSheetSchemaArrayItem(t *testing.T, schema map[string]any) map[string]any {
+ t.Helper()
+ return mustSheetSchemaMap(t, schema, "items")
+}
+
+func assertSheetSchemaRequired(t *testing.T, schema map[string]any, name string) {
+ t.Helper()
+ required, ok := schema["required"].([]any)
+ if !ok {
+ t.Fatalf("schema required = %#v, want array containing %q", schema["required"], name)
+ }
+ for _, value := range required {
+ if value == name {
+ return
+ }
+ }
+ t.Fatalf("schema required = %#v, missing %q", required, name)
+}
+
+func assertSheetSchemaNotRequired(t *testing.T, schema map[string]any, name string) {
+ t.Helper()
+ required, ok := schema["required"].([]any)
+ if !ok {
+ t.Fatalf("schema required = %#v, want array without %q", schema["required"], name)
+ }
+ for _, value := range required {
+ if value == name {
+ t.Fatalf("schema required = %#v, unexpectedly contains optional %q", required, name)
+ }
+ }
+}
+
+func assertSheetSchemaEnum(t *testing.T, schema map[string]any, wantCount int, wantValue string) {
+ t.Helper()
+ values, ok := schema["enum"].([]any)
+ if !ok || len(values) != wantCount {
+ t.Fatalf("schema enum = %#v, want %d values including %q", schema["enum"], wantCount, wantValue)
+ }
+ for _, value := range values {
+ if value == wantValue {
+ return
+ }
+ }
+ t.Fatalf("schema enum = %#v, missing %q", values, wantValue)
+}
+
+func assertSheetSchemaTypeIncludes(t *testing.T, schema map[string]any, want string) {
+ t.Helper()
+ types, ok := schema["type"].([]any)
+ if !ok {
+ t.Fatalf("schema type = %#v, want array containing %q", schema["type"], want)
+ }
+ for _, value := range types {
+ if value == want {
+ return
+ }
+ }
+ t.Fatalf("schema type = %#v, missing %q", types, want)
+}
diff --git a/internal/helpers/sheet_version.go b/internal/helpers/sheet_version.go
index 58e00bfb..a1a8c7c4 100644
--- a/internal/helpers/sheet_version.go
+++ b/internal/helpers/sheet_version.go
@@ -111,9 +111,14 @@ func newSheetVersionCmd() *cobra.Command {
versionListCmd.Flags().String("cursor", "", "分页游标")
versionRevertCmd := &cobra.Command{
- Use: "revert",
- Short: "[危险] 回滚表格到指定版本",
- Example: ` dws sheet version revert --node SHEET_ID --version 3 --yes`,
+ Use: "revert",
+ Short: "[危险] 回滚表格到指定历史版本或 revision",
+ Long: `将在线表格恢复到指定历史版本或精确 revision。
+
+通常应从 version list 选择已保存的历史版本。用户明确要求恢复到某个精确 revision 时,
+也可传入已从同一工作簿真实查询结果确认的 revision,即使它不在版本列表中。未列入
+版本列表的 revision 只有在服务端仍可恢复时才能成功;禁止猜测 revision。`,
+ Example: ` dws sheet version revert --node SHEET_ID --version 3`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -142,22 +147,22 @@ func newSheetVersionCmd() *cobra.Command {
CLIPath: "sheet version revert",
PrimaryCLIPath: "sheet version revert",
},
- Description: "回滚表格到指定历史版本",
+ Description: "回滚表格到指定历史版本或已确认的精确 revision",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
},
Selection: contract.SelectionSpec{
- AgentSummary: "回滚表格到指定历史版本",
- UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格"},
- AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert"},
+ AgentSummary: "回滚表格到指定历史版本或已确认的精确 revision",
+ UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格,或明确要求恢复到同一工作簿中已确认的 revision"},
+ AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert;目标 revision 未经同一工作簿的真实查询结果确认时不要猜测"},
Examples: []string{"dws sheet version revert --node --version 3 --format json"},
},
},
})
versionRevertCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
- versionRevertCmd.Flags().Int("version", 0, "目标版本号 (必填,从 list 获取)")
+ versionRevertCmd.Flags().Int("version", 0, "目标历史版本或已确认 revision (必填,通常从 version list 获取)")
for _, c := range []*cobra.Command{versionSaveCmd, versionListCmd, versionRevertCmd} {
c.Flags().String("url", "", "")
diff --git a/internal/helpers/sheet_version_test.go b/internal/helpers/sheet_version_test.go
new file mode 100644
index 00000000..3288f64b
--- /dev/null
+++ b/internal/helpers/sheet_version_test.go
@@ -0,0 +1,78 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import (
+ "bytes"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestSheetVersionRevertHelpDocumentsConfirmedRevisionTargets(t *testing.T) {
+ command := newSheetVersionCmd()
+ var output bytes.Buffer
+ command.SetOut(&output)
+ command.SetErr(&output)
+ command.SetArgs([]string{"revert", "--help"})
+
+ if err := command.Execute(); err != nil {
+ t.Fatalf("sheet version revert --help returned error: %v", err)
+ }
+ help := output.String()
+ for _, expected := range []string{
+ "通常应从 version list 选择已保存的历史版本",
+ "已从同一工作簿真实查询结果确认的 revision",
+ "禁止猜测 revision",
+ "目标历史版本或已确认 revision",
+ } {
+ if !strings.Contains(help, expected) {
+ t.Fatalf("sheet version revert help missing %q:\n%s", expected, help)
+ }
+ }
+ if strings.Contains(help, "--yes") {
+ t.Fatalf("sheet version revert help must not publish a confirmation-bypass example:\n%s", help)
+ }
+}
+
+func TestSheetVersionRevertRequiresConfirmationAndCallsExactToolWhenConfirmed(t *testing.T) {
+ args := []string{"version", "revert", "--node", "node-1", "--version", "37"}
+
+ caller := &guardedMutationCaller{}
+ err := executeGuardedMutationCommand(t, caller, newSheetCommand, args...)
+ requireTypedConfirmationError(t, err)
+ if len(caller.calls) != 0 {
+ t.Fatalf("expected 0 MCP calls before confirmation, got %d: %+v", len(caller.calls), caller.calls)
+ }
+
+ caller = &guardedMutationCaller{}
+ confirmedArgs := append(append([]string(nil), args...), "--yes")
+ if err := executeGuardedMutationCommand(t, caller, newSheetCommand, confirmedArgs...); err != nil {
+ t.Fatalf("sheet version revert after confirmation returned error: %v", err)
+ }
+ want := guardedMutationCall{
+ productID: "doc",
+ toolName: "revert_doc_version",
+ args: map[string]any{
+ "nodeId": "node-1",
+ "version": 37,
+ },
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("expected exactly 1 MCP call after confirmation, got %d: %+v", len(caller.calls), caller.calls)
+ }
+ if !reflect.DeepEqual(caller.calls[0], want) {
+ t.Fatalf("tool call = %#v, want %#v", caller.calls[0], want)
+ }
+}
diff --git a/internal/helpers/wiki.go b/internal/helpers/wiki.go
index 3c8dc9cf..8d3bac3e 100644
--- a/internal/helpers/wiki.go
+++ b/internal/helpers/wiki.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -557,13 +558,17 @@ func newWikiCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "role"); err != nil {
return err
}
+ role := normalizePermissionRole(mustGetFlag(cmd, "role"))
+ if role == "OWNER" {
+ return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
+ }
userIds, err := collectUserIDs(cmd)
if err != nil {
return err
}
return callMCPTool("add_member", map[string]any{
"workspaceId": workspaceID,
- "roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
+ "roleId": role,
"userIds": userIds,
})
},
diff --git a/internal/helpers/wiki_coverage_more_test.go b/internal/helpers/wiki_coverage_more_test.go
index 9ebd7561..446008f2 100644
--- a/internal/helpers/wiki_coverage_more_test.go
+++ b/internal/helpers/wiki_coverage_more_test.go
@@ -1,11 +1,13 @@
package helpers
import (
+ stderrors "errors"
"io"
"os"
"strings"
"testing"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -106,10 +108,17 @@ func TestCrossPlatformCoverageProxySubCommandCoverage(t *testing.T) {
}
func executeWikiEdge(t *testing.T, args ...string) error {
+ t.Helper()
+ _, err := executeWikiEdgeWithCaller(t, args...)
+ return err
+}
+
+func executeWikiEdgeWithCaller(t *testing.T, args ...string) (*scriptedToolCaller, error) {
t.Helper()
oldDeps := deps
oldArgs := os.Args
- InitDeps(&scriptedToolCaller{})
+ caller := &scriptedToolCaller{}
+ InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
t.Cleanup(func() {
@@ -125,7 +134,7 @@ func executeWikiEdge(t *testing.T, args ...string) error {
root.SetIn(os.Stdin)
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
- return root.Execute()
+ return caller, root.Execute()
}
func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
@@ -154,6 +163,20 @@ func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
}
}
+func TestCrossPlatformCoverageWikiMemberAddRejectsOwner(t *testing.T) {
+ caller, err := executeWikiEdgeWithCaller(t, "member", "add", "--workspace", "space", "--users", "u1", "--role", "OWNER")
+ if err == nil || !strings.Contains(strings.ToUpper(err.Error()), "OWNER") {
+ t.Fatalf("member add OWNER error = %v, want local OWNER rejection", err)
+ }
+ var appErr *apperrors.Error
+ if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
+ t.Fatalf("member add OWNER error = %#v, want validation category", err)
+ }
+ if caller.calls != 0 {
+ t.Fatalf("member add OWNER made %d remote calls, want 0", caller.calls)
+ }
+}
+
func TestCrossPlatformCoverageWikiDeleteCancellationEdges(t *testing.T) {
oldStdin := os.Stdin
t.Cleanup(func() { os.Stdin = oldStdin })
diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json
index 81870ac1..558ffc34 100644
--- a/internal/i18n/locales/en.json
+++ b/internal/i18n/locales/en.json
@@ -80,6 +80,7 @@
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
"已取消操作": "Operation cancelled",
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
+ "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "The current identity's refresh_token is invalid; login state was recovered from the organization mirror token.",
"待办": "todo",
"待办任务 ID (必填)": "Todo task ID (required)",
"待办任务管理": "Todo task management",
diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json
index 06b04ac5..29cc29d2 100644
--- a/internal/i18n/locales/zh.json
+++ b/internal/i18n/locales/zh.json
@@ -80,6 +80,7 @@
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
"已取消操作": "已取消操作",
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
+ "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态",
"待办": "待办",
"待办任务 ID (必填)": "待办任务 ID (必填)",
"待办任务管理": "待办任务管理",
diff --git a/internal/interfacesnapshot/command_migrations.go b/internal/interfacesnapshot/command_migrations.go
new file mode 100644
index 00000000..62205fba
--- /dev/null
+++ b/internal/interfacesnapshot/command_migrations.go
@@ -0,0 +1,639 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+
+package interfacesnapshot
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "reflect"
+ "strconv"
+ "strings"
+)
+
+const CommandMigrationManifestVersion = 1
+
+const (
+ CommandMigrationPending = "pending"
+ CommandMigrationConsumed = "consumed"
+
+ CommandMigrationMove = "command_move"
+ CommandMigrationFlagExtraction = "flag_extraction"
+)
+
+// CommandMigrationManifest governs compatibility-preserving surface moves that
+// cannot be represented as an in-command flag rename. The merge-base owns the
+// authorization; the candidate copy is only a lifecycle receipt.
+type CommandMigrationManifest struct {
+ Version int `json:"version"`
+ Migrations []CommandMigration `json:"migrations"`
+}
+
+type CommandMigration struct {
+ Kind string `json:"kind"`
+ Legacy CommandMigrationSide `json:"legacy"`
+ Replacement CommandMigrationSide `json:"replacement"`
+ LegacyFlag CommandMigrationFlag `json:"legacy_flag"`
+ Schema CommandMigrationSchema `json:"schema"`
+ State string `json:"state"`
+ Reason string `json:"reason"`
+}
+
+type CommandMigrationSide struct {
+ Command string `json:"command"`
+ Before CommandMigrationState `json:"before"`
+ After CommandMigrationState `json:"after"`
+}
+
+type CommandMigrationState struct {
+ Present bool `json:"present"`
+ Runnable bool `json:"runnable,omitempty"`
+ Hidden bool `json:"hidden,omitempty"`
+}
+
+type CommandMigrationFlag struct {
+ Name string `json:"name,omitempty"`
+ Before FlagMigrationState `json:"before"`
+ After FlagMigrationState `json:"after"`
+}
+
+type CommandMigrationSchema struct {
+ ProductID string `json:"product_id"`
+ SourceToolID string `json:"source_tool_id"`
+ ReplacementToolID string `json:"replacement_tool_id"`
+ Parameters []CommandParameterMigration `json:"parameters"`
+}
+
+type CommandParameterMigration struct {
+ From string `json:"from"`
+ To string `json:"to,omitempty"`
+ ReplacementConstant *CommandReplacementConstant `json:"replacement_constant,omitempty"`
+}
+
+type CommandReplacementConstant struct {
+ Property string `json:"property"`
+ Value bool `json:"value"`
+}
+
+func ReadCommandMigrationManifest(r io.Reader) (CommandMigrationManifest, error) {
+ data, err := io.ReadAll(r)
+ if err != nil {
+ return CommandMigrationManifest{}, fmt.Errorf("read command migration manifest: %w", err)
+ }
+ var manifest CommandMigrationManifest
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&manifest); err != nil {
+ return CommandMigrationManifest{}, err
+ }
+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
+ if err == nil {
+ return CommandMigrationManifest{}, fmt.Errorf("command migration manifest contains trailing multiple JSON values")
+ }
+ return CommandMigrationManifest{}, fmt.Errorf("read trailing command migration manifest data: %w", err)
+ }
+ strict := json.NewDecoder(bytes.NewReader(data))
+ strict.UseNumber()
+ if err := validateLabeledMigrationJSONValue(strict, "$", reflect.TypeOf(CommandMigrationManifest{}), "command"); err != nil {
+ return CommandMigrationManifest{}, err
+ }
+ if err := manifest.Validate(); err != nil {
+ return CommandMigrationManifest{}, err
+ }
+ return manifest, nil
+}
+
+func (m CommandMigrationManifest) Validate() error {
+ if m.Version != CommandMigrationManifestVersion {
+ return fmt.Errorf("unsupported command migration manifest version %d (want %d)", m.Version, CommandMigrationManifestVersion)
+ }
+ if m.Migrations == nil {
+ return fmt.Errorf("command migration manifest migrations must be an array")
+ }
+ seen := make(map[string]bool, len(m.Migrations))
+ for index, migration := range m.Migrations {
+ if err := migration.validate(); err != nil {
+ return fmt.Errorf("command migration %d: %w", index, err)
+ }
+ if seen[migration.key()] {
+ return fmt.Errorf("command migration %d duplicates %s", index, migration.displayKey())
+ }
+ seen[migration.key()] = true
+ }
+ return nil
+}
+
+func (m CommandMigration) validate() error {
+ if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction {
+ return fmt.Errorf("invalid kind %q", m.Kind)
+ }
+ if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) {
+ return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws")
+ }
+ if m.Legacy.Command == m.Replacement.Command {
+ return fmt.Errorf("legacy and replacement command paths must differ")
+ }
+ if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) {
+ return fmt.Errorf("migration must include a non-empty trimmed reason")
+ }
+ if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed {
+ return fmt.Errorf("invalid state %q", m.State)
+ }
+ for label, state := range map[string]CommandMigrationState{
+ "legacy before": m.Legacy.Before,
+ "legacy after": m.Legacy.After,
+ "replacement before": m.Replacement.Before,
+ "replacement after": m.Replacement.After,
+ } {
+ if err := state.validate(label); err != nil {
+ return err
+ }
+ }
+ if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden ||
+ !m.Legacy.After.Present || !m.Legacy.After.Runnable {
+ return fmt.Errorf("legacy command must remain runnable and start visible")
+ }
+ if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden {
+ return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable")
+ }
+ if err := m.Schema.validate(m.Kind); err != nil {
+ return err
+ }
+ switch m.Kind {
+ case CommandMigrationMove:
+ if strings.HasPrefix(m.Replacement.Command, m.Legacy.Command+" ") ||
+ strings.HasPrefix(m.Legacy.Command, m.Replacement.Command+" ") {
+ return fmt.Errorf("command_move legacy and replacement command paths must not have an ancestor relationship")
+ }
+ if !m.Legacy.After.Hidden {
+ return fmt.Errorf("command_move legacy command must migrate exactly from visible to hidden")
+ }
+ if m.LegacyFlag != (CommandMigrationFlag{}) {
+ return fmt.Errorf("command_move must not declare legacy_flag")
+ }
+ if m.Schema.SourceToolID != m.Schema.ReplacementToolID {
+ return fmt.Errorf("command_move must retain one stable Schema tool identity")
+ }
+ case CommandMigrationFlagExtraction:
+ if m.Legacy.After.Hidden || m.Legacy.Before != m.Legacy.After {
+ return fmt.Errorf("flag_extraction legacy command must remain visible and unchanged")
+ }
+ if err := m.LegacyFlag.validate(); err != nil {
+ return err
+ }
+ if m.Schema.SourceToolID == m.Schema.ReplacementToolID {
+ return fmt.Errorf("flag_extraction requires a distinct replacement Schema tool")
+ }
+ if m.LegacyFlag.Before.Type != "bool" || m.LegacyFlag.After.Type != "bool" {
+ return fmt.Errorf("flag_extraction legacy_flag must be an optional bool")
+ }
+ var extracted *CommandParameterMigration
+ constantCount := 0
+ for index := range m.Schema.Parameters {
+ parameter := &m.Schema.Parameters[index]
+ if parameter.ReplacementConstant == nil {
+ continue
+ }
+ constantCount++
+ if parameter.From == m.LegacyFlag.Name {
+ extracted = parameter
+ }
+ }
+ if constantCount != 1 {
+ return fmt.Errorf("flag_extraction must declare exactly one replacement constant")
+ }
+ if extracted == nil {
+ return fmt.Errorf("flag_extraction must map the exact extracted flag to replacement_constant")
+ }
+ if !extracted.ReplacementConstant.Value {
+ return fmt.Errorf("flag_extraction v1 replacement_constant must be true")
+ }
+ wantNoOpt := strconv.FormatBool(extracted.ReplacementConstant.Value)
+ if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt {
+ return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt)
+ }
+ }
+ return nil
+}
+
+func (s CommandMigrationState) validate(label string) error {
+ if !s.Present {
+ if s.Runnable || s.Hidden {
+ return fmt.Errorf("%s absent state must not declare command attributes", label)
+ }
+ return nil
+ }
+ if !s.Runnable {
+ return fmt.Errorf("%s present state must be runnable", label)
+ }
+ return nil
+}
+
+func (f CommandMigrationFlag) validate() error {
+ if !isExactFlagName(f.Name) {
+ return fmt.Errorf("legacy_flag name must be an exact flag")
+ }
+ if err := f.Before.validate("legacy_flag before"); err != nil {
+ return err
+ }
+ if err := f.After.validate("legacy_flag after"); err != nil {
+ return err
+ }
+ if !f.Before.Present || !f.After.Present || f.Before.Hidden || !f.After.Hidden {
+ return fmt.Errorf("legacy_flag must migrate exactly from visible to hidden while remaining present")
+ }
+ if f.Before.Required || f.After.Required {
+ return fmt.Errorf("legacy_flag must remain optional before and after extraction")
+ }
+ before := f.Before
+ after := f.After
+ before.Hidden = false
+ after.Hidden = false
+ if before != after {
+ return fmt.Errorf("legacy_flag may change only hidden visibility")
+ }
+ return nil
+}
+
+func (s CommandMigrationSchema) validate(kind string) error {
+ for label, value := range map[string]string{
+ "product_id": s.ProductID,
+ "source_tool_id": s.SourceToolID,
+ "replacement_tool_id": s.ReplacementToolID,
+ } {
+ if !isExactSchemaIdentifier(value) {
+ return fmt.Errorf("schema %s must be an exact identifier", label)
+ }
+ }
+ if s.Parameters == nil {
+ return fmt.Errorf("schema parameters must be an array")
+ }
+ seenFrom := map[string]bool{}
+ seenTo := map[string]bool{}
+ seenConstantProperty := map[string]bool{}
+ for index, parameter := range s.Parameters {
+ if !isExactFlagName(parameter.From) {
+ return fmt.Errorf("schema parameter %d from must be an exact parameter name", index)
+ }
+ if seenFrom[parameter.From] {
+ return fmt.Errorf("schema parameter %d duplicates from %q", index, parameter.From)
+ }
+ seenFrom[parameter.From] = true
+ if kind == CommandMigrationMove {
+ if parameter.ReplacementConstant != nil {
+ return fmt.Errorf("command_move schema parameter %d must not declare replacement_constant", index)
+ }
+ if !isExactFlagName(parameter.To) || parameter.To == parameter.From {
+ return fmt.Errorf("command_move schema parameter %d requires a distinct exact to name", index)
+ }
+ if seenTo[parameter.To] {
+ return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To)
+ }
+ seenTo[parameter.To] = true
+ continue
+ }
+ if kind != CommandMigrationFlagExtraction {
+ return fmt.Errorf("invalid command migration kind %q", kind)
+ }
+
+ hasTo := parameter.To != ""
+ hasConstant := parameter.ReplacementConstant != nil
+ if !hasTo && !hasConstant {
+ return fmt.Errorf("flag_extraction schema parameter %d requires an exact to or replacement_constant", index)
+ }
+ if hasTo && hasConstant {
+ return fmt.Errorf("flag_extraction schema parameter %d must declare exactly one target", index)
+ }
+ if hasTo {
+ if !isExactFlagName(parameter.To) {
+ return fmt.Errorf("flag_extraction schema parameter %d to must be an exact parameter name", index)
+ }
+ if seenTo[parameter.To] {
+ return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To)
+ }
+ if seenConstantProperty[parameter.To] {
+ return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, parameter.To)
+ }
+ seenTo[parameter.To] = true
+ continue
+ }
+ property := parameter.ReplacementConstant.Property
+ if !isExactSchemaIdentifier(property) {
+ return fmt.Errorf("flag_extraction schema parameter %d replacement_constant requires an exact property", index)
+ }
+ if seenConstantProperty[property] {
+ return fmt.Errorf("schema parameter %d duplicates replacement_constant property %q", index, property)
+ }
+ if seenTo[property] {
+ return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, property)
+ }
+ seenConstantProperty[property] = true
+ }
+ return nil
+}
+
+func isExactSchemaIdentifier(value string) bool {
+ return value != "" && value == strings.TrimSpace(value) &&
+ !strings.ContainsAny(value, "*?[]{} /\\\t\r\n")
+}
+
+func (m CommandMigration) key() string {
+ return strings.Join([]string{m.Kind, m.Legacy.Command, m.Replacement.Command, m.Schema.ProductID, m.Schema.SourceToolID, m.Schema.ReplacementToolID}, "\x00")
+}
+
+func (m CommandMigration) displayKey() string {
+ return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command)
+}
+
+type commandMigrationPhase string
+
+const (
+ commandMigrationBefore commandMigrationPhase = "before"
+ commandMigrationAfter commandMigrationPhase = "after"
+ commandMigrationPartial commandMigrationPhase = "partial"
+)
+
+func AuthorizeCommandMigrations(
+ current Snapshot,
+ references map[string]Snapshot,
+ authority CommandMigrationManifest,
+ candidate CommandMigrationManifest,
+) ([]CommandMigration, error) {
+ if err := current.Validate(); err != nil {
+ return nil, fmt.Errorf("validate current interface snapshot: %w", err)
+ }
+ for label, snapshot := range references {
+ if err := snapshot.Validate(); err != nil {
+ return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err)
+ }
+ }
+ if err := authority.Validate(); err != nil {
+ return nil, fmt.Errorf("validate approved command migrations: %w", err)
+ }
+ if err := candidate.Validate(); err != nil {
+ return nil, fmt.Errorf("validate candidate command migrations: %w", err)
+ }
+ type commandMoveTopologyCheck struct {
+ label string
+ snapshot Snapshot
+ manifest CommandMigrationManifest
+ }
+ topologyChecks := []commandMoveTopologyCheck{
+ {label: "approved", snapshot: current, manifest: authority},
+ {label: "candidate", snapshot: current, manifest: candidate},
+ }
+ if mergeBase, _, ok := flagMigrationAuthoritySnapshot(references); ok {
+ topologyChecks = append(topologyChecks,
+ commandMoveTopologyCheck{label: "approved base", snapshot: mergeBase, manifest: authority},
+ commandMoveTopologyCheck{label: "candidate base", snapshot: mergeBase, manifest: candidate},
+ )
+ }
+ for _, check := range topologyChecks {
+ if err := validateCommandMoveLegacyLeaves(check.snapshot, check.manifest); err != nil {
+ return nil, fmt.Errorf("validate %s command migration topology: %w", check.label, err)
+ }
+ }
+ return evaluateCommandMigrationLifecycle(current, references, authority, candidate)
+}
+
+func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error {
+ commands := commandIndex(snapshot)
+ for _, migration := range manifest.Migrations {
+ if migration.Kind != CommandMigrationMove {
+ continue
+ }
+ if _, present := commands[migration.Legacy.Command]; !present {
+ continue
+ }
+ for _, command := range snapshot.Commands {
+ if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") {
+ return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command)
+ }
+ }
+ }
+ return nil
+}
+
+func evaluateCommandMigrationLifecycle(
+ current Snapshot,
+ references map[string]Snapshot,
+ authority CommandMigrationManifest,
+ candidate CommandMigrationManifest,
+) ([]CommandMigration, error) {
+ if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 {
+ return nil, nil
+ }
+ if _, ok := references["stable"]; !ok {
+ return nil, fmt.Errorf("command migration lifecycle requires a stable reference")
+ }
+ mergeBase, label, ok := flagMigrationAuthoritySnapshot(references)
+ if !ok {
+ return nil, fmt.Errorf("command migration lifecycle requires a main or merge-base reference")
+ }
+ authorityByKey := commandMigrationIndex(authority)
+ candidateByKey := commandMigrationIndex(candidate)
+ authorizations := make([]CommandMigration, 0, len(authority.Migrations))
+ for _, approved := range authority.Migrations {
+ basePhase := matchCommandMigrationPhase(mergeBase, approved)
+ wantBase := commandMigrationBefore
+ if approved.State == CommandMigrationConsumed {
+ wantBase = commandMigrationAfter
+ }
+ if basePhase != wantBase {
+ return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State)
+ }
+ proposed, exists := candidateByKey[approved.key()]
+ if exists && !sameCommandMigrationApproval(approved, proposed) {
+ return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey())
+ }
+ currentPhase := matchCommandMigrationPhase(current, approved)
+ switch approved.State {
+ case CommandMigrationPending:
+ if !exists {
+ return nil, fmt.Errorf("candidate removed pending command migration %s", approved.displayKey())
+ }
+ switch currentPhase {
+ case commandMigrationBefore:
+ if proposed.State != CommandMigrationPending {
+ return nil, fmt.Errorf("candidate falsely consumed unchanged command migration %s", approved.displayKey())
+ }
+ case commandMigrationAfter:
+ if proposed.State != CommandMigrationConsumed {
+ return nil, fmt.Errorf("candidate completed command migration %s without marking it consumed", approved.displayKey())
+ }
+ authorizations = append(authorizations, approved)
+ default:
+ return nil, fmt.Errorf("candidate partially applied command migration %s", approved.displayKey())
+ }
+ case CommandMigrationConsumed:
+ if currentPhase != commandMigrationAfter {
+ return nil, fmt.Errorf("candidate drifted from consumed command migration %s", approved.displayKey())
+ }
+ allAfter := true
+ for _, reference := range references {
+ if matchCommandMigrationPhase(reference, approved) != commandMigrationAfter {
+ allAfter = false
+ break
+ }
+ }
+ if allAfter {
+ if exists && proposed.State != CommandMigrationConsumed {
+ return nil, fmt.Errorf("candidate changed consumed command migration %s back to pending", approved.displayKey())
+ }
+ continue
+ }
+ if !exists || proposed.State != CommandMigrationConsumed {
+ return nil, fmt.Errorf("candidate must retain consumed command migration %s until every reference reaches the after state", approved.displayKey())
+ }
+ authorizations = append(authorizations, approved)
+ }
+ }
+ for _, proposed := range candidate.Migrations {
+ if _, exists := authorityByKey[proposed.key()]; exists {
+ continue
+ }
+ if proposed.State != CommandMigrationPending {
+ return nil, fmt.Errorf("candidate-added command migration %s must start pending", proposed.displayKey())
+ }
+ if matchCommandMigrationPhase(mergeBase, proposed) != commandMigrationBefore {
+ return nil, fmt.Errorf("candidate-added command migration %s does not match the merge-base before state", proposed.displayKey())
+ }
+ if matchCommandMigrationPhase(current, proposed) != commandMigrationBefore {
+ return nil, fmt.Errorf("candidate-added command migration %s cannot authorize its own interface change", proposed.displayKey())
+ }
+ }
+ return authorizations, nil
+}
+
+func commandMigrationIndex(manifest CommandMigrationManifest) map[string]CommandMigration {
+ index := make(map[string]CommandMigration, len(manifest.Migrations))
+ for _, migration := range manifest.Migrations {
+ index[migration.key()] = migration
+ }
+ return index
+}
+
+func sameCommandMigrationApproval(left, right CommandMigration) bool {
+ left.State = ""
+ right.State = ""
+ return reflect.DeepEqual(left, right)
+}
+
+func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase {
+ commands := commandIndex(snapshot)
+ legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command)
+ replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command)
+ before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before
+ after := legacy == migration.Legacy.After && replacement == migration.Replacement.After
+ if migration.Kind == CommandMigrationFlagExtraction {
+ command, exists := commands[migration.Legacy.Command]
+ flagState := FlagMigrationState{}
+ if exists {
+ flagState = flagMigrationStateForCommand(command, migration.LegacyFlag.Name)
+ }
+ before = before && flagState == migration.LegacyFlag.Before
+ after = after && flagState == migration.LegacyFlag.After
+ if replacement, replacementExists := commands[migration.Replacement.Command]; replacementExists {
+ after = after && commandMigrationReplacementConstantsMatch(replacement, migration)
+ } else {
+ after = false
+ }
+ }
+ if before {
+ return commandMigrationBefore
+ }
+ if after {
+ return commandMigrationAfter
+ }
+ return commandMigrationPartial
+}
+
+func commandMigrationReplacementConstantsMatch(command Command, migration CommandMigration) bool {
+ expected := make(map[string]bool)
+ for _, parameter := range migration.Schema.Parameters {
+ if parameter.ReplacementConstant == nil {
+ continue
+ }
+ expected[parameter.ReplacementConstant.Property] = parameter.ReplacementConstant.Value
+ }
+ return reflect.DeepEqual(command.BoolConstParams, expected)
+}
+
+func commandMigrationStateForCommand(commands map[string]Command, path string) CommandMigrationState {
+ command, exists := commands[path]
+ if !exists {
+ return CommandMigrationState{}
+ }
+ return CommandMigrationState{Present: true, Runnable: command.Runnable, Hidden: command.Hidden}
+}
+
+// CompareAllWithInterfaceMigrations applies both migration families to one
+// ordinary report, so the two ledgers cannot mask each other's unrelated
+// findings.
+func CompareAllWithInterfaceMigrations(
+ current Snapshot,
+ references map[string]Snapshot,
+ flagAuthority FlagMigrationManifest,
+ flagCandidate FlagMigrationManifest,
+ commandAuthority CommandMigrationManifest,
+ commandCandidate CommandMigrationManifest,
+) (Report, error) {
+ flagAuthorizations, err := AuthorizeFlagMigrations(current, references, flagAuthority, flagCandidate)
+ if err != nil {
+ return Report{}, err
+ }
+ commandAuthorizations, err := AuthorizeCommandMigrations(current, references, commandAuthority, commandCandidate)
+ if err != nil {
+ return Report{}, err
+ }
+ report := CompareAll(current, references)
+ for index := range report.Comparisons {
+ comparison := &report.Comparisons[index]
+ reference := references[comparison.Reference]
+ filtered := comparison.Blocking[:0]
+ for _, change := range comparison.Blocking {
+ if flagMigrationAuthorizesChange(current, reference, change, flagAuthorizations) ||
+ commandMigrationAuthorizesChange(current, reference, change, commandAuthorizations) {
+ continue
+ }
+ filtered = append(filtered, change)
+ }
+ comparison.Blocking = filtered
+ comparison.Compatible = len(filtered) == 0
+ }
+ report.Compatible = true
+ for _, comparison := range report.Comparisons {
+ if !comparison.Compatible {
+ report.Compatible = false
+ break
+ }
+ }
+ return report, nil
+}
+
+func commandMigrationAuthorizesChange(current, reference Snapshot, change Change, authorizations []CommandMigration) bool {
+ canonicalPath := acceptedPathIndex(reference)[change.Path]
+ if canonicalPath == "" {
+ canonicalPath = change.Path
+ }
+ for _, migration := range authorizations {
+ if canonicalPath != migration.Legacy.Command ||
+ matchCommandMigrationPhase(reference, migration) != commandMigrationBefore ||
+ matchCommandMigrationPhase(current, migration) != commandMigrationAfter {
+ continue
+ }
+ switch migration.Kind {
+ case CommandMigrationMove:
+ if change.Kind == "command_became_hidden" && change.Flag == "" {
+ return true
+ }
+ case CommandMigrationFlagExtraction:
+ if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name {
+ return true
+ }
+ }
+ }
+ return false
+}
diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go
new file mode 100644
index 00000000..a7d5965e
--- /dev/null
+++ b/internal/interfacesnapshot/command_migrations_test.go
@@ -0,0 +1,747 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+
+package interfacesnapshot
+
+import (
+ "errors"
+ "os"
+ "strings"
+ "testing"
+)
+
+type commandMigrationErrorReader struct{}
+
+func (commandMigrationErrorReader) Read([]byte) (int, error) {
+ return 0, errors.New("command migration read failure")
+}
+
+func TestApprovedCommandMigrationManifestRemainsValid(t *testing.T) {
+ manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-command-migrations-v1.json")
+ if err != nil {
+ t.Fatalf("open approved command migration manifest: %v", err)
+ }
+ defer manifest.Close()
+ if _, err := ReadCommandMigrationManifest(manifest); err != nil {
+ t.Fatalf("approved command migration manifest is invalid: %v", err)
+ }
+}
+
+func TestCrossPlatformCoverageReadCommandMigrationManifestFailsClosed(t *testing.T) {
+ valid := commandMigrationManifestJSON()
+ if _, err := ReadCommandMigrationManifest(strings.NewReader(valid)); err != nil {
+ t.Fatalf("valid command migration manifest: %v", err)
+ }
+ validExtraction := flagExtractionCommandMigrationManifestJSON()
+ if _, err := ReadCommandMigrationManifest(strings.NewReader(validExtraction)); err != nil {
+ t.Fatalf("valid flag extraction command migration manifest: %v", err)
+ }
+ for _, test := range []struct {
+ input string
+ want string
+ }{
+ {strings.Replace(valid, `"state": "pending"`, `"state": "pending", "allow": true`, 1), "unknown field"},
+ {strings.Replace(valid, `"state": "pending"`, `"state": "pending", "state": "consumed"`, 1), "duplicate field"},
+ {strings.Replace(valid, `"version": 1`, `"version": null`, 1), "must be"},
+ {strings.Replace(valid, "dws chat message old", "dws chat *", 1), "exact command paths"},
+ {strings.Replace(valid, `"kind": "command_move"`, `"kind": "anything"`, 1), "invalid kind"},
+ {`{"version":1,"migrations":null}`, "must be an array"},
+ {strings.Replace(validExtraction, `"replacement_constant":{"property":"convThreadEnabled","value":true}`, `"replacement_constant":null`, 1), "must be an object"},
+ {strings.Replace(validExtraction, `,"value":true`, ``, 1), `must be true`},
+ {strings.Replace(validExtraction, `"value":true`, `"value":"true"`, 1), "bool"},
+ } {
+ if _, err := ReadCommandMigrationManifest(strings.NewReader(test.input)); err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("ReadCommandMigrationManifest() error=%v, want %q", err, test.want)
+ }
+ }
+ if _, err := ReadCommandMigrationManifest(commandMigrationErrorReader{}); err == nil || !strings.Contains(err.Error(), "read failure") {
+ t.Fatalf("reader error=%v", err)
+ }
+ for _, input := range []string{valid + ` {}`, valid + ` {`} {
+ if _, err := ReadCommandMigrationManifest(strings.NewReader(input)); err == nil || !strings.Contains(err.Error(), "trailing") {
+ t.Fatalf("trailing input error=%v", err)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) {
+ validMove := commandMigrationManifest(CommandMigrationPending).Migrations[0]
+ validExtraction := commandMigrationManifest(CommandMigrationPending).Migrations[1]
+
+ duplicate := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{validMove, validMove}}
+ if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") {
+ t.Fatalf("duplicate error=%v", err)
+ }
+ if err := (CommandMigrationManifest{Version: CommandMigrationManifestVersion}).Validate(); err == nil || !strings.Contains(err.Error(), "must be an array") {
+ t.Fatalf("nil migrations error=%v", err)
+ }
+
+ for _, test := range []struct {
+ name string
+ mutate func(*CommandMigration)
+ want string
+ }{
+ {"same command", func(m *CommandMigration) { m.Replacement.Command = m.Legacy.Command }, "must differ"},
+ {"empty reason", func(m *CommandMigration) { m.Reason = " " }, "non-empty trimmed"},
+ {"invalid state", func(m *CommandMigration) { m.State = "approved" }, "invalid state"},
+ {"invalid command state", func(m *CommandMigration) { m.Replacement.Before.Runnable = true }, "absent state"},
+ {"legacy contract", func(m *CommandMigration) { m.Legacy.Before.Hidden = true }, "remain runnable"},
+ {"replacement contract", func(m *CommandMigration) { m.Replacement.After.Hidden = true }, "absent to visible"},
+ {"schema contract", func(m *CommandMigration) { m.Schema.ProductID = "bad/id" }, "exact identifier"},
+ {"move not hidden", func(m *CommandMigration) { m.Legacy.After.Hidden = false }, "must migrate exactly"},
+ {"move flag", func(m *CommandMigration) { m.LegacyFlag.Name = "thread" }, "must not declare legacy_flag"},
+ {"move tool identity", func(m *CommandMigration) { m.Schema.ReplacementToolID = "chat.new" }, "stable Schema tool"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ migration := validMove
+ test.mutate(&migration)
+ if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("validate error=%v, want %q", err, test.want)
+ }
+ })
+ }
+ for _, test := range []struct {
+ name string
+ mutate func(*CommandMigration)
+ want string
+ }{
+ {"legacy changed", func(m *CommandMigration) { m.Legacy.After.Hidden = true }, "remain visible"},
+ {"invalid legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "bad name" }, "exact flag"},
+ {"same tool", func(m *CommandMigration) { m.Schema.ReplacementToolID = m.Schema.SourceToolID }, "distinct replacement"},
+ {"wrong parameter", func(m *CommandMigration) { m.Schema.Parameters[3].From = "other" }, "exact extracted flag"},
+ {"wrong type", func(m *CommandMigration) {
+ m.LegacyFlag.Before.Type = "string"
+ m.LegacyFlag.After.Type = "string"
+ }, "optional bool"},
+ {"wrong no opt", func(m *CommandMigration) {
+ m.LegacyFlag.Before.NoOpt = "false"
+ m.LegacyFlag.After.NoOpt = "false"
+ }, "no_opt"},
+ {"missing extracted mapping", func(m *CommandMigration) {
+ m.Schema.Parameters = m.Schema.Parameters[:3]
+ }, "exactly one replacement constant"},
+ {"duplicate extracted mapping", func(m *CommandMigration) {
+ m.Schema.Parameters = append(m.Schema.Parameters, m.Schema.Parameters[3])
+ }, "duplicates from"},
+ {"multiple constants", func(m *CommandMigration) {
+ m.Schema.Parameters[0] = CommandParameterMigration{
+ From: "name",
+ ReplacementConstant: &CommandReplacementConstant{
+ Property: "name",
+ Value: true,
+ },
+ }
+ }, "exactly one replacement constant"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ migration := cloneCommandMigration(validExtraction)
+ test.mutate(&migration)
+ if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("validate error=%v, want %q", err, test.want)
+ }
+ })
+ }
+ falseConstant := cloneCommandMigration(validExtraction)
+ falseConstant.Schema.Parameters[3].ReplacementConstant.Value = false
+ falseConstant.LegacyFlag.Before.NoOpt = "false"
+ falseConstant.LegacyFlag.After.NoOpt = "false"
+ if err := falseConstant.validate(); err == nil || !strings.Contains(err.Error(), "must be true") {
+ t.Fatalf("false replacement constant validation error=%v, want v1 true-only rejection", err)
+ }
+
+ for _, state := range []CommandMigrationState{
+ {Runnable: true},
+ {Present: true},
+ } {
+ if err := state.validate("state"); err == nil {
+ t.Fatalf("invalid command state accepted: %#v", state)
+ }
+ }
+
+ for _, test := range []struct {
+ name string
+ mutate func(*CommandMigrationFlag)
+ want string
+ }{
+ {"name", func(f *CommandMigrationFlag) { f.Name = "bad name" }, "exact flag"},
+ {"before state", func(f *CommandMigrationFlag) { f.Before = FlagMigrationState{Present: true} }, "requires type"},
+ {"after state", func(f *CommandMigrationFlag) { f.After = FlagMigrationState{Present: true} }, "requires type"},
+ {"visibility", func(f *CommandMigrationFlag) { f.After.Hidden = false }, "visible to hidden"},
+ {"attribute", func(f *CommandMigrationFlag) { f.After.Type = "string" }, "only hidden visibility"},
+ } {
+ t.Run("flag "+test.name, func(t *testing.T) {
+ flag := validExtraction.LegacyFlag
+ test.mutate(&flag)
+ if err := flag.validate(); err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("flag validate error=%v, want %q", err, test.want)
+ }
+ })
+ }
+
+ validSchema := validMove.Schema
+ for _, test := range []struct {
+ name string
+ kind string
+ mutate func(*CommandMigrationSchema)
+ want string
+ }{
+ {"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"},
+ {"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"},
+ {"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"},
+ {"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"},
+ {"bad to", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].To = s.Parameters[0].From }, "distinct exact"},
+ {"duplicate to", CommandMigrationMove, func(s *CommandMigrationSchema) {
+ s.Parameters = append(s.Parameters, CommandParameterMigration{From: "other", To: s.Parameters[0].To})
+ }, "duplicates to"},
+ {"move constant", CommandMigrationMove, func(s *CommandMigrationSchema) {
+ s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "property", Value: true}
+ }, "must not declare replacement_constant"},
+ {"invalid kind", "anything", func(*CommandMigrationSchema) {}, "invalid command migration kind"},
+ } {
+ t.Run("schema "+test.name, func(t *testing.T) {
+ schema := validSchema
+ schema.Parameters = append([]CommandParameterMigration(nil), validSchema.Parameters...)
+ test.mutate(&schema)
+ if err := schema.validate(test.kind); err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("schema validate error=%v, want %q", err, test.want)
+ }
+ })
+ }
+
+ for _, test := range []struct {
+ name string
+ mutate func(*CommandMigrationSchema)
+ want string
+ }{
+ {"missing target", func(s *CommandMigrationSchema) { s.Parameters[0].To = "" }, "requires an exact to or replacement_constant"},
+ {"both targets", func(s *CommandMigrationSchema) {
+ s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "name", Value: true}
+ }, "exactly one target"},
+ {"bad to", func(s *CommandMigrationSchema) { s.Parameters[0].To = "bad name" }, "to must be an exact parameter name"},
+ {"bad constant property", func(s *CommandMigrationSchema) { s.Parameters[3].ReplacementConstant.Property = "bad/property" }, "exact property"},
+ {"duplicate parameter target", func(s *CommandMigrationSchema) { s.Parameters[1].To = s.Parameters[0].To }, "duplicates to"},
+ {"duplicate constant target", func(s *CommandMigrationSchema) {
+ s.Parameters[0] = CommandParameterMigration{From: "name", ReplacementConstant: &CommandReplacementConstant{Property: "convThreadEnabled", Value: true}}
+ }, "duplicates replacement_constant property"},
+ {"constant before parameter target", func(s *CommandMigrationSchema) {
+ s.Parameters[3].ReplacementConstant.Property = "name"
+ s.Parameters[0], s.Parameters[3] = s.Parameters[3], s.Parameters[0]
+ }, "duplicates parameter target"},
+ {"parameter target before constant", func(s *CommandMigrationSchema) {
+ s.Parameters[3].ReplacementConstant.Property = "name"
+ }, "duplicates parameter target"},
+ } {
+ t.Run("extraction schema "+test.name, func(t *testing.T) {
+ schema := cloneCommandMigration(validExtraction).Schema
+ test.mutate(&schema)
+ if err := schema.validate(CommandMigrationFlagExtraction); err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("schema validate error=%v, want %q", err, test.want)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ mutate func(*CommandMigrationFlag)
+ }{
+ {"before required", func(flag *CommandMigrationFlag) { flag.Before.Required = true }},
+ {"after required", func(flag *CommandMigrationFlag) { flag.After.Required = true }},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ migration := commandMigrationManifest(CommandMigrationPending).Migrations[1]
+ test.mutate(&migration.LegacyFlag)
+
+ if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "optional") {
+ t.Fatalf("required legacy flag validation error=%v, want optional-only rejection", err)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageCommandMoveRejectsRunnableParent(t *testing.T) {
+ migration := commandMigrationManifest(CommandMigrationPending).Migrations[0]
+ migration.Legacy.Command = "dws agoal"
+ migration.Replacement.Command = "dws goal"
+ migration.Schema = CommandMigrationSchema{
+ ProductID: "agoal",
+ SourceToolID: "agoal.root",
+ ReplacementToolID: "agoal.root",
+ Parameters: []CommandParameterMigration{},
+ }
+ authority := CommandMigrationManifest{
+ Version: CommandMigrationManifestVersion,
+ Migrations: []CommandMigration{migration},
+ }
+ consumed := authority
+ consumed.Migrations = append([]CommandMigration(nil), authority.Migrations...)
+ consumed.Migrations[0].State = CommandMigrationConsumed
+
+ before := testSnapshot(
+ testCommand("dws"),
+ testCommand("dws agoal"),
+ testCommand("dws agoal strategy"),
+ testCommand("dws agoal strategy list"),
+ )
+ after := testSnapshot(
+ testCommand("dws"),
+ Command{Path: "dws agoal", Runnable: true, Hidden: true},
+ testCommand("dws goal"),
+ )
+
+ _, err := AuthorizeCommandMigrations(
+ after,
+ map[string]Snapshot{"merge-base": before, "stable": before},
+ authority,
+ consumed,
+ )
+ if err == nil || !strings.Contains(err.Error(), "must be a leaf") {
+ t.Fatalf("runnable parent command_move error=%v, want leaf-only rejection", err)
+ }
+}
+
+func TestCrossPlatformCoverageCommandMoveAllowsReplacementParent(t *testing.T) {
+ before := commandMigrationSnapshot(false, false)
+ after := commandMigrationSnapshot(true, false)
+ after.Commands = append(after.Commands, testCommand("dws chat topic new detail"))
+ pending := singleCommandMigrationManifest(CommandMigrationPending)
+ consumed := singleCommandMigrationManifest(CommandMigrationConsumed)
+ if err := validateCommandMoveLegacyLeaves(testSnapshot(testCommand("dws")), pending); err != nil {
+ t.Fatalf("absent legacy topology should remain a lifecycle concern: %v", err)
+ }
+
+ got, err := AuthorizeCommandMigrations(
+ after,
+ map[string]Snapshot{"merge-base": before, "stable": before},
+ pending,
+ consumed,
+ )
+ if err != nil {
+ t.Fatalf("replacement parent command_move was rejected: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("replacement parent command_move authorizations=%d, want 1", len(got))
+ }
+}
+
+func TestCrossPlatformCoverageCommandMoveRejectsAncestorOverlap(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ legacy string
+ replacement string
+ }{
+ {
+ name: "legacy ancestor",
+ legacy: "dws chat message",
+ replacement: "dws chat message list",
+ },
+ {
+ name: "replacement ancestor",
+ legacy: "dws chat message list",
+ replacement: "dws chat message",
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ migration := commandMigrationManifest(CommandMigrationPending).Migrations[0]
+ migration.Legacy.Command = test.legacy
+ migration.Replacement.Command = test.replacement
+
+ if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "must not have an ancestor relationship") {
+ t.Fatalf("overlapping command_move error=%v, want ancestor-overlap rejection", err)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
+ before := commandMigrationSnapshot(false, false)
+ after := commandMigrationSnapshot(true, false)
+ partial := commandMigrationSnapshot(false, false)
+ partial.Commands = append(partial.Commands, testCommand("dws chat topic new"))
+ pending := singleCommandMigrationManifest(CommandMigrationPending)
+ consumed := singleCommandMigrationManifest(CommandMigrationConsumed)
+ empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
+
+ if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{}, empty, empty); err != nil || len(got) != 0 {
+ t.Fatalf("empty lifecycle=%#v, %v", got, err)
+ }
+ for _, test := range []struct {
+ name string
+ current Snapshot
+ references map[string]Snapshot
+ authority CommandMigrationManifest
+ candidate CommandMigrationManifest
+ want string
+ }{
+ {"missing stable", before, map[string]Snapshot{"main": before}, pending, pending, "stable reference"},
+ {"missing base", before, map[string]Snapshot{"stable": before}, pending, pending, "main or merge-base"},
+ {"pending base after", after, map[string]Snapshot{"main": after, "stable": before}, pending, pending, "want exact before"},
+ {"modified approval", before, map[string]Snapshot{"main": before, "stable": before}, pending, modifiedCommandManifest(pending), "modified base-owned"},
+ {"pending removed", before, map[string]Snapshot{"main": before, "stable": before}, pending, empty, "removed pending"},
+ {"false consumed", before, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "falsely consumed"},
+ {"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"},
+ {"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"},
+ {"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"},
+ {"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"},
+ {"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"},
+ {"inert consumed back to pending", after, map[string]Snapshot{"main": after, "stable": after}, consumed, pending, "back to pending"},
+ {"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"},
+ {"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate)
+ if err == nil || !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("lifecycle error=%v, want %q", err, test.want)
+ }
+ })
+ }
+ if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed); err != nil || len(got) != 1 {
+ t.Fatalf("retained consumed receipt=%#v, %v", got, err)
+ }
+ if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 {
+ t.Fatalf("cleaned stale receipt=%#v, %v", got, err)
+ }
+ if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed); err != nil || len(got) != 0 {
+ t.Fatalf("retained inert receipt=%#v, %v", got, err)
+ }
+ if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 {
+ t.Fatalf("candidate pending plan=%#v, %v", got, err)
+ }
+
+ invalidSnapshot := before
+ invalidSnapshot.SchemaVersion = 0
+ for _, test := range []struct {
+ name string
+ current Snapshot
+ references map[string]Snapshot
+ authority CommandMigrationManifest
+ candidate CommandMigrationManifest
+ }{
+ {"current", invalidSnapshot, map[string]Snapshot{"main": before}, empty, empty},
+ {"reference", before, map[string]Snapshot{"main": invalidSnapshot}, empty, empty},
+ {"authority", before, map[string]Snapshot{"main": before}, CommandMigrationManifest{}, empty},
+ {"candidate", before, map[string]Snapshot{"main": before}, empty, CommandMigrationManifest{}},
+ } {
+ t.Run("invalid "+test.name, func(t *testing.T) {
+ if _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate); err == nil {
+ t.Fatal("invalid authorization input accepted")
+ }
+ })
+ }
+ invalidFlags := FlagMigrationManifest{}
+ validFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
+ validCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
+ if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, invalidFlags, validFlags, validCommands, validCommands); err == nil {
+ t.Fatal("combined compare accepted invalid flag lifecycle")
+ }
+ if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, validFlags, validFlags, CommandMigrationManifest{}, validCommands); err == nil {
+ t.Fatal("combined compare accepted invalid command lifecycle")
+ }
+ if commandMigrationAuthorizesChange(before, before, Change{Kind: "command_removed", Path: "dws unrelated"}, pending.Migrations) {
+ t.Fatal("unrelated command change was authorized")
+ }
+}
+
+func TestCrossPlatformCoverageCommandMigrationLifecycleAndExactFiltering(t *testing.T) {
+ before := commandMigrationSnapshot(false, false)
+ after := commandMigrationSnapshot(true, false)
+ pending := commandMigrationManifest(CommandMigrationPending)
+ consumed := commandMigrationManifest(CommandMigrationConsumed)
+ emptyFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
+
+ report, err := CompareAllWithInterfaceMigrations(
+ after,
+ map[string]Snapshot{"merge-base": before, "stable": before},
+ emptyFlags,
+ emptyFlags,
+ pending,
+ consumed,
+ )
+ if err != nil {
+ t.Fatalf("governed command migration: %v", err)
+ }
+ if !report.Compatible {
+ t.Fatalf("exact command migration remained blocking: %#v", report.Comparisons)
+ }
+
+ unrelated := commandMigrationSnapshot(true, true)
+ report, err = CompareAllWithInterfaceMigrations(
+ unrelated,
+ map[string]Snapshot{"merge-base": before, "stable": before},
+ emptyFlags,
+ emptyFlags,
+ pending,
+ consumed,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "flag_removed") {
+ t.Fatalf("unrelated flag removal was hidden: %#v", report.Comparisons)
+ }
+
+ emptyCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
+ if _, err := AuthorizeCommandMigrations(
+ after,
+ map[string]Snapshot{"merge-base": before, "stable": before},
+ emptyCommands,
+ pending,
+ ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
+ t.Fatalf("candidate self-authorization error=%v", err)
+ }
+
+ partial := commandMigrationSnapshot(true, false)
+ partial.Commands = partial.Commands[:len(partial.Commands)-1]
+ if _, err := AuthorizeCommandMigrations(
+ partial,
+ map[string]Snapshot{"merge-base": before, "stable": before},
+ pending,
+ consumed,
+ ); err == nil || !strings.Contains(err.Error(), "partially applied") {
+ t.Fatalf("partial command migration error=%v", err)
+ }
+}
+
+func TestCrossPlatformCoverageFlagExtractionRequiresReplacementConstantEvidence(t *testing.T) {
+ before := commandMigrationSnapshot(false, false)
+ after := commandMigrationSnapshot(true, false)
+ pending := flagExtractionCommandMigrationManifest(CommandMigrationPending)
+ consumed := flagExtractionCommandMigrationManifest(CommandMigrationConsumed)
+ empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}}
+ migration := pending.Migrations[0]
+
+ if phase := matchCommandMigrationPhase(before, migration); phase != commandMigrationBefore {
+ t.Fatalf("before phase=%s, want %s", phase, commandMigrationBefore)
+ }
+ if phase := matchCommandMigrationPhase(after, migration); phase != commandMigrationAfter {
+ t.Fatalf("after phase=%s, want %s", phase, commandMigrationAfter)
+ }
+
+ for _, test := range []struct {
+ name string
+ values map[string]bool
+ }{
+ {name: "missing", values: nil},
+ {name: "wrong property", values: map[string]bool{"otherProperty": true}},
+ {name: "wrong value", values: map[string]bool{"convThreadEnabled": false}},
+ {name: "extra", values: map[string]bool{"convThreadEnabled": true, "otherProperty": true}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ current := withCommandBoolConstParams(after, "dws chat topic create", test.values)
+ if phase := matchCommandMigrationPhase(current, migration); phase != commandMigrationPartial {
+ t.Fatalf("phase=%s, want %s", phase, commandMigrationPartial)
+ }
+ if _, err := AuthorizeCommandMigrations(
+ current,
+ map[string]Snapshot{"merge-base": before, "stable": before},
+ pending,
+ consumed,
+ ); err == nil || !strings.Contains(err.Error(), "partially applied") {
+ t.Fatalf("constant evidence lifecycle error=%v, want partial rejection", err)
+ }
+ if _, err := AuthorizeCommandMigrations(
+ current,
+ map[string]Snapshot{"merge-base": before, "stable": before},
+ empty,
+ pending,
+ ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
+ t.Fatalf("constant evidence self-authorization error=%v", err)
+ }
+ })
+ }
+}
+
+func commandMigrationManifestJSON() string {
+ return `{
+ "version": 1,
+ "migrations": [{
+ "kind": "command_move",
+ "legacy": {
+ "command": "dws chat message old",
+ "before": {"present": true, "runnable": true},
+ "after": {"present": true, "runnable": true, "hidden": true}
+ },
+ "replacement": {
+ "command": "dws chat topic new",
+ "before": {"present": false},
+ "after": {"present": true, "runnable": true}
+ },
+ "schema": {
+ "product_id": "chat",
+ "source_tool_id": "chat.old",
+ "replacement_tool_id": "chat.old",
+ "parameters": [{"from": "old-id", "to": "new-id"}]
+ },
+ "state": "pending",
+ "reason": "Reviewed command move."
+ }]
+}`
+}
+
+func flagExtractionCommandMigrationManifestJSON() string {
+ return `{
+ "version": 1,
+ "migrations": [{
+ "kind": "flag_extraction",
+ "legacy": {
+ "command": "dws chat group create",
+ "before": {"present": true, "runnable": true},
+ "after": {"present": true, "runnable": true}
+ },
+ "replacement": {
+ "command": "dws chat topic create",
+ "before": {"present": false},
+ "after": {"present": true, "runnable": true}
+ },
+ "legacy_flag": {
+ "name": "thread",
+ "before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"},
+ "after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"}
+ },
+ "schema": {
+ "product_id": "chat",
+ "source_tool_id": "chat.create_group",
+ "replacement_tool_id": "chat.create_topic",
+ "parameters": [
+ {"from":"name","to":"name"},
+ {"from":"type","to":"type"},
+ {"from":"users","to":"users"},
+ {"from":"thread","replacement_constant":{"property":"convThreadEnabled","value":true}}
+ ]
+ },
+ "state": "pending",
+ "reason": "Reviewed flag extraction."
+ }]
+}`
+}
+
+func commandMigrationManifest(state string) CommandMigrationManifest {
+ move := CommandMigration{
+ Kind: CommandMigrationMove,
+ Legacy: CommandMigrationSide{
+ Command: "dws chat message old",
+ Before: CommandMigrationState{Present: true, Runnable: true},
+ After: CommandMigrationState{Present: true, Runnable: true, Hidden: true},
+ },
+ Replacement: CommandMigrationSide{
+ Command: "dws chat topic new",
+ Before: CommandMigrationState{},
+ After: CommandMigrationState{Present: true, Runnable: true},
+ },
+ Schema: CommandMigrationSchema{
+ ProductID: "chat",
+ SourceToolID: "chat.old",
+ ReplacementToolID: "chat.old",
+ Parameters: []CommandParameterMigration{{From: "old-id", To: "new-id"}},
+ },
+ State: state,
+ Reason: "Reviewed command move.",
+ }
+ extraction := CommandMigration{
+ Kind: CommandMigrationFlagExtraction,
+ Legacy: CommandMigrationSide{
+ Command: "dws chat group create",
+ Before: CommandMigrationState{Present: true, Runnable: true},
+ After: CommandMigrationState{Present: true, Runnable: true},
+ },
+ Replacement: CommandMigrationSide{
+ Command: "dws chat topic create",
+ Before: CommandMigrationState{},
+ After: CommandMigrationState{Present: true, Runnable: true},
+ },
+ LegacyFlag: CommandMigrationFlag{
+ Name: "thread",
+ Before: FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"},
+ After: FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"},
+ },
+ Schema: CommandMigrationSchema{
+ ProductID: "chat",
+ SourceToolID: "chat.create_group",
+ ReplacementToolID: "chat.create_topic",
+ Parameters: []CommandParameterMigration{
+ {From: "name", To: "name"},
+ {From: "type", To: "type"},
+ {From: "users", To: "users"},
+ {
+ From: "thread",
+ ReplacementConstant: &CommandReplacementConstant{
+ Property: "convThreadEnabled",
+ Value: true,
+ },
+ },
+ },
+ },
+ State: state,
+ Reason: "Reviewed flag extraction.",
+ }
+ return CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{move, extraction}}
+}
+
+func flagExtractionCommandMigrationManifest(state string) CommandMigrationManifest {
+ manifest := commandMigrationManifest(state)
+ manifest.Migrations = manifest.Migrations[1:]
+ return manifest
+}
+
+func cloneCommandMigration(source CommandMigration) CommandMigration {
+ cloned := source
+ cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...)
+ for index, parameter := range source.Schema.Parameters {
+ if parameter.ReplacementConstant == nil {
+ continue
+ }
+ constant := *parameter.ReplacementConstant
+ cloned.Schema.Parameters[index].ReplacementConstant = &constant
+ }
+ return cloned
+}
+
+func singleCommandMigrationManifest(state string) CommandMigrationManifest {
+ manifest := commandMigrationManifest(state)
+ manifest.Migrations = manifest.Migrations[:1]
+ return manifest
+}
+
+func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest {
+ modified := source
+ modified.Migrations = append([]CommandMigration(nil), source.Migrations...)
+ modified.Migrations[0].Reason = "Modified reason."
+ return modified
+}
+
+func commandMigrationSnapshot(after, removeUnrelated bool) Snapshot {
+ oldFlags := []Flag{{Name: "old-id", Type: "string", Required: true}, {Name: "keep", Type: "string"}}
+ groupFlags := []Flag{{Name: "thread", Type: "bool", NoOpt: "true"}}
+ commands := []Command{
+ testCommand("dws"),
+ testCommand("dws chat group create", groupFlags...),
+ testCommand("dws chat message old", oldFlags...),
+ }
+ if after {
+ commands[1].LocalFlags[0].Hidden = true
+ commands[2].Hidden = true
+ if removeUnrelated {
+ commands[2].LocalFlags = commands[2].LocalFlags[:1]
+ }
+ topicCreate := testCommand("dws chat topic create")
+ topicCreate.BoolConstParams = map[string]bool{"convThreadEnabled": true}
+ commands = append(commands,
+ topicCreate,
+ testCommand("dws chat topic new", Flag{Name: "new-id", Type: "string", Required: true}),
+ )
+ }
+ return testSnapshot(commands...)
+}
+
+func withCommandBoolConstParams(snapshot Snapshot, path string, values map[string]bool) Snapshot {
+ cloned := snapshot
+ cloned.Commands = append([]Command(nil), snapshot.Commands...)
+ for index := range cloned.Commands {
+ if cloned.Commands[index].Path == path {
+ cloned.Commands[index].BoolConstParams = values
+ break
+ }
+ }
+ return cloned
+}
diff --git a/internal/interfacesnapshot/compare.go b/internal/interfacesnapshot/compare.go
index e520626d..5de0dd9e 100644
--- a/internal/interfacesnapshot/compare.go
+++ b/internal/interfacesnapshot/compare.go
@@ -71,6 +71,9 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report {
// alias),
// - flags accepted at each command path may not disappear, change type, or
// become required; an existing path may not gain a new required flag,
+// - once bool ConstParams evidence exists, its exact property/value map is a
+// durable contract; adding the first evidence to an older snapshot remains
+// a silent bootstrap,
// - new commands and flags are allowed.
//
// The single exception to the type rule is an individually reviewed migration
@@ -153,6 +156,14 @@ func Compare(current, baseline Snapshot, reference string) Comparison {
}
func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) {
+ if len(oldCommand.BoolConstParams) > 0 && !reflect.DeepEqual(oldCommand.BoolConstParams, newCommand.BoolConstParams) {
+ result.Blocking = append(result.Blocking, Change{
+ Kind: "bool_const_params_changed",
+ Path: acceptedPath,
+ Before: fmt.Sprintf("%v", oldCommand.BoolConstParams),
+ After: fmt.Sprintf("%v", newCommand.BoolConstParams),
+ })
+ }
if oldCommand.Runnable && !newCommand.Runnable {
result.Blocking = append(result.Blocking, Change{
Kind: "command_became_non_runnable",
diff --git a/internal/interfacesnapshot/compare_test.go b/internal/interfacesnapshot/compare_test.go
index 9dab9936..ee362dee 100644
--- a/internal/interfacesnapshot/compare_test.go
+++ b/internal/interfacesnapshot/compare_test.go
@@ -135,6 +135,63 @@ func TestCrossPlatformCoverageCompareAllowsRenameWhenOldPathIsAlias(t *testing.T
}
}
+func TestCrossPlatformCoverageCompareAllowsBoolConstParamsBootstrapAddition(t *testing.T) {
+ base := testSnapshot(
+ testCommand("dws"),
+ testCommand("dws send"),
+ )
+ current := testSnapshot(
+ testCommand("dws"),
+ testCommand("dws send"),
+ )
+ current.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true}
+
+ comparison := Compare(current, base, "base")
+ if !comparison.Compatible || len(comparison.Blocking) != 0 || len(comparison.Additions) != 0 {
+ t.Fatalf("ordinary Compare treated bootstrapped bool ConstParams evidence as a compatibility change: %#v", comparison)
+ }
+}
+
+func TestCrossPlatformCoverageCompareBlocksBoolConstParamsDriftAfterBootstrap(t *testing.T) {
+ base := testSnapshot(
+ testCommand("dws"),
+ testCommand("dws send"),
+ )
+ base.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true}
+
+ for _, test := range []struct {
+ name string
+ current map[string]bool
+ }{
+ {name: "removed", current: nil},
+ {name: "value flipped", current: map[string]bool{"convThreadEnabled": false}},
+ {name: "extra key", current: map[string]bool{"convThreadEnabled": true, "other": false}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ current := testSnapshot(
+ testCommand("dws"),
+ testCommand("dws send"),
+ )
+ current.Commands[1].BoolConstParams = test.current
+
+ comparison := Compare(current, base, "base")
+ if comparison.Compatible {
+ t.Fatalf("historical bool ConstParams drift was accepted: %#v", comparison)
+ }
+ if len(comparison.Blocking) != 1 {
+ t.Fatalf("blocking=%#v, want exactly one durable contract change", comparison.Blocking)
+ }
+ change := comparison.Blocking[0]
+ if change.Kind != "bool_const_params_changed" || change.Path != "dws send" {
+ t.Fatalf("blocking=%#v, want bool_const_params_changed at dws send", comparison.Blocking)
+ }
+ if len(comparison.Additions) != 0 {
+ t.Fatalf("bool ConstParams drift also produced additions: %#v", comparison.Additions)
+ }
+ })
+ }
+}
+
func TestCrossPlatformCoverageCompareBlocksRemovedAlias(t *testing.T) {
base := testSnapshot(
testCommand("dws"),
@@ -832,7 +889,7 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceipt
}
}
-func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
+func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsRetentionOrCleanupAfterAllReferencesCatchUp(t *testing.T) {
after := testFlagMigrationSnapshot(true, true)
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
references := map[string]Snapshot{
@@ -840,11 +897,15 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAl
"stable": after,
}
- if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil {
- t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report)
+ report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed)
+ if err != nil {
+ t.Fatalf("inert consumed receipt was rejected after every reference caught up: %v", err)
+ }
+ if !report.Compatible {
+ t.Fatalf("inert consumed receipt changed compatibility: %#v", report.Comparisons)
}
- report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
+ report, err = CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
if err != nil {
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
}
diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go
index 892a7c55..6d50aa2c 100644
--- a/internal/interfacesnapshot/migrations.go
+++ b/internal/interfacesnapshot/migrations.go
@@ -17,6 +17,9 @@ const FlagMigrationManifestVersion = 1
const (
FlagMigrationPending = "pending"
FlagMigrationConsumed = "consumed"
+
+ FlagMigrationRename = "flag_rename"
+ FlagMigrationRequirednessChange = "requiredness_change"
)
type FlagMigrationManifest struct {
@@ -25,11 +28,13 @@ type FlagMigrationManifest struct {
}
type FlagMigration struct {
- Command string `json:"command"`
- Legacy FlagMigrationSide `json:"legacy"`
- Canonical FlagMigrationSide `json:"canonical"`
- State string `json:"state"`
- Reason string `json:"reason"`
+ Kind string `json:"kind,omitempty"`
+ Command string `json:"command"`
+ Legacy FlagMigrationSide `json:"legacy"`
+ Canonical FlagMigrationSide `json:"canonical"`
+ Flag *FlagMigrationSide `json:"flag,omitempty"`
+ State string `json:"state"`
+ Reason string `json:"reason"`
}
type FlagMigrationSide struct {
@@ -91,22 +96,32 @@ func validateFlagMigrationJSONSchema(data []byte) error {
}
func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error {
+ return validateLabeledMigrationJSONValue(decoder, path, schema, "flag")
+}
+
+func validateLabeledMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type, label string) error {
token, err := decoder.Token()
if err != nil {
- return fmt.Errorf("read flag migration manifest value at %s: %w", path, err)
+ return fmt.Errorf("read %s migration manifest value at %s: %w", label, path, err)
+ }
+ for schema.Kind() == reflect.Pointer {
+ if token == nil {
+ return migrationJSONTypeError(path, schema.Elem(), token, label)
+ }
+ schema = schema.Elem()
}
switch schema.Kind() {
case reflect.Struct:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
- return migrationJSONTypeError(path, schema, token)
+ return migrationJSONTypeError(path, schema, token, label)
}
fields := migrationJSONFields(schema)
seen := make(map[string]bool, len(fields))
for decoder.More() {
keyToken, keyErr := decoder.Token()
if keyErr != nil {
- return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr)
+ return fmt.Errorf("read %s migration manifest field at %s: %w", label, path, keyErr)
}
// encoding/json guarantees object member names are string tokens.
key := keyToken.(string)
@@ -115,57 +130,58 @@ func validateMigrationJSONValue(decoder *json.Decoder, path string, schema refle
for canonical := range fields {
if strings.EqualFold(key, canonical) {
return fmt.Errorf(
- "flag migration manifest contains non-canonical field %q at %s (want %q)",
+ "%s migration manifest contains non-canonical field %q at %s (want %q)",
+ label,
key,
path,
canonical,
)
}
}
- return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path)
+ return fmt.Errorf("%s migration manifest contains unknown field %q at %s", label, key, path)
}
if seen[key] {
- return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path)
+ return fmt.Errorf("%s migration manifest contains duplicate field %q at %s", label, key, path)
}
seen[key] = true
- if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil {
+ if err := validateLabeledMigrationJSONValue(decoder, path+"."+key, fieldSchema, label); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
- return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr)
+ return fmt.Errorf("close %s migration manifest object at %s: %w", label, path, closeErr)
}
return nil
case reflect.Slice:
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
- return migrationJSONTypeError(path, schema, token)
+ return migrationJSONTypeError(path, schema, token, label)
}
for index := 0; decoder.More(); index++ {
- if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil {
+ if err := validateLabeledMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem(), label); err != nil {
return err
}
}
if _, closeErr := decoder.Token(); closeErr != nil {
- return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr)
+ return fmt.Errorf("close %s migration manifest array at %s: %w", label, path, closeErr)
}
return nil
case reflect.String:
if _, ok := token.(string); !ok {
- return migrationJSONTypeError(path, schema, token)
+ return migrationJSONTypeError(path, schema, token, label)
}
return nil
case reflect.Int:
if _, ok := token.(json.Number); !ok {
- return migrationJSONTypeError(path, schema, token)
+ return migrationJSONTypeError(path, schema, token, label)
}
return nil
case reflect.Bool:
if _, ok := token.(bool); !ok {
- return migrationJSONTypeError(path, schema, token)
+ return migrationJSONTypeError(path, schema, token, label)
}
return nil
default:
- return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema)
+ return fmt.Errorf("%s migration manifest value at %s has unsupported Go schema type %s", label, path, schema)
}
}
@@ -188,9 +204,10 @@ func migrationJSONFields(schema reflect.Type) map[string]reflect.Type {
return fields
}
-func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error {
+func migrationJSONTypeError(path string, want reflect.Type, token json.Token, label string) error {
return fmt.Errorf(
- "flag migration manifest value at %s must be %s, got %s",
+ "%s migration manifest value at %s must be %s, got %s",
+ label,
path,
migrationJSONKindDescription(want),
migrationJSONTokenDescription(token),
@@ -275,6 +292,23 @@ func (m FlagMigrationManifest) Validate() error {
}
legacyTargets[legacyKey] = migration.Canonical.Name
}
+ for _, requiredness := range m.Migrations {
+ if requiredness.EffectiveKind() != FlagMigrationRequirednessChange {
+ continue
+ }
+ for _, rename := range m.Migrations {
+ if rename.EffectiveKind() != FlagMigrationRename || rename.Command != requiredness.Command {
+ continue
+ }
+ if rename.Legacy.Name == requiredness.Flag.Name || rename.Canonical.Name == requiredness.Flag.Name {
+ return fmt.Errorf(
+ "flag requiredness migration %s overlaps rename migration %s",
+ requiredness.displayKey(),
+ rename.displayKey(),
+ )
+ }
+ }
+ }
return nil
}
@@ -282,15 +316,6 @@ func (m FlagMigration) validate() error {
if !isExactCommandPath(m.Command) {
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
}
- if !isExactFlagName(m.Legacy.Name) {
- return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
- }
- if !isExactFlagName(m.Canonical.Name) {
- return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
- }
- if m.Legacy.Name == m.Canonical.Name {
- return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
- }
if strings.TrimSpace(m.Reason) == "" {
return fmt.Errorf("migration must include a non-empty reason")
}
@@ -300,6 +325,30 @@ func (m FlagMigration) validate() error {
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
return fmt.Errorf("invalid state %q", m.State)
}
+
+ switch m.EffectiveKind() {
+ case FlagMigrationRename:
+ return m.validateRename()
+ case FlagMigrationRequirednessChange:
+ return m.validateRequirednessChange()
+ default:
+ return fmt.Errorf("invalid kind %q", m.Kind)
+ }
+}
+
+func (m FlagMigration) validateRename() error {
+ if m.Flag != nil {
+ return fmt.Errorf("flag rename must not declare flag requiredness fields")
+ }
+ if !isExactFlagName(m.Legacy.Name) {
+ return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
+ }
+ if !isExactFlagName(m.Canonical.Name) {
+ return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
+ }
+ if m.Legacy.Name == m.Canonical.Name {
+ return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
+ }
if err := m.Legacy.Before.validate("legacy before"); err != nil {
return err
}
@@ -380,6 +429,45 @@ func (m FlagMigration) validate() error {
return nil
}
+func (m FlagMigration) validateRequirednessChange() error {
+ if m.Legacy != (FlagMigrationSide{}) || m.Canonical != (FlagMigrationSide{}) {
+ return fmt.Errorf("flag requiredness migration must not declare legacy or canonical rename fields")
+ }
+ if m.Flag == nil || !isExactFlagName(m.Flag.Name) {
+ return fmt.Errorf("flag name must be an exact flag: %q", flagMigrationSideName(m.Flag))
+ }
+ if err := m.Flag.Before.validate("flag before"); err != nil {
+ return err
+ }
+ if err := m.Flag.After.validate("flag after"); err != nil {
+ return err
+ }
+ if !m.Flag.Before.Present || !m.Flag.After.Present || m.Flag.Before.Required || !m.Flag.After.Required {
+ return fmt.Errorf("flag requiredness migration must change exactly from optional to required")
+ }
+ if m.Flag.Before.Hidden || m.Flag.After.Hidden {
+ return fmt.Errorf("flag requiredness migration flag must stay visible")
+ }
+ if m.Flag.Before.AliasOf != "" || m.Flag.After.AliasOf != "" {
+ return fmt.Errorf("flag requiredness migration must not declare alias_of")
+ }
+ before := m.Flag.Before
+ after := m.Flag.After
+ before.Required = false
+ after.Required = false
+ if before != after {
+ return fmt.Errorf("flag requiredness migration must preserve every flag attribute except requiredness")
+ }
+ return nil
+}
+
+func flagMigrationSideName(side *FlagMigrationSide) string {
+ if side == nil {
+ return ""
+ }
+ return side.Name
+}
+
func (s FlagMigrationState) validate(label string) error {
if !s.Present {
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
@@ -397,7 +485,19 @@ func (s FlagMigrationState) validate(label string) error {
}
func (m FlagMigration) key() string {
- return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
+ if m.EffectiveKind() == FlagMigrationRequirednessChange {
+ return m.EffectiveKind() + "\x00" + m.Command + "\x00" + flagMigrationSideName(m.Flag)
+ }
+ return m.EffectiveKind() + "\x00" + m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
+}
+
+// EffectiveKind keeps manifests written before kinds were introduced valid.
+// An omitted kind is the original flag rename primitive.
+func (m FlagMigration) EffectiveKind() string {
+ if m.Kind == "" {
+ return FlagMigrationRename
+ }
+ return m.Kind
}
func isExactCommandPath(path string) bool {
@@ -416,9 +516,9 @@ func isExactFlagName(name string) bool {
}
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
-// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
-// records participate in the lifecycle check, but never authorize their own
-// interface change.
+// then consumes only exact, merge-base-owned flag rename or requiredness
+// migrations. Candidate-owned records participate in the lifecycle check, but
+// never authorize their own interface change.
func CompareAllWithFlagMigrations(
current Snapshot,
references map[string]Snapshot,
@@ -567,8 +667,8 @@ func evaluateFlagMigrationLifecycle(
}
}
if allReferencesAfter {
- if exists {
- return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
+ if exists && proposed.State != FlagMigrationConsumed {
+ return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
}
continue
}
@@ -628,6 +728,16 @@ func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMig
if !exists {
return flagMigrationPartial
}
+ if migration.EffectiveKind() == FlagMigrationRequirednessChange {
+ state := flagMigrationStateForCommand(command, flagMigrationSideName(migration.Flag))
+ if state == migration.Flag.Before {
+ return flagMigrationBefore
+ }
+ if state == migration.Flag.After {
+ return flagMigrationAfter
+ }
+ return flagMigrationPartial
+ }
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
@@ -682,6 +792,12 @@ func flagMigrationAuthorizesChange(
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
continue
}
+ if migration.EffectiveKind() == FlagMigrationRequirednessChange {
+ if change.Flag == migration.Flag.Name && change.Kind == "flag_became_required" {
+ return true
+ }
+ continue
+ }
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
return true
}
@@ -705,5 +821,8 @@ func flagMigrationAuthorizesChange(
}
func (m FlagMigration) displayKey() string {
+ if m.EffectiveKind() == FlagMigrationRequirednessChange {
+ return fmt.Sprintf("%q --%s optional -> required", m.Command, flagMigrationSideName(m.Flag))
+ }
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
}
diff --git a/internal/interfacesnapshot/migrations_coverage_test.go b/internal/interfacesnapshot/migrations_coverage_test.go
index fa4b239f..90f8064a 100644
--- a/internal/interfacesnapshot/migrations_coverage_test.go
+++ b/internal/interfacesnapshot/migrations_coverage_test.go
@@ -602,12 +602,12 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *tes
wantErr: "back to pending",
},
{
- name: "consumed receipt becomes stale",
+ name: "inert consumed receipt cannot revert to pending",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: consumed,
- candidate: consumed,
- wantErr: "is stale after all references reached the after state",
+ candidate: pending,
+ wantErr: "back to pending",
},
{
name: "candidate-added receipt starts pending",
@@ -857,15 +857,15 @@ func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *te
}
})
- t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) {
- _, err := CompareAllWithFlagMigrations(
+ t.Run("consumed receipt becomes inert after every reference converges", func(t *testing.T) {
+ report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after, "stable": after},
consumed,
consumed,
)
- if err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
- t.Fatalf("stale optional migration error = %v", err)
+ if err != nil || !report.Compatible {
+ t.Fatalf("inert optional migration = (%#v, %v), want compatible", report, err)
}
})
}
diff --git a/internal/interfacesnapshot/migrations_test.go b/internal/interfacesnapshot/migrations_test.go
index f9c406f7..4b3e9e75 100644
--- a/internal/interfacesnapshot/migrations_test.go
+++ b/internal/interfacesnapshot/migrations_test.go
@@ -38,6 +38,23 @@ const validFlagMigrationManifestJSON = `{
]
}`
+const validRequirednessMigrationManifestJSON = `{
+ "version": 1,
+ "migrations": [
+ {
+ "kind": "requiredness_change",
+ "command": "dws report entry submit",
+ "flag": {
+ "name": "to-user-ids",
+ "before": {"present": true, "type": "string", "scope": "local"},
+ "after": {"present": true, "type": "string", "required": true, "scope": "local"}
+ },
+ "state": "pending",
+ "reason": "Reject report submissions that have no visible recipient."
+ }
+ ]
+}`
+
func optionalFlagMigrationManifestJSON() string {
manifest := strings.Replace(
validFlagMigrationManifestJSON,
@@ -153,6 +170,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil {
t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err)
}
+ if _, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)); err != nil {
+ t.Fatalf("ReadFlagMigrationManifest(requiredness change) error = %v", err)
+ }
tests := []struct {
name string
@@ -246,6 +266,229 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *
}
}
+func TestCrossPlatformCoverageRequirednessMigrationRejectsInexactContracts(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantErr string
+ }{
+ {
+ name: "unknown kind",
+ input: strings.Replace(validRequirednessMigrationManifestJSON, `"requiredness_change"`, `"anything"`, 1),
+ wantErr: "invalid kind",
+ },
+ {
+ name: "missing flag name",
+ input: strings.Replace(validRequirednessMigrationManifestJSON, `"to-user-ids"`, `""`, 1),
+ wantErr: "exact flag",
+ },
+ {
+ name: "invalid before state",
+ input: strings.Replace(
+ validRequirednessMigrationManifestJSON,
+ `"before": {"present": true, "type": "string", "scope": "local"}`,
+ `"before": {"present": true, "type": "string", "scope": "anything"}`,
+ 1,
+ ),
+ wantErr: "flag before present state has invalid scope",
+ },
+ {
+ name: "invalid after state",
+ input: strings.Replace(
+ validRequirednessMigrationManifestJSON,
+ `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
+ `"after": {"present": true, "type": "string", "required": true, "scope": "anything"}`,
+ 1,
+ ),
+ wantErr: "flag after present state has invalid scope",
+ },
+ {
+ name: "before already required",
+ input: strings.Replace(
+ validRequirednessMigrationManifestJSON,
+ `"before": {"present": true, "type": "string", "scope": "local"}`,
+ `"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
+ 1,
+ ),
+ wantErr: "optional to required",
+ },
+ {
+ name: "after remains optional",
+ input: strings.Replace(
+ validRequirednessMigrationManifestJSON,
+ `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
+ `"after": {"present": true, "type": "string", "scope": "local"}`,
+ 1,
+ ),
+ wantErr: "optional to required",
+ },
+ {
+ name: "type drift",
+ input: strings.Replace(
+ validRequirednessMigrationManifestJSON,
+ `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
+ `"after": {"present": true, "type": "stringSlice", "required": true, "scope": "local"}`,
+ 1,
+ ),
+ wantErr: "must preserve every flag attribute except requiredness",
+ },
+ {
+ name: "visibility drift",
+ input: strings.Replace(
+ validRequirednessMigrationManifestJSON,
+ `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
+ `"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`,
+ 1,
+ ),
+ wantErr: "must stay visible",
+ },
+ {
+ name: "alias relation",
+ input: strings.Replace(
+ validRequirednessMigrationManifestJSON,
+ `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
+ `"after": {"present": true, "type": "string", "required": true, "scope": "local", "alias_of": "other"}`,
+ 1,
+ ),
+ wantErr: "must not declare alias_of",
+ },
+ {
+ name: "rename fields cannot be combined",
+ input: strings.Replace(
+ validRequirednessMigrationManifestJSON,
+ `"flag": {`,
+ `"legacy": {"name":"old","before":{"present":false},"after":{"present":false}}, "flag": {`,
+ 1,
+ ),
+ wantErr: "must not declare legacy or canonical",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ _, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
+ if err == nil || !strings.Contains(err.Error(), test.wantErr) {
+ t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
+ }
+ })
+ }
+
+ missingFlag := FlagMigration{
+ Kind: FlagMigrationRequirednessChange,
+ Command: "dws report entry submit",
+ State: FlagMigrationPending,
+ Reason: "Missing exact flag state.",
+ }
+ if err := missingFlag.validate(); err == nil || !strings.Contains(err.Error(), "exact flag") {
+ t.Fatalf("missing requiredness flag error = %v", err)
+ }
+
+ rename, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
+ if err != nil {
+ t.Fatal(err)
+ }
+ rename.Migrations[0].Kind = FlagMigrationRename
+ rename.Migrations[0].Flag = &FlagMigrationSide{
+ Name: "unrelated",
+ Before: FlagMigrationState{Present: true, Type: "string", Scope: "local"},
+ After: FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
+ }
+ if err := rename.Validate(); err == nil || !strings.Contains(err.Error(), "must not declare flag requiredness fields") {
+ t.Fatalf("rename with requiredness fields error = %v", err)
+ }
+}
+
+func TestCrossPlatformCoverageRequirednessMigrationLifecycleAndAuthorization(t *testing.T) {
+ pending, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
+ if err != nil {
+ t.Fatal(err)
+ }
+ consumed := pending
+ consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
+ consumed.Migrations[0].State = FlagMigrationConsumed
+ migration := pending.Migrations[0]
+ before := requirednessMigrationSnapshot(migration, false)
+ after := requirednessMigrationSnapshot(migration, true)
+
+ ordinary := Compare(after, before, "merge-base")
+ if !hasFlagChange(ordinary.Blocking, "flag_became_required", migration.Command, migration.Flag.Name) {
+ t.Fatalf("fixture did not produce flag_became_required: %#v", ordinary.Blocking)
+ }
+ report, err := CompareAllWithFlagMigrations(
+ after,
+ map[string]Snapshot{"merge-base": before, "stable": before},
+ pending,
+ consumed,
+ )
+ if err != nil || !report.Compatible {
+ t.Fatalf("governed requiredness change = (%#v, %v), want compatible", report, err)
+ }
+ partial := after
+ partial.Commands = append([]Command(nil), after.Commands...)
+ partial.Commands[len(partial.Commands)-1].LocalFlags = append(
+ []Flag(nil),
+ after.Commands[len(after.Commands)-1].LocalFlags...,
+ )
+ partial.Commands[len(partial.Commands)-1].LocalFlags[0].Type = "stringSlice"
+ if phase := matchFlagMigrationPhase(partial, migration); phase != flagMigrationPartial {
+ t.Fatalf("drifted requiredness migration phase = %s, want partial", phase)
+ }
+
+ beforeWithUnrelated := before
+ beforeWithUnrelated.Commands = append([]Command(nil), before.Commands...)
+ beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags = append(
+ beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags,
+ Flag{Name: "unrelated", Type: "string"},
+ )
+ afterWithUnrelated := after
+ afterWithUnrelated.Commands = append([]Command(nil), after.Commands...)
+ afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags = append(
+ afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags,
+ Flag{Name: "unrelated", Type: "string", Required: true},
+ )
+ report, err = CompareAllWithFlagMigrations(
+ afterWithUnrelated,
+ map[string]Snapshot{"merge-base": beforeWithUnrelated, "stable": beforeWithUnrelated},
+ pending,
+ consumed,
+ )
+ if err != nil || report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_became_required", migration.Command, "unrelated") {
+ t.Fatalf("unrelated requiredness change was hidden: report=%#v err=%v", report, err)
+ }
+
+ empty := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
+ if _, err := CompareAllWithFlagMigrations(
+ after,
+ map[string]Snapshot{"merge-base": before, "stable": before},
+ empty,
+ pending,
+ ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") {
+ t.Fatalf("candidate self-authorization error = %v", err)
+ }
+}
+
+func requirednessMigrationSnapshot(migration FlagMigration, after bool) Snapshot {
+ state := migration.Flag.Before
+ if after {
+ state = migration.Flag.After
+ }
+ flag := Flag{
+ Name: migration.Flag.Name,
+ Type: state.Type,
+ Required: state.Required,
+ Hidden: state.Hidden,
+ Shorthand: state.Shorthand,
+ NoOpt: state.NoOpt,
+ }
+ command := testCommandWithFlagScopes(migration.Command, nil, nil)
+ if state.Scope == "inherited" {
+ command.InheritedFlags = []Flag{flag}
+ } else {
+ command.LocalFlags = []Flag{flag}
+ }
+ return testSnapshot(testCommand("dws"), command)
+}
+
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
if err != nil {
@@ -282,6 +525,22 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon
if err == nil || !strings.Contains(err.Error(), "trailing") {
t.Fatalf("trailing JSON error = %v", err)
}
+
+ requiredness, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON))
+ if err != nil {
+ t.Fatal(err)
+ }
+ overlap := manifest
+ overlap.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
+ rename := overlap.Migrations[0]
+ rename.Command = requiredness.Migrations[0].Command
+ rename.Canonical.Name = requiredness.Migrations[0].Flag.Name
+ rename.Legacy.After.AliasOf = rename.Canonical.Name
+ overlap.Migrations[0] = rename
+ overlap.Migrations = append(overlap.Migrations, requiredness.Migrations[0])
+ if err := overlap.Validate(); err == nil || !strings.Contains(err.Error(), "overlaps rename migration") {
+ t.Fatalf("requiredness and rename overlap error = %v", err)
+ }
}
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
diff --git a/internal/interfacesnapshot/snapshot.go b/internal/interfacesnapshot/snapshot.go
index 408805d3..11a7bf46 100644
--- a/internal/interfacesnapshot/snapshot.go
+++ b/internal/interfacesnapshot/snapshot.go
@@ -22,12 +22,13 @@ import (
"sort"
"strings"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
-const SchemaVersion = 2
+const SchemaVersion = 3
// FlagAliasOfAnnotation records a framework-originated reviewed relationship
// between a retained compatibility flag and an exact canonical Cobra flag.
@@ -66,13 +67,14 @@ type Snapshot struct {
// Command contains compatibility-relevant command metadata. Path always
// includes the root command name (for example, "dws chat message send").
type Command struct {
- Path string `json:"path"`
- Runnable bool `json:"runnable"`
- Hidden bool `json:"hidden"`
- Deprecated string `json:"deprecated,omitempty"`
- Aliases []string `json:"aliases"`
- LocalFlags []Flag `json:"local_flags"`
- InheritedFlags []Flag `json:"inherited_flags"`
+ Path string `json:"path"`
+ Runnable bool `json:"runnable"`
+ Hidden bool `json:"hidden"`
+ Deprecated string `json:"deprecated,omitempty"`
+ Aliases []string `json:"aliases"`
+ LocalFlags []Flag `json:"local_flags"`
+ InheritedFlags []Flag `json:"inherited_flags"`
+ BoolConstParams map[string]bool `json:"bool_const_params,omitempty"`
}
// Flag contains the stable pflag contract visible at a command node.
@@ -126,14 +128,16 @@ func Capture(root *cobra.Command) Snapshot {
}
aliases = compactSorted(aliases)
+ boolConstParams := corecmd.InterfaceBoolConstParams(cmd)
snapshot.Commands = append(snapshot.Commands, Command{
- Path: path,
- Runnable: cmd.Runnable(),
- Hidden: cmd.Hidden,
- Deprecated: strings.TrimSpace(cmd.Deprecated),
- Aliases: aliases,
- LocalFlags: captureFlags(cmd.LocalFlags()),
- InheritedFlags: captureFlags(cmd.InheritedFlags()),
+ Path: path,
+ Runnable: cmd.Runnable(),
+ Hidden: cmd.Hidden,
+ Deprecated: strings.TrimSpace(cmd.Deprecated),
+ Aliases: aliases,
+ LocalFlags: captureFlags(cmd.LocalFlags()),
+ InheritedFlags: captureFlags(cmd.InheritedFlags()),
+ BoolConstParams: boolConstParams,
})
children := append([]*cobra.Command(nil), cmd.Commands()...)
@@ -198,6 +202,9 @@ func (s Snapshot) Validate() error {
return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path)
}
seenCommands[command.Path] = true
+ if command.BoolConstParams != nil && len(command.BoolConstParams) == 0 {
+ return fmt.Errorf("command %q must omit empty bool_const_params", command.Path)
+ }
if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil {
return err
}
diff --git a/internal/interfacesnapshot/snapshot_test.go b/internal/interfacesnapshot/snapshot_test.go
index eb27274e..2bb6324d 100644
--- a/internal/interfacesnapshot/snapshot_test.go
+++ b/internal/interfacesnapshot/snapshot_test.go
@@ -17,6 +17,7 @@ import (
"bytes"
"encoding/json"
"reflect"
+ "strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -98,16 +99,105 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin
func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
input := bytes.NewBufferString(`{
- "schema_version": 2,
- "rules": {"excluded_command_subtrees": [], "excluded_flags": []},
- "commands": [],
- "future_field": true
+ "schema_version": 3,
+ "rules": {"excluded_command_subtrees": [], "excluded_flags": []},
+ "commands": [],
+ "future_field": true
}`)
if _, err := Read(input); err == nil {
t.Fatal("Read accepted an unknown field")
}
}
+func TestCrossPlatformCoverageCaptureRoundTripsFrameworkBoolConstParams(t *testing.T) {
+ root := &cobra.Command{Use: "dws"}
+ leaf := corecmd.New(corecmd.Spec{
+ Use: "send",
+ ConstParams: map[string]any{"convThreadEnabled": true, "precheckOnly": false},
+ Invoke: func(*corecmd.Ctx, map[string]any) error { return nil },
+ })
+ root.AddCommand(leaf)
+
+ snapshot := Capture(root)
+ got := commandIndex(snapshot)["dws send"].BoolConstParams
+ want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("captured bool ConstParams = %#v, want %#v", got, want)
+ }
+
+ var encoded bytes.Buffer
+ if err := Write(&encoded, snapshot); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ decoded, err := Read(bytes.NewReader(encoded.Bytes()))
+ if err != nil {
+ t.Fatalf("Read: %v", err)
+ }
+ if got := commandIndex(decoded)["dws send"].BoolConstParams; !reflect.DeepEqual(got, want) {
+ t.Fatalf("round-tripped bool ConstParams = %#v, want %#v", got, want)
+ }
+}
+
+func TestCrossPlatformCoverageCaptureIgnoresHandwrittenLegacyConstParamsAnnotations(t *testing.T) {
+ root := &cobra.Command{Use: "dws"}
+ direct := &cobra.Command{
+ Use: "direct",
+ Run: func(*cobra.Command, []string) {},
+ Annotations: map[string]string{
+ "dws.compat.bool_const_params": `{"forged":true}`,
+ "dws.compat.const_params_origin": "corecmd.const_params.v1",
+ },
+ }
+ businessConstructor := func() *cobra.Command {
+ payloadKey := strings.Join([]string{"dws.compat.bool_", "const_params"}, "")
+ originKey := strings.Join([]string{"dws.compat.const_", "params_origin"}, "")
+ origin := strings.Join([]string{"corecmd.const_", "params.v1"}, "")
+ return &cobra.Command{
+ Use: "business",
+ Run: func(*cobra.Command, []string) {},
+ Annotations: map[string]string{
+ payloadKey: `{"forged":true}`,
+ originKey: origin,
+ },
+ }
+ }
+ root.AddCommand(direct, businessConstructor())
+
+ snapshot := Capture(root)
+ if err := snapshot.Validate(); err != nil {
+ t.Fatalf("handwritten legacy annotations affected snapshot validity: %v", err)
+ }
+ for _, path := range []string{"dws direct", "dws business"} {
+ if got := commandIndex(snapshot)[path].BoolConstParams; got != nil {
+ t.Fatalf("%s forged bool ConstParams evidence = %#v", path, got)
+ }
+ }
+ var encoded bytes.Buffer
+ if err := Write(&encoded, snapshot); err != nil {
+ t.Fatalf("Write snapshot without forged evidence: %v", err)
+ }
+}
+
+func TestCrossPlatformCoverageReadRejectsMalformedBoolConstParams(t *testing.T) {
+ for _, payload := range []string{`{"fixed":"true"}`, `{}`} {
+ input := bytes.NewBufferString(`{
+ "schema_version": 3,
+ "rules": {"excluded_command_subtrees": [], "excluded_flags": []},
+ "commands": [{
+ "path": "dws send",
+ "runnable": true,
+ "aliases": [],
+ "local_flags": [],
+ "inherited_flags": [],
+ "bool_const_params": ` + payload + `
+ }]
+ }`)
+ if _, err := Read(input); err == nil {
+ t.Fatalf("Read accepted malformed bool_const_params %s", payload)
+ }
+ }
+}
+
func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) {
if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf {
t.Fatalf(
diff --git a/internal/shortcut/aitable/aitable.go b/internal/shortcut/aitable/aitable.go
index ac7443da..c5c20534 100644
--- a/internal/shortcut/aitable/aitable.go
+++ b/internal/shortcut/aitable/aitable.go
@@ -403,11 +403,7 @@ var BaseCopy = shortcut.Shortcut{
},
Tips: []string{`dws aitable +base-copy --base-id BASE_ID --target-folder-id FOLDER_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
- return rt.CallMCP("copy_base", map[string]any{
- "baseId": rt.Str("base-id"),
- "targetFolderId": rt.Str("target-folder-id"),
- "onlyCopyMeta": rt.Bool("only-struct"),
- })
+ return executeBaseCopy(rt)
},
}
@@ -660,7 +656,7 @@ var RecordQuery = shortcut.Shortcut{
Command: "+record-query",
Product: serverMain,
Description: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
- Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。",
+ Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -682,7 +678,7 @@ var RecordQuery = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
- UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。"},
+ UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws aitable +record-query --base-id B --table-id T --query \"关键词\" --limit 50",
@@ -975,11 +971,7 @@ var RecordPrimaryDocGet = shortcut.Shortcut{
},
Tips: []string{`dws aitable +record-primary-doc-get --base-id B --table-id T --record-id R`},
Execute: func(rt *shortcut.RuntimeContext) error {
- return rt.CallMCP("get_primary_doc", map[string]any{
- "baseId": rt.Str("base-id"),
- "tableId": rt.Str("table-id"),
- "recordId": rt.Str("record-id"),
- })
+ return executeRecordPrimaryDocGet(rt)
},
}
@@ -2078,7 +2070,7 @@ func workflowListProject(data map[string]any) ([]map[string]any, error) {
return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item)
}
row := map[string]any{}
- if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok {
+ if v, ok := workflowListFirst(m, "workflowId", "flowId", "workflow_id", "id"); ok {
row["workflowId"] = v
}
if v, ok := workflowListFirst(m, "name", "workflowName", "title"); ok {
diff --git a/internal/shortcut/aitable/base_composite_e2e_test.go b/internal/shortcut/aitable/base_composite_e2e_test.go
index 20c6965d..9df47ae6 100644
--- a/internal/shortcut/aitable/base_composite_e2e_test.go
+++ b/internal/shortcut/aitable/base_composite_e2e_test.go
@@ -79,6 +79,154 @@ func TestCrossPlatformCoverageBaseSchemaSnapshotE2E(t *testing.T) {
})
}
+func TestCrossPlatformCoverageBaseCopyRequiresNewIDAndExactReadBackE2E(t *testing.T) {
+ t.Run("verified copy", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"data":{"fileId":"folder","type":"FOLDER"}}`},
+ {text: `{"data":{"newBaseId":"new-base"}}`},
+ {text: `{"data":{"baseId":"new-base","tables":[]}}`},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
+ if err != nil || !strings.Contains(out, `"newBaseId": "new-base"`) || len(caller.calls) != 3 || caller.calls[0].product != "drive" || caller.calls[0].tool != "get_file_info" || caller.calls[2].tool != "get_base" {
+ t.Fatalf("base copy = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("missing new ID is unknown without read-back", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"data":{"fileId":"folder","type":"folder"}}`},
+ {text: `{"success":true}`},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
+ var typed *apperrors.Error
+ if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
+ t.Fatalf("missing newBaseId = output:%q err:%#v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("explicit downstream target rejection is stable and not retryable", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"data":{"fileId":"folder","type":"folder"}}`},
+ {err: errors.New("Invalid target folder ID")},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
+ var typed *apperrors.Error
+ if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
+ t.Fatalf("rejected target = output:%q err:%#v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("rejects a non-folder target before write", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"file-1","type":"FILE"}}`}}}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "file-1", "--yes")
+ var typed *apperrors.Error
+ if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_wrong_type" || typed.Retryable || typed.ExecutionStarted == nil || *typed.ExecutionStarted {
+ t.Fatalf("wrong target type = output:%q err:%#v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("rejects target metadata without a supported type", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"folder"}}`}}}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
+ var typed *apperrors.Error
+ if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
+ t.Fatalf("unsupported target metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("rejects URL target before transport", func(t *testing.T) {
+ caller := &upsertByKeyCaller{}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "https://example.test/folder", "--yes")
+ if err == nil || out != "" || len(caller.calls) != 0 {
+ t.Fatalf("URL target = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("rejects pure numeric dentryId before transport", func(t *testing.T) {
+ caller := &upsertByKeyCaller{}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "123456789", "--yes")
+ if err == nil || out != "" || len(caller.calls) != 0 {
+ t.Fatalf("numeric target = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("nodeId-only metadata cannot prove dentryUuid", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"nodeId":"folder","type":"FOLDER"}}`}}}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
+ var typed *apperrors.Error
+ if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
+ t.Fatalf("nodeId-only metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageBaseCopyFailureAndDryRunEdgesE2E(t *testing.T) {
+ t.Run("invalid source", func(t *testing.T) {
+ out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-copy", "--base-id", "bad/id", "--target-folder-id", "folder", "--yes")
+ if err == nil || out != "" {
+ t.Fatalf("invalid source = output:%q err:%v", out, err)
+ }
+ })
+
+ t.Run("dry run", func(t *testing.T) {
+ caller := &upsertByKeyCaller{dryRun: true}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--dry-run")
+ if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
+ t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("target lookup error", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: context.Canceled}}}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
+ if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || out != "" || len(caller.calls) != 1 {
+ t.Fatalf("target error = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("generic copy error is unknown", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"data":{"fileId":"folder","type":"folder"}}`},
+ {err: errors.New("transport failed")},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
+ if err == nil || out != "" || len(caller.calls) != 2 {
+ t.Fatalf("copy error = output:%q err:%v", out, err)
+ }
+ })
+
+ readBackCases := []struct {
+ name string
+ step upsertByKeyStep
+ }{
+ {name: "transport", step: upsertByKeyStep{err: context.DeadlineExceeded}},
+ {name: "missing id", step: upsertByKeyStep{text: `{}`}},
+ {name: "wrong id", step: upsertByKeyStep{text: `{"baseId":"other"}`}},
+ }
+ for _, tc := range readBackCases {
+ t.Run("readback "+tc.name, func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"data":{"fileId":"folder","type":"folder"}}`},
+ {text: `{"newBaseId":"new-base"}`},
+ tc.step,
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
+ if err == nil || out != "" || len(caller.calls) != 3 {
+ t.Fatalf("readback %s = output:%q err:%v", tc.name, out, err)
+ }
+ })
+ }
+
+ for _, value := range []string{"", "white space", "control\x00"} {
+ if validCompositeOpaqueID(value) {
+ t.Errorf("validCompositeOpaqueID(%q) = true", value)
+ }
+ }
+ if pureNumericID("") || pureNumericID("123a") || !pureNumericID("123") {
+ t.Fatal("pureNumericID edge mismatch")
+ }
+}
+
func bootstrapFields(count int) []any {
fields := make([]any, 0, count)
for index := 0; index < count; index++ {
diff --git a/internal/shortcut/aitable/base_copy.go b/internal/shortcut/aitable/base_copy.go
new file mode 100644
index 00000000..bc569a68
--- /dev/null
+++ b/internal/shortcut/aitable/base_copy.go
@@ -0,0 +1,136 @@
+// Copyright 2026 Alibaba Group
+// SPDX-License-Identifier: Apache-2.0
+
+package aitable
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func executeBaseCopy(rt *shortcut.RuntimeContext) error {
+ baseID := strings.TrimSpace(rt.Str("base-id"))
+ targetFolderID := strings.TrimSpace(rt.Str("target-folder-id"))
+ if !validCompositeOpaqueID(baseID) {
+ return apperrors.NewValidation("--base-id 不是合法的 Base ID")
+ }
+ if !validCompositeOpaqueID(targetFolderID) || pureNumericID(targetFolderID) {
+ return apperrors.NewValidation("--target-folder-id 必须是文件夹 dentryUuid,不能是 URL、路径或空值")
+ }
+ params := map[string]any{
+ "baseId": baseID,
+ "targetFolderId": targetFolderID,
+ "onlyCopyMeta": rt.Bool("only-struct"),
+ }
+ result := newCompositeResult("base_copy")
+ result.Resolved = map[string]any{"sourceBaseId": baseID, "targetFolderId": targetFolderID}
+ result.Plan = []compositeStep{
+ {Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "planned", Arguments: map[string]any{"fileId": targetFolderID}},
+ {Index: 2, Name: "copy base", Tool: "copy_base", Status: "planned", Arguments: params},
+ }
+ if rt.DryRun() {
+ result.Status = "planned"
+ result.Executed = false
+ return rt.Output(result)
+ }
+
+ targetData, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": targetFolderID})
+ if err != nil {
+ return err
+ }
+ actualTargetID := findStringByKeys(targetData, "fileId", "dentryUuid")
+ if actualTargetID == "" || actualTargetID != targetFolderID {
+ return apperrors.NewAPI("drive/get_file_info did not prove the exact target dentryUuid",
+ apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
+ apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
+ }
+ targetType := findStringByKeys(targetData, "type", "dentryType", "fileType")
+ if targetType == "" {
+ return apperrors.NewAPI("drive/get_file_info response is missing the target node type",
+ apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
+ apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
+ }
+ if !strings.EqualFold(targetType, "folder") {
+ return apperrors.NewAPI(fmt.Sprintf("target %s is %s, not a folder", targetFolderID, targetType),
+ apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_wrong_type"),
+ apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
+ }
+ result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "completed", Result: map[string]any{"fileId": actualTargetID, "type": targetType}})
+
+ writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "copy_base", params)
+ newBaseID := findStringByKeys(writeData, "newBaseId")
+ if newBaseID == "" && copyBaseRejectedTarget(writeErr) {
+ return apperrors.NewAPI("copy_base rejected a target that Drive proved is the exact folder dentryUuid",
+ apperrors.WithOperation("aitable/copy_base"), apperrors.WithOrigin("mcp"),
+ apperrors.WithReason("target_not_supported"), apperrors.WithFailureStage("copy_base"),
+ apperrors.WithExecutionStarted(true), apperrors.WithRetryable(false), apperrors.WithCause(writeErr),
+ apperrors.WithHint("the current copy_base service does not accept this Drive folder contract; retrying or substituting spaceId/nodeId/dentryId is unsafe"))
+ }
+ if newBaseID == "" || !validCompositeOpaqueID(newBaseID) {
+ cause := fmt.Errorf("copy_base response is missing a valid newBaseId")
+ if writeErr != nil {
+ cause = fmt.Errorf("copy_base response error: %w; newBaseId is unavailable", writeErr)
+ }
+ result.Status = "unknown"
+ return compositeError(result, cause, false)
+ }
+ result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "copy_base", "newBaseId": newBaseID})
+
+ readBack, verifyErr := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": newBaseID})
+ if verifyErr == nil {
+ actualID := findStringByKeys(readBack, "baseId")
+ if actualID == "" {
+ verifyErr = fmt.Errorf("get_base read-back is missing baseId")
+ } else if actualID != newBaseID {
+ verifyErr = fmt.Errorf("get_base read-back identity mismatch: got %q, want %q", actualID, newBaseID)
+ }
+ }
+ if verifyErr != nil {
+ result.Status = "partial_success"
+ result.Verification = map[string]any{"status": "failed", "newBaseId": newBaseID, "error": verifyErr.Error()}
+ return compositeError(result, verifyErr, false)
+ }
+
+ result.CompletedCount = 2
+ result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "copy base", Tool: "copy_base", Status: "completed", Result: map[string]any{"newBaseId": newBaseID}})
+ result.Verification = map[string]any{"status": "verified", "newBaseId": newBaseID}
+ result.Result = map[string]any{"newBaseId": newBaseID, "base": readBack}
+ return rt.Output(result)
+}
+
+func copyBaseRejectedTarget(err error) bool {
+ if err == nil {
+ return false
+ }
+ message := strings.ToLower(err.Error())
+ return strings.Contains(message, "invalid target folder") ||
+ strings.Contains(message, "invalid targetfolder")
+}
+
+func validCompositeOpaqueID(value string) bool {
+ if value == "" || len(value) > 512 || strings.ContainsAny(value, "/?#") {
+ return false
+ }
+ for _, r := range value {
+ if unicode.IsSpace(r) || unicode.IsControl(r) {
+ return false
+ }
+ }
+ return true
+}
+
+func pureNumericID(value string) bool {
+ if value == "" {
+ return false
+ }
+ for _, r := range value {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/shortcut/aitable/primary_doc.go b/internal/shortcut/aitable/primary_doc.go
new file mode 100644
index 00000000..52d2a662
--- /dev/null
+++ b/internal/shortcut/aitable/primary_doc.go
@@ -0,0 +1,142 @@
+// Copyright 2026 Alibaba Group
+// SPDX-License-Identifier: Apache-2.0
+
+package aitable
+
+import (
+ "fmt"
+ "strings"
+
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func executeRecordPrimaryDocGet(rt *shortcut.RuntimeContext) error {
+ baseID, tableID, requestedRecordID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id")
+ fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
+ if err != nil {
+ return err
+ }
+ fields, found := findNamedObjectList(fieldsData, "fields", "fieldList")
+ if !found {
+ return apperrors.NewAPI("get_fields response is missing the fields collection",
+ apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"),
+ apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
+ }
+ primaryFieldIDs := make([]string, 0, 1)
+ for index, field := range fields {
+ fieldType := stringValue(field, "type", "fieldType")
+ if !strings.EqualFold(fieldType, "primaryDoc") {
+ continue
+ }
+ fieldID := stringValue(field, "fieldId", "id")
+ if fieldID == "" {
+ return apperrors.NewAPI(fmt.Sprintf("primaryDoc field %d is missing fieldId", index),
+ apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"),
+ apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
+ }
+ primaryFieldIDs = append(primaryFieldIDs, fieldID)
+ }
+
+ recordParams := map[string]any{
+ "baseId": baseID, "tableId": tableID, "recordIds": []string{requestedRecordID},
+ }
+ if len(primaryFieldIDs) > 0 {
+ recordParams["fieldIds"] = primaryFieldIDs
+ }
+ window, err := queryRecordWindow(rt, recordParams, 1)
+ if err != nil {
+ return err
+ }
+ records := window.Records
+ if len(records) == 0 {
+ return apperrors.NewAPI(fmt.Sprintf("record %s was not found", requestedRecordID),
+ apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("RESOURCE_NOT_FOUND"),
+ apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false))
+ }
+ if len(records) != 1 || recordID(records[0]) != requestedRecordID {
+ return apperrors.NewAPI("record preflight did not return the exact requested record",
+ apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("target_invalid_response"),
+ apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
+ }
+ if len(primaryFieldIDs) == 0 {
+ return rt.Output(map[string]any{
+ "status": "no_primary_doc_field", "exists": false, "recordId": requestedRecordID, "nodeId": nil,
+ })
+ }
+ data, err := rt.CallMCPData(serverHelper, "get_primary_doc", map[string]any{
+ "baseId": baseID, "tableId": tableID, "recordId": requestedRecordID,
+ })
+ if err != nil {
+ if knownPrimaryDocUnassociatedError(err) {
+ return rt.Output(map[string]any{
+ "status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil,
+ })
+ }
+ return err
+ }
+ nodeID := primaryDocNodeID(data)
+ if nodeID == "" {
+ if knownPrimaryDocUnassociatedData(data) {
+ return rt.Output(map[string]any{
+ "status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil,
+ })
+ }
+ return apperrors.NewAPI("get_primary_doc response is missing nodeId",
+ apperrors.WithOperation("aitable-helper/get_primary_doc"), apperrors.WithReason("target_invalid_response"),
+ apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
+ }
+ return rt.Output(map[string]any{
+ "status": "associated", "exists": true, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nodeID,
+ })
+}
+
+func knownPrimaryDocUnassociatedError(err error) bool {
+ message := strings.ToLower(strings.TrimSpace(err.Error()))
+ return strings.Contains(message, "no record") || strings.Contains(message, "unassociated")
+}
+
+func primaryDocNodeID(value any) string {
+ object, ok := value.(map[string]any)
+ if !ok {
+ return ""
+ }
+ for _, key := range []string{"nodeId", "dentryUuid"} {
+ if nodeID, ok := object[key].(string); ok && strings.TrimSpace(nodeID) != "" {
+ return strings.TrimSpace(nodeID)
+ }
+ }
+ for _, envelopeKey := range []string{"data", "result", "response"} {
+ if nodeID := primaryDocNodeID(object[envelopeKey]); nodeID != "" {
+ return nodeID
+ }
+ }
+ return ""
+}
+
+func knownPrimaryDocUnassociatedData(value any) bool {
+ object, ok := value.(map[string]any)
+ if !ok {
+ return false
+ }
+ if exists, ok := object["exists"].(bool); ok && !exists {
+ return true
+ }
+ for _, key := range []string{"nodeId", "dentryUuid"} {
+ if nodeID, exists := object[key]; exists && (nodeID == nil || strings.TrimSpace(fmt.Sprint(nodeID)) == "") {
+ return true
+ }
+ }
+ if status, ok := object["status"].(string); ok {
+ status = strings.ToLower(strings.TrimSpace(status))
+ if status == "unassociated" || status == "no_record" {
+ return true
+ }
+ }
+ for _, envelopeKey := range []string{"data", "result", "response"} {
+ if child, ok := object[envelopeKey].(map[string]any); ok && knownPrimaryDocUnassociatedData(child) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/shortcut/aitable/record_batch.go b/internal/shortcut/aitable/record_batch.go
index 63f7d92f..7ff35ebd 100644
--- a/internal/shortcut/aitable/record_batch.go
+++ b/internal/shortcut/aitable/record_batch.go
@@ -84,9 +84,43 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
return rt.Output(result)
}
- for offset := 0; offset < len(ids); offset += recordBatchSize {
- end := minInt(offset+recordBatchSize, len(ids))
- batch := ids[offset:end]
+ existingIDs, err := queryExistingRecordIDs(rt, baseID, tableID, ids)
+ if err != nil {
+ return err
+ }
+ existingSet := make(map[string]bool, len(existingIDs))
+ for _, id := range existingIDs {
+ existingSet[id] = true
+ }
+ missingIDs := make([]string, 0, len(ids)-len(existingIDs))
+ for _, id := range ids {
+ if !existingSet[id] {
+ missingIDs = append(missingIDs, id)
+ }
+ }
+ result.Resolved = map[string]any{
+ "existingRecordIds": existingIDs,
+ "missingRecordIds": missingIDs,
+ }
+ result.Plan = nil
+ for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
+ end := minInt(offset+recordBatchSize, len(existingIDs))
+ result.Plan = append(result.Plan, compositeStep{
+ Index: len(result.Plan) + 1, Name: "delete existing record batch", Tool: "delete_records",
+ Status: "planned", Offset: offset, Count: end - offset,
+ })
+ }
+ if len(existingIDs) == 0 {
+ result.Status = "unchanged"
+ result.Executed = false
+ result.Verification = map[string]any{"status": "verified_absent_before_write", "missingCount": len(missingIDs)}
+ result.Result = map[string]any{"deletedCount": 0, "missingCount": len(missingIDs), "batchCount": 0}
+ return rt.Output(result)
+ }
+
+ for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
+ end := minInt(offset+recordBatchSize, len(existingIDs))
+ batch := existingIDs[offset:end]
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": batch,
})
@@ -109,7 +143,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
step.Status = "unknown"
result.CompletedSteps = append(result.CompletedSteps, step)
result.CompletedCount = offset
- result.FailedCount = len(ids) - offset
+ result.FailedCount = len(existingIDs) - offset
result.Status = "unknown"
if offset > 0 {
result.Status = "partial_success"
@@ -130,11 +164,49 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
result.CompletedCount = end
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "delete_records", "offset": offset, "recordIds": batch})
}
- result.Verification = map[string]any{"status": "verified_absent", "verifiedCount": len(ids)}
- result.Result = map[string]any{"deletedCount": len(ids), "batchCount": len(result.CompletedSteps)}
+ result.Verification = map[string]any{
+ "status": "verified_absent", "verifiedCount": len(existingIDs),
+ "missingBeforeWriteCount": len(missingIDs),
+ }
+ result.Result = map[string]any{
+ "deletedCount": len(existingIDs), "missingCount": len(missingIDs),
+ "batchCount": len(result.CompletedSteps),
+ }
return rt.Output(result)
}
+func queryExistingRecordIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]string, error) {
+ records, err := queryDeletedRecordsByIDs(rt, baseID, tableID, ids)
+ if err != nil {
+ return nil, err
+ }
+ wanted := make(map[string]bool, len(ids))
+ for _, id := range ids {
+ wanted[id] = true
+ }
+ present := make(map[string]bool, len(records))
+ for index, record := range records {
+ id := recordID(record)
+ if id == "" {
+ return nil, fmt.Errorf("delete preflight record %d is missing recordId", index)
+ }
+ if !wanted[id] {
+ return nil, fmt.Errorf("delete preflight returned unexpected recordId %q", id)
+ }
+ if present[id] {
+ return nil, fmt.Errorf("delete preflight returned duplicate recordId %q", id)
+ }
+ present[id] = true
+ }
+ existing := make([]string, 0, len(present))
+ for _, id := range ids {
+ if present[id] {
+ existing = append(existing, id)
+ }
+ }
+ return existing, nil
+}
+
func parseRecordIDs(values []string) ([]string, error) {
seen := map[string]bool{}
ids := make([]string, 0, len(values))
diff --git a/internal/shortcut/aitable/record_coverage_gap_test.go b/internal/shortcut/aitable/record_coverage_gap_test.go
index 00d89975..253e2e90 100644
--- a/internal/shortcut/aitable/record_coverage_gap_test.go
+++ b/internal/shortcut/aitable/record_coverage_gap_test.go
@@ -4,11 +4,14 @@
package aitable
import (
+ "context"
"errors"
"fmt"
"strconv"
"strings"
"testing"
+
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) {
@@ -90,6 +93,137 @@ func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T)
}
}
+func TestCrossPlatformCoverageRecordPrimaryDocPreflightAndNormalizationE2E(t *testing.T) {
+ t.Run("record not found is classified", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
+ {text: `{"records":[]}`},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "missing")
+ var typed *apperrors.Error
+ if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "RESOURCE_NOT_FOUND" {
+ t.Fatalf("missing primary-doc record = output:%q err:%#v", out, err)
+ }
+ })
+
+ t.Run("table without primary doc field is normalized", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"fields":[{"fieldId":"text","type":"text"}]}`},
+ {text: `{"records":[{"recordId":"r1","cells":{"text":"x"}}]}`},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
+ if err != nil || !strings.Contains(out, `"status": "no_primary_doc_field"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 2 {
+ t.Fatalf("no primary field = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("empty primary doc cell is unassociated", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
+ {text: `{"records":[{"recordId":"r1","cells":{}}]}`},
+ {text: `{"data":{"nodeId":null}}`},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
+ if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
+ t.Fatalf("unassociated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("known helper no-record error is unassociated", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
+ {text: `{"records":[{"recordId":"r1","cells":{}}]}`},
+ {err: errors.New("no record")},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
+ if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 {
+ t.Fatalf("no-record primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("associated doc is resolved through helper", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
+ {text: `{"records":[{"recordId":"r1","cells":{"primary":{"associated":true}}}]}`},
+ {text: `{"data":{"nodeId":"node-1"}}`},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
+ if err != nil || !strings.Contains(out, `"nodeId": "node-1"`) || !strings.Contains(out, `"exists": true`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
+ t.Fatalf("associated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageRecordPrimaryDocFailureAndShapeEdgesE2E(t *testing.T) {
+ cases := []struct {
+ name string
+ steps []upsertByKeyStep
+ }{
+ {name: "fields transport", steps: []upsertByKeyStep{{err: context.Canceled}}},
+ {name: "missing fields collection", steps: []upsertByKeyStep{{text: `{}`}}},
+ {name: "primary field missing id", steps: []upsertByKeyStep{{text: `{"fields":[{"type":"primaryDoc"}]}`}}},
+ {name: "record query error", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {err: context.DeadlineExceeded}}},
+ {name: "wrong record identity", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {text: `{"records":[{"recordId":"other"}]}`}}},
+ {name: "helper unknown error", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {err: errors.New("permission denied")}}},
+ {name: "helper missing node", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"data":{"message":"ok"}}`}}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
+ if err == nil || out != "" {
+ t.Fatalf("primary doc failure = output:%q err:%v", out, err)
+ }
+ })
+ }
+
+ for _, value := range []any{
+ map[string]any{"exists": false},
+ map[string]any{"dentryUuid": ""},
+ map[string]any{"status": "NO_RECORD"},
+ map[string]any{"data": map[string]any{"result": map[string]any{"status": "unassociated"}}},
+ } {
+ if !knownPrimaryDocUnassociatedData(value) {
+ t.Errorf("known unassociated shape not recognized: %#v", value)
+ }
+ }
+ if knownPrimaryDocUnassociatedData([]any{"unrelated"}) || knownPrimaryDocUnassociatedData(nil) {
+ t.Fatal("unrelated shapes must not be unassociated")
+ }
+ for _, value := range []any{
+ map[string]any{"metadata": map[string]any{"exists": false}},
+ map[string]any{"items": []any{map[string]any{"nodeId": nil}}},
+ } {
+ if knownPrimaryDocUnassociatedData(value) {
+ t.Errorf("unrelated nested marker must not be unassociated: %#v", value)
+ }
+ }
+ if got := primaryDocNodeID(map[string]any{"metadata": map[string]any{"nodeId": "wrong"}}); got != "" {
+ t.Fatalf("unrelated nested nodeId = %q, want empty", got)
+ }
+ if got := primaryDocNodeID(map[string]any{"data": map[string]any{"response": map[string]any{"dentryUuid": " node-1 "}}}); got != "node-1" {
+ t.Fatalf("known envelope nodeId = %q, want node-1", got)
+ }
+}
+
+func TestCrossPlatformCoverageRecordDeleteRejectsMalformedPreflightRecordsE2E(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ records string
+ }{
+ {name: "missing id", records: `[{"cells":{}}]`},
+ {name: "unexpected id", records: `[{"recordId":"other"}]`},
+ {name: "duplicate id", records: `[{"recordId":"r1"},{"recordId":"r1"}]`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":` + tc.records + `}`}}}
+ out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
+ if err == nil || out != "" || len(caller.calls) != 1 {
+ t.Fatalf("delete malformed preflight = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+ }
+}
+
func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) {
record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}}
diff --git a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go
index 2304cf6c..653a80b5 100644
--- a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go
+++ b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go
@@ -612,11 +612,18 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
})
t.Run("delete readback follows active continuation to absence", func(t *testing.T) {
- caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
+ written := false
+ verificationPage := 0
+ caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
+ written = true
return `{"deletedCount":1}`, nil
}
- if call == 1 {
+ if !written {
+ return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
+ }
+ verificationPage++
+ if verificationPage == 1 {
if _, exists := args["cursor"]; exists {
t.Fatalf("first deletion readback unexpectedly has cursor: %#v", args)
}
@@ -628,23 +635,30 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
return `{"success":true,"data":{"records":[],"hasMore":false}}`, nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
- if err != nil || out == "" || len(caller.calls) != 3 {
+ if err != nil || out == "" || len(caller.calls) != 4 {
t.Fatalf("delete continued absence output=%q error=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("delete readback follows active continuation to remaining record", func(t *testing.T) {
- caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
+ written := false
+ verificationPage := 0
+ caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
+ written = true
return `{"deletedCount":1}`, nil
}
- if call == 1 {
+ if !written {
+ return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
+ }
+ verificationPage++
+ if verificationPage == 1 {
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
}
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
- if err == nil || out != "" || len(caller.calls) != 3 {
+ if err == nil || out != "" || len(caller.calls) != 4 {
t.Fatalf("delete continued remaining output=%q error=%v calls=%#v", out, err, caller.calls)
}
var typed *apperrors.Error
@@ -654,14 +668,21 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
})
t.Run("delete readback fails closed on empty continuation stall", func(t *testing.T) {
- caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
+ written := false
+ verificationPage := 0
+ caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
+ written = true
return `{"deletedCount":1}`, nil
}
- return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, call), nil
+ if !written {
+ return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
+ }
+ verificationPage++
+ return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, verificationPage), nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
- if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+1 {
+ if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+2 {
t.Fatalf("delete stalled continuation output=%q error=%v calls=%#v", out, err, caller.calls)
}
var typed *apperrors.Error
@@ -678,21 +699,26 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te
records := updateFixtureRecords(0, size, "完成")
caller := &upsertByKeyCaller{}
queryCalls := 0
+ writeSeen := false
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
+ writeSeen = true
return `{"success":true}`, nil
}
queryCalls++
if operation == "delete" {
ids := args["recordIds"].([]string)
- empty := make([]map[string]any, len(ids))
- response := pagedRecordQueryResponse(t, empty, args)
+ requested := make([]map[string]any, 0, len(ids))
+ if !writeSeen {
+ for _, id := range ids {
+ requested = append(requested, map[string]any{"recordId": id})
+ }
+ }
+ response := pagedRecordQueryResponse(t, requested, args)
var payload map[string]any
if err := json.Unmarshal([]byte(response), &payload); err != nil {
t.Fatal(err)
}
- page := payload["data"].(map[string]any)
- page["records"] = []any{}
raw, _ := json.Marshal(payload)
return string(raw), nil
}
@@ -725,6 +751,9 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te
t.Fatalf("%s size %d false negative: output=%q err=%v", operation, size, out, err)
}
wantQueries := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
+ if operation == "delete" {
+ wantQueries *= 2
+ }
if queryCalls != wantQueries {
t.Fatalf("%s size %d query calls=%d, want %d", operation, size, queryCalls, wantQueries)
}
@@ -860,15 +889,20 @@ func recordIDFixtures(count int) []string {
func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testing.T) {
ids := recordIDFixtures(101)
- caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
- {text: `{"deletedCount":100}`},
- {text: `{"data":{"records":[]}}`},
- {text: `{"data":{"records":[]}}`},
- {text: `{"data":{"records":[]}}`},
- {text: `{"data":{"records":[]}}`},
- {text: `{"data":{"records":[]}}`},
- {text: `{"deletedCount":1}`},
- {text: `{"data":{"records":[]}}`},
+ writes := 0
+ caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
+ if tool == "delete_records" {
+ writes++
+ return fmt.Sprintf(`{"deletedCount":%d}`, len(args["recordIds"].([]string))), nil
+ }
+ requested := args["recordIds"].([]string)
+ records := make([]map[string]any, 0, len(requested))
+ if writes == 0 {
+ for _, id := range requested {
+ records = append(records, map[string]any{"recordId": id})
+ }
+ }
+ return recordListJSON(t, records), nil
}}
out, err := runRecordDeleteCLI(t, caller, ids)
if err != nil {
@@ -879,14 +913,43 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
t.Fatalf("delete output missing %s: %s", want, out)
}
}
- if len(caller.calls) != 8 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" || caller.calls[6].tool != "delete_records" {
+ if len(caller.calls) != 14 || caller.calls[0].tool != "query_records" || caller.calls[6].tool != "delete_records" || caller.calls[12].tool != "delete_records" {
t.Fatalf("delete call sequence = %#v", caller.calls)
}
}
+func TestCrossPlatformCoverageRecordDeleteDoesNotCountMissingIDsE2E(t *testing.T) {
+ t.Run("all requested IDs were already absent", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"success":true,"data":{"records":[],"hasMore":false}}`}}}
+ out, err := runRecordDeleteCLI(t, caller, []string{"missing-1", "missing-2"})
+ if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "query_records" ||
+ !strings.Contains(out, `"deletedCount": 0`) || !strings.Contains(out, `"missingCount": 2`) {
+ t.Fatalf("already absent delete = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("only records proven present are counted", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`},
+ {text: `{"deletedCount":1}`},
+ {text: `{"success":true,"data":{"records":[],"hasMore":false}}`},
+ }}
+ out, err := runRecordDeleteCLI(t, caller, []string{"missing", "r1"})
+ if err != nil || !strings.Contains(out, `"deletedCount": 1`) || !strings.Contains(out, `"missingCount": 1`) {
+ t.Fatalf("mixed delete = output:%q err:%v", out, err)
+ }
+ deleted := caller.calls[1].args["recordIds"].([]string)
+ if !slices.Equal(deleted, []string{"r1"}) {
+ t.Fatalf("delete target IDs = %#v", deleted)
+ }
+ })
+}
+
func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t *testing.T) {
t.Run("legal empty collection proves deletion", func(t *testing.T) {
- caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}, {text: `{"records":[]}`}}}
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"records":[{"recordId":"r1"}]}`}, {text: ""}, {text: `{"records":[]}`},
+ }}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err != nil || !strings.Contains(out, `"status": "recovered"`) {
t.Fatalf("delete recovered = output:%q err:%v", out, err)
@@ -895,6 +958,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
t.Run("explicit service success with empty data proves deletion", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"records":[{"recordId":"r1"}]}`},
{text: `{"deletedCount":1}`},
{text: `{"success":true,"status":"success","error":{},"data":{}}`},
}}
@@ -905,7 +969,9 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
})
t.Run("missing records contract is unknown", func(t *testing.T) {
- caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}}
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"deletedCount":1}`}, {text: `{"data":{}}`},
+ }}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" {
t.Fatalf("delete missing records = output:%q err:%v", out, err)
@@ -918,6 +984,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
t.Run("remaining record is not success", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"records":[{"recordId":"r1"}]}`},
{text: `{"deletedCount":0}`},
{text: `{"records":[{"recordId":"r1","cells":{"fld":"still here"}}]}`},
}}
@@ -930,15 +997,22 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) {
ids := recordIDFixtures(101)
- caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
- {text: `{"deletedCount":100}`},
- {text: `{"records":[]}`},
- {text: `{"records":[]}`},
- {text: `{"records":[]}`},
- {text: `{"records":[]}`},
- {text: `{"records":[]}`},
- {text: `{"deletedCount":0}`},
- {text: `{"records":[{"recordId":"r100","cells":{}}]}`},
+ writes := 0
+ caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
+ if tool == "delete_records" {
+ writes++
+ return `{"deletedCount":0}`, nil
+ }
+ requested := args["recordIds"].([]string)
+ records := make([]map[string]any, 0, len(requested))
+ if writes == 0 {
+ for _, id := range requested {
+ records = append(records, map[string]any{"recordId": id})
+ }
+ } else if writes == 2 {
+ records = append(records, map[string]any{"recordId": "r100", "cells": map[string]any{}})
+ }
+ return recordListJSON(t, records), nil
}}
out, err := runRecordDeleteCLI(t, caller, ids)
if err == nil || out != "" {
diff --git a/internal/shortcut/aitable/remaining_coverage_gap_test.go b/internal/shortcut/aitable/remaining_coverage_gap_test.go
index 2b834f68..3f95b40e 100644
--- a/internal/shortcut/aitable/remaining_coverage_gap_test.go
+++ b/internal/shortcut/aitable/remaining_coverage_gap_test.go
@@ -77,6 +77,10 @@ func TestCrossPlatformCoverageAITableProjectionValidAndMissingIdentityShapes(t *
if err != nil || len(workflows) != 1 {
t.Fatalf("workflow projection = %#v, %v", workflows, err)
}
+ workflows, err = workflowListProject(map[string]any{"list": []any{map[string]any{"flowId": "flow", "name": "real service shape"}}})
+ if err != nil || len(workflows) != 1 || workflows[0]["workflowId"] != "flow" {
+ t.Fatalf("workflow flowId projection = %#v, %v", workflows, err)
+ }
}
func TestCrossPlatformCoverageCompositeAndReviewedContractFallbacks(t *testing.T) {
@@ -183,6 +187,7 @@ func TestCrossPlatformCoverageViewPresetValidationAndDryRunE2E(t *testing.T) {
}
func TestCrossPlatformCoverageViewPresetReadBackFailuresAndUpdateE2E(t *testing.T) {
+ disableViewPresetSleep(t)
preflight := `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":0}}]}`
cases := []struct {
name string
@@ -237,6 +242,40 @@ func TestCrossPlatformCoverageViewPresetPureMatching(t *testing.T) {
}
}
+func TestCrossPlatformCoverageViewPresetProjectionShapeEdges(t *testing.T) {
+ if !presetViewMatches(map[string]any{"viewType": "Grid", "f": 1}, "Grid", map[string]any{"f": 1}) {
+ t.Fatal("top-level projected config must match")
+ }
+ invalid := []map[string]any{
+ {"columns": "bad", "custom": map[string]any{}},
+ {"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{"f": "bad"}}},
+ {"columns": []any{1}, "custom": map[string]any{"hiddenFields": map[string]any{"1": false}}},
+ {"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{}}},
+ {"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{}}},
+ {"columns": []any{1}, "custom": map[string]any{"hiddenFields": []any{false}}},
+ {"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{"bad"}}},
+ }
+ for _, view := range invalid {
+ if got, ok := projectedVisibleFieldIDs(view); ok || got != nil {
+ t.Errorf("invalid projection %#v = %#v, %v", view, got, ok)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageViewPresetReadBackDuplicateStopsE2E(t *testing.T) {
+ disableViewPresetSleep(t)
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"views":[]}`},
+ {text: `{"viewId":"v1"}`},
+ {text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
+ "--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
+ if err == nil || out != "" || len(caller.calls) != 3 {
+ t.Fatalf("duplicate readback = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+}
+
func TestCrossPlatformCoverageWorkflowDeployValidationAndFailureStagesE2E(t *testing.T) {
cases := []struct {
name string
diff --git a/internal/shortcut/aitable/view_preset.go b/internal/shortcut/aitable/view_preset.go
index 6a16aebc..66ea741f 100644
--- a/internal/shortcut/aitable/view_preset.go
+++ b/internal/shortcut/aitable/view_preset.go
@@ -7,12 +7,17 @@ import (
"fmt"
"reflect"
"strings"
+ "time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
+const viewPresetReadbackAttempts = 8
+
+var viewPresetSleep = time.Sleep
+
var ViewPresetApply = shortcut.Shortcut{
Service: "aitable",
Command: "+view-preset-apply",
@@ -100,23 +105,39 @@ func executeViewPresetApply(rt *shortcut.RuntimeContext) error {
if action == "create" {
viewID = findStringByKeys(writeData, "viewId")
}
- readBack, verifyErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
- verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
- if verifyErr == nil && !found {
- verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
- }
- verifiedMatches := viewsByExactName(verifiedViews, name)
- if verifyErr == nil && len(verifiedMatches) != 1 {
- verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
- }
- if verifyErr == nil {
- actualID := stringValue(verifiedMatches[0], "viewId", "id")
- if actualID == "" || (viewID != "" && actualID != viewID) {
- verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
- } else if !presetViewMatches(verifiedMatches[0], viewType, config) {
- verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
- } else {
- viewID = actualID
+ var verifiedMatches []map[string]any
+ var verifyErr error
+ for attempt := 0; attempt < viewPresetReadbackAttempts; attempt++ {
+ if attempt > 0 {
+ backoff := time.Duration(1<<(attempt-1)) * time.Second
+ if backoff > 12*time.Second {
+ backoff = 12 * time.Second
+ }
+ viewPresetSleep(backoff)
+ }
+ readBack, readErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
+ verifyErr = readErr
+ verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
+ if verifyErr == nil && !found {
+ verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
+ }
+ verifiedMatches = viewsByExactName(verifiedViews, name)
+ if verifyErr == nil && len(verifiedMatches) != 1 {
+ verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
+ if len(verifiedMatches) > 1 {
+ break
+ }
+ }
+ if verifyErr == nil {
+ actualID := stringValue(verifiedMatches[0], "viewId", "id")
+ if actualID == "" || (viewID != "" && actualID != viewID) {
+ verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
+ } else if !presetViewMatches(verifiedMatches[0], viewType, config) {
+ verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
+ } else {
+ viewID = actualID
+ break
+ }
}
}
if verifyErr != nil {
@@ -153,8 +174,71 @@ func presetViewMatches(view map[string]any, viewType string, config map[string]a
if actualType != "" && actualType != viewType {
return false
}
- actualConfig, ok := view["config"].(map[string]any)
- return ok && mapContains(actualConfig, config)
+ actualConfig := make(map[string]any, len(config))
+ if nested, ok := view["config"].(map[string]any); ok {
+ for key, value := range nested {
+ actualConfig[key] = value
+ }
+ }
+ for key := range config {
+ if value, ok := view[key]; ok {
+ actualConfig[key] = value
+ }
+ }
+ if _, wanted := config["visibleFieldIds"]; wanted {
+ if visible, ok := projectedVisibleFieldIDs(view); ok {
+ actualConfig["visibleFieldIds"] = visible
+ }
+ }
+ return mapContains(actualConfig, config)
+}
+
+// get_views projects visible fields as columns plus hiddenFields instead of
+// echoing create_view's config.visibleFieldIds input. Different deployments
+// return hiddenFields either as a fieldId-keyed object or a parallel array.
+func projectedVisibleFieldIDs(view map[string]any) ([]any, bool) {
+ columns, columnsOK := view["columns"].([]any)
+ custom, customOK := view["custom"].(map[string]any)
+ if !columnsOK || !customOK {
+ return nil, false
+ }
+ if hidden, ok := custom["hiddenFields"].(map[string]any); ok {
+ visible := make([]any, 0, len(columns))
+ for _, column := range columns {
+ fieldID, fieldOK := column.(string)
+ if !fieldOK {
+ return nil, false
+ }
+ isHidden, exists := hidden[fieldID]
+ if !exists {
+ return nil, false
+ }
+ hiddenFlag, hiddenTypeOK := isHidden.(bool)
+ if !hiddenTypeOK {
+ return nil, false
+ }
+ if !hiddenFlag {
+ visible = append(visible, fieldID)
+ }
+ }
+ return visible, true
+ }
+ hidden, hiddenOK := custom["hiddenFields"].([]any)
+ if !hiddenOK || len(columns) != len(hidden) {
+ return nil, false
+ }
+ visible := make([]any, 0, len(columns))
+ for index, column := range columns {
+ fieldID, fieldOK := column.(string)
+ isHidden, hiddenTypeOK := hidden[index].(bool)
+ if !fieldOK || !hiddenTypeOK {
+ return nil, false
+ }
+ if !isHidden {
+ visible = append(visible, fieldID)
+ }
+ }
+ return visible, true
}
func mapContains(actual, expected map[string]any) bool {
diff --git a/internal/shortcut/aitable/view_workflow_composite_e2e_test.go b/internal/shortcut/aitable/view_workflow_composite_e2e_test.go
index ee437148..450964cd 100644
--- a/internal/shortcut/aitable/view_workflow_composite_e2e_test.go
+++ b/internal/shortcut/aitable/view_workflow_composite_e2e_test.go
@@ -7,13 +7,21 @@ import (
"errors"
"strings"
"testing"
+ "time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
const workflowDSLFixture = `{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}`
+func disableViewPresetSleep(t *testing.T) {
+ t.Helper()
+ testseam.Swap(t, &viewPresetSleep, func(time.Duration) {})
+}
+
func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testing.T) {
+ disableViewPresetSleep(t)
t.Run("create and verify exact config", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`},
@@ -30,6 +38,32 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin
}
})
+ t.Run("projected columns and hidden flags verify visible field config", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"views":[]}`},
+ {text: `{"data":{"viewId":"v1"}}`},
+ {text: `{"views":[{"viewId":"v1","viewName":"投影","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":[false,true,false]}}]}`},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
+ "--base-id", "base", "--table-id", "table", "--name", "投影", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1","f3"]}`, "--yes")
+ if err != nil || !strings.Contains(out, `"status": "verified"`) {
+ t.Fatalf("projected view preset = output:%q err:%v", out, err)
+ }
+ })
+
+ t.Run("projected columns and keyed hidden flags verify visible field config", func(t *testing.T) {
+ caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
+ {text: `{"views":[]}`},
+ {text: `{"data":{"viewId":"v1"}}`},
+ {text: `{"views":[{"viewId":"v1","viewName":"投影对象","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":{"f1":true,"f2":false,"f3":false}}}]}`},
+ }}
+ out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
+ "--base-id", "base", "--table-id", "table", "--name", "投影对象", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f2","f3"]}`, "--yes")
+ if err != nil || !strings.Contains(out, `"status": "verified"`) {
+ t.Fatalf("keyed projected view preset = output:%q err:%v", out, err)
+ }
+ })
+
t.Run("unchanged preset makes no write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"待处理","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
@@ -52,7 +86,26 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin
})
}
+func TestCrossPlatformCoverageViewPresetRetriesUntilTerminalStateE2E(t *testing.T) {
+ disableViewPresetSleep(t)
+ steps := []upsertByKeyStep{
+ {text: `{"views":[]}`},
+ {text: `{"data":{"viewId":"v1"}}`},
+ }
+ for attempt := 0; attempt < viewPresetReadbackAttempts-1; attempt++ {
+ steps = append(steps, upsertByKeyStep{text: `{"views":[]}`})
+ }
+ steps = append(steps, upsertByKeyStep{text: `{"views":[{"viewId":"v1","viewName":"X","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`})
+ caller := &upsertByKeyCaller{steps: steps}
+ out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
+ "--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes")
+ if err != nil || !strings.Contains(out, `"status": "verified"`) || len(caller.calls) != viewPresetReadbackAttempts+2 {
+ t.Fatalf("eventual view preset = output:%q err:%v calls:%#v", out, err, caller.calls)
+ }
+}
+
func TestCrossPlatformCoverageViewPresetAmbiguousOrMismatchedIsNotSuccessE2E(t *testing.T) {
+ disableViewPresetSleep(t)
t.Run("duplicate name stops before write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
diff --git a/internal/shortcut/aitabletarget/resolver.go b/internal/shortcut/aitabletarget/resolver.go
index 25baf098..882d4e3f 100644
--- a/internal/shortcut/aitabletarget/resolver.go
+++ b/internal/shortcut/aitabletarget/resolver.go
@@ -219,6 +219,9 @@ func ResolveBaseName(reader Reader, name string, allowFuzzy bool) (Resolution, e
all = append(all, Candidate{ID: id, Name: candidateName})
}
next, hasMore, hasMoreKnown := pagination(data)
+ if hasMoreKnown && !hasMore {
+ return selectCandidate("base", query, dedupe(all), allowFuzzy)
+ }
if next == "" {
if hasMoreKnown && hasMore {
return Resolution{}, incomplete("base", query, all, "search_bases 声明有后续页但没有 cursor")
diff --git a/internal/shortcut/aitabletarget/resolver_test.go b/internal/shortcut/aitabletarget/resolver_test.go
index 8a011a2a..68c989b5 100644
--- a/internal/shortcut/aitabletarget/resolver_test.go
+++ b/internal/shortcut/aitabletarget/resolver_test.go
@@ -125,6 +125,24 @@ func TestCrossPlatformCoverageResolveBaseNameExactPaginationE2E(t *testing.T) {
}
}
+func TestCrossPlatformCoverageResolveBaseStopsOnAuthoritativeTerminalCursor(t *testing.T) {
+ reader := &resolverReader{steps: []resolverStep{
+ {data: map[string]any{"bases": []any{}, "hasMore": true, "nextCursor": "next"}},
+ {data: map[string]any{
+ "bases": []any{map[string]any{"baseId": "b1", "baseName": "项目管理"}},
+ "hasMore": false,
+ "nextCursor": "next",
+ }},
+ }}
+ got, err := ResolveBaseName(reader, "项目管理", false)
+ if err != nil || got.Selected.ID != "b1" {
+ t.Fatalf("terminal cursor resolution = %#v, %v", got, err)
+ }
+ if len(reader.calls) != 2 {
+ t.Fatalf("terminal hasMore=false made %d calls, want 2", len(reader.calls))
+ }
+}
+
func TestCrossPlatformCoverageResolveNameFuzzyIsExplicitAndAmbiguityFails(t *testing.T) {
data := map[string]any{"bases": []any{
map[string]any{"baseId": "b1", "baseName": "项目管理归档"},
diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go
index 067f922c..5d62242c 100644
--- a/internal/shortcut/attendance/attendance.go
+++ b/internal/shortcut/attendance/attendance.go
@@ -26,6 +26,7 @@ package attendance
import (
"encoding/json"
"fmt"
+ "math"
"strconv"
"strings"
"time"
@@ -34,6 +35,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
)
const (
@@ -86,7 +88,7 @@ func flexDateTime(s string) (string, error) {
}
// dateToMillis parses YYYY-MM-DD / datetime; when endOfDay is set a bare date
-// resolves to 23:59:59 (mirrors helper parseDateToTimestamp "end" behaviour).
+// resolves to the final millisecond before the next local calendar day.
func dateToMillis(s string, endOfDay bool) (int64, error) {
s = strings.TrimSpace(s)
if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil {
@@ -94,7 +96,7 @@ func dateToMillis(s string, endOfDay bool) (int64, error) {
}
if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
if endOfDay {
- return t.Add(23*time.Hour + 59*time.Minute + 59*time.Second).UnixMilli(), nil
+ return t.AddDate(0, 0, 1).Add(-time.Millisecond).UnixMilli(), nil
}
return t.UnixMilli(), nil
}
@@ -197,14 +199,43 @@ var CheckResult = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
- {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔,最多 100 人", Required: true},
- {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true},
- {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true},
- {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "分页偏移量,默认 0"},
- {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "分页大小,1-1000,默认 100"},
+ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,最多 100 个,逗号分隔", Required: true},
+ {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true},
+ {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月", Required: true},
+ {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "--offset 不能小于 0,默认 0"},
+ {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "--limit 必须大于 0 且不超过 1000,默认 100"},
+ },
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复,最多 100 个"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 1000"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"offset"}, Description: "--offset 不能小于 0"},
+ },
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if err := attendanceValidateUserIDs(rt.StrSlice("users"), 100); err != nil {
+ return err
+ }
+ if err := attendanceValidateMonthRange(rt.Str("start"), rt.Str("end")); err != nil {
+ return err
+ }
+ if limit := rt.Int("limit"); limit < 1 || limit > 1000 {
+ return fmt.Errorf("--limit 必须在 1 到 1000 之间")
+ }
+ if rt.Int("offset") < 0 {
+ return fmt.Errorf("--offset 不能小于 0")
+ }
+ return nil
},
Tips: []string{`dws attendance +check-result --users userId1,userId2 --start 2026-04-01 --end 2026-04-30 --limit 50`},
Execute: func(rt *shortcut.RuntimeContext) error {
+ limit, offset := rt.Int("limit"), rt.Int("offset")
+ if limit < 1 || limit > 1000 {
+ return fmt.Errorf("--limit 必须在 1 到 1000 之间")
+ }
+ if offset < 0 {
+ return fmt.Errorf("--offset 不能小于 0")
+ }
from, err := dayDateTime(rt.Str("start"))
if err != nil {
return err
@@ -213,15 +244,40 @@ var CheckResult = shortcut.Shortcut{
if err != nil {
return err
}
- return rt.CallMCP("query_check_result", map[string]any{
+ params := map[string]any{
"QueryCheckResultRequest": map[string]any{
"userIds": rt.StrSlice("users"),
"workDateFrom": from,
"workDateTo": to,
- "offset": rt.Int("offset"),
- "limit": rt.Int("limit"),
+ "offset": offset,
+ "limit": limit,
},
- })
+ }
+ data, err := rt.CallMCPData(serverWukong, "query_check_result", params)
+ if err != nil {
+ return err
+ }
+ records, err := responsecheck.RequireObjectCollection(data, serverWukong+"/query_check_result", "result")
+ if err != nil {
+ return err
+ }
+ if err := attendanceValidatePositiveIntegerIDs(records, serverWukong+"/query_check_result", "id"); err != nil {
+ return err
+ }
+ startMillis, _ := dayMillis(rt.Str("start"))
+ endMillis, _ := dateToMillis(rt.Str("end"), true)
+ if err := attendanceValidateUserAndTimeBinding(records, serverWukong+"/query_check_result", rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis); err != nil {
+ return err
+ }
+ complete := len(records) < limit
+ extra := map[string]any{"limit": limit}
+ nextToken := ""
+ if !complete {
+ nextOffset := offset + len(records)
+ extra["nextOffset"] = nextOffset
+ nextToken = strconv.Itoa(nextOffset)
+ }
+ return attendanceOutputCollection(rt, "records", records, complete, extra, true, nextToken)
},
}
@@ -259,9 +315,20 @@ var CheckRecord = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
- {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true},
- {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true},
- {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true},
+ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true},
+ {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true},
+ {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月", Required: true},
+ },
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月"},
+ },
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil {
+ return err
+ }
+ return attendanceValidateMonthRange(rt.Str("start"), rt.Str("end"))
},
Tips: []string{`dws attendance +check-record --users userId1 --start 2026-04-01 --end 2026-04-30`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -273,13 +340,30 @@ var CheckRecord = shortcut.Shortcut{
if err != nil {
return err
}
- return rt.CallMCP("query_check_record", map[string]any{
+ data, err := rt.CallMCPData(serverWukong, "query_check_record", map[string]any{
"QueryCheckRecordRequest": map[string]any{
"userIds": rt.StrSlice("users"),
"checkDateFrom": from,
"checkDateTo": to,
},
})
+ if err != nil {
+ return err
+ }
+ operation := serverWukong + "/query_check_record"
+ items, err := responsecheck.RequireObjectCollection(data, operation, "result")
+ if err != nil {
+ return err
+ }
+ if err := attendanceValidatePositiveIntegerIDs(items, operation, "id"); err != nil {
+ return err
+ }
+ startMillis, _ := dayMillis(rt.Str("start"))
+ endMillis, _ := dateToMillis(rt.Str("end"), true)
+ if err := attendanceValidateUserAndTimeBinding(items, operation, rt.StrSlice("users"), "userId", "userCheckTime", startMillis, endMillis); err != nil {
+ return err
+ }
+ return attendanceOutputCollection(rt, "records", items, true, nil, false, "")
},
}
@@ -319,10 +403,47 @@ var ListApprove = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
- {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true},
- {Name: "types", Type: shortcut.FlagStringSlice, Desc: "审批类型,逗号分隔:overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true},
- {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true},
- {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD", Required: true},
+ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true},
+ {Name: "types", Type: shortcut.FlagStringSlice, Desc: "--types 不能为空,映射后的审批类型不能重复;overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true},
+ {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true},
+ {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,且不能早于 --start", Required: true},
+ },
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"types"}, Description: "--types 不能为空,映射后的审批类型不能重复"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,且不能早于 --start"},
+ },
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil {
+ return err
+ }
+ start, err := dayMillis(rt.Str("start"))
+ if err != nil {
+ return err
+ }
+ end, err := dayMillis(rt.Str("end"))
+ if err != nil {
+ return err
+ }
+ if end < start {
+ return fmt.Errorf("--end 不能早于 --start")
+ }
+ seen := map[int]struct{}{}
+ for _, value := range rt.StrSlice("types") {
+ mapped, ok := approveTypeMapping[strings.ToLower(strings.TrimSpace(value))]
+ if !ok {
+ return fmt.Errorf("无效的审批类型: %s", value)
+ }
+ if _, duplicate := seen[mapped]; duplicate {
+ return fmt.Errorf("审批类型不能重复")
+ }
+ seen[mapped] = struct{}{}
+ }
+ if len(seen) == 0 {
+ return fmt.Errorf("--types 不能为空")
+ }
+ return nil
},
Tips: []string{`dws attendance +list-approve --users userId1 --types overtime,leave --start 2026-04-01 --end 2026-04-30`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -346,14 +467,25 @@ var ListApprove = shortcut.Shortcut{
}
bizTypes = append(bizTypes, bizType)
}
- return rt.CallMCP("query_user_approve", map[string]any{
+ return attendanceCallCollection(rt, serverWukong, "query_user_approve", map[string]any{
"QueryUserApproveRequest": map[string]any{
"userIds": rt.StrSlice("users"),
"bizTypes": bizTypes,
"fromDate": from,
"toDate": to,
},
- })
+ }, "approvals", true, nil, func(items []map[string]any) error {
+ if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/query_user_approve", "id"); err != nil {
+ return err
+ }
+ requestedTypes := make(map[int]struct{}, len(bizTypes))
+ for _, value := range bizTypes {
+ requestedTypes[value] = struct{}{}
+ }
+ startMillis, _ := dayMillis(rt.Str("start"))
+ endMillis, _ := dateToMillis(rt.Str("end"), true)
+ return attendanceValidateApprovalBinding(items, serverWukong+"/query_user_approve", rt.StrSlice("users"), requestedTypes, startMillis, endMillis)
+ }, "result")
},
}
@@ -405,9 +537,11 @@ var GetApproveTemplate = shortcut.Shortcut{
default:
return fmt.Errorf("无效的审批类型: %s,支持: repair-check/补卡、leave/请假、overtime/加班、travel/外出、out/出差,或 REPAIR_CHECK/LEAVE/OVERTIME/TRAVEL/OUT", input)
}
- return rt.CallMCP("query_at_approve_template", map[string]any{
+ return attendanceCallCollection(rt, serverWukong, "query_at_approve_template", map[string]any{
"approveType": approveType,
- })
+ }, "templates", true, nil, func(items []map[string]any) error {
+ return attendanceValidateApproveTemplates(items, serverWukong+"/query_at_approve_template", approveType)
+ }, "result")
},
}
@@ -450,9 +584,31 @@ var GetSchedule = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
- {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true},
- {Name: "start", Type: shortcut.FlagString, Desc: "开始日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true},
- {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true},
+ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true},
+ {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true},
+ {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss,且不能早于 --start", Required: true},
+ },
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss,且不能早于 --start"},
+ },
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil {
+ return err
+ }
+ start, err := dateToMillis(rt.Str("start"), false)
+ if err != nil {
+ return err
+ }
+ end, err := dateToMillis(rt.Str("end"), true)
+ if err != nil {
+ return err
+ }
+ if end < start {
+ return fmt.Errorf("--end 不能早于 --start")
+ }
+ return nil
},
Tips: []string{`dws attendance +get-schedule --users user001,user002 --start 2026-04-01 --end 2026-04-30`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -464,13 +620,18 @@ var GetSchedule = shortcut.Shortcut{
if err != nil {
return err
}
- return rt.CallMCP("getScheduleByRange", map[string]any{
+ return attendanceCallCollection(rt, serverWukong, "getScheduleByRange", map[string]any{
"GetScheduleByRangeRequest": map[string]any{
"userIdList": rt.StrSlice("users"),
"workDateBegin": begin,
"workDateEnd": end,
},
- })
+ }, "schedules", true, nil, func(items []map[string]any) error {
+ if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/getScheduleByRange", "id"); err != nil {
+ return err
+ }
+ return attendanceValidateUserAndTimeBinding(items, serverWukong+"/getScheduleByRange", rt.StrSlice("users"), "userId", "workDate", begin, end)
+ }, "result")
},
}
@@ -560,18 +721,21 @@ var SearchClass = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "班次名称关键字,模糊搜索"},
{Name: "filter-type", Type: shortcut.FlagString, Enum: []string{"ALL", "MINE_OWN"}, Desc: "班次类型:ALL 全部 / MINE_OWN 我负责的"},
- {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"},
- {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 200"},
+ {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"},
+ {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"},
},
- Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`},
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"},
+ },
+ Validate: attendanceValidatePageRequest,
+ Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`},
Execute: func(rt *shortcut.RuntimeContext) error {
- pageQuery := map[string]any{}
- if v := rt.Int("page"); v > 0 {
- pageQuery["pageIndex"] = v
- }
- if v := rt.Int("limit"); v > 0 {
- pageQuery["pageSize"] = v
+ page, limit, err := attendancePageInput(rt)
+ if err != nil {
+ return err
}
+ pageQuery := map[string]any{"pageIndex": page, "pageSize": limit}
shiftParam := map[string]any{}
if v := rt.Str("query"); v != "" {
shiftParam["searchName"] = v
@@ -590,69 +754,64 @@ var SearchClass = shortcut.Shortcut{
if err != nil {
return err
}
- classes := searchClassProject(data)
- return rt.Output(map[string]any{"count": len(classes), "classes": classes})
+ classes, err := searchClassProject(data)
+ if err != nil {
+ return err
+ }
+ complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_class_list", page, limit, len(classes))
+ if err != nil {
+ return err
+ }
+ nextToken := ""
+ if !complete {
+ nextToken = strconv.Itoa(page + 1)
+ }
+ return attendanceOutputCollection(rt, "classes", classes, complete, extra, true, nextToken)
},
}
// searchClassProject reshapes the raw get_class_list response into a clean class
// list ({classId, name, ownerName}) — clean output projection. Both
// the list container and per-item field names are probed defensively across
-// candidate keys, so an unknown/empty shape yields an empty list rather than a
-// crash or fabricated data.
-func searchClassProject(data map[string]any) []map[string]any {
- raw := attendanceResolveList(data, "classList", "shiftList", "list", "items")
+// result.items is the reviewed wire path. Missing/wrong collections and bad
+// rows fail closed instead of becoming an empty list.
+func searchClassProject(data map[string]any) ([]map[string]any, error) {
+ raw, err := responsecheck.RequireObjectCollection(data, serverWukong+"/get_class_list", "result.items")
+ if err != nil {
+ return nil, err
+ }
out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
+ for index, item := range raw {
+ m := item
// get_class_list wraps each shift's identity/label under shiftVO
// (item = {"shiftVO": {id, name, ...}}); unwrap it first, otherwise every
// row projects empty and the whole command silently returns nothing.
- if vo, ok := m["shiftVO"].(map[string]any); ok {
+ if value, present := m["shiftVO"]; present {
+ vo, ok := value.(map[string]any)
+ if !ok || len(vo) == 0 {
+ return nil, responsecheck.Error(serverWukong+"/get_class_list", "malformed_item", fmt.Sprintf("result.items[%d].shiftVO 不是非空对象", index))
+ }
m = vo
}
row := map[string]any{}
- if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok {
- row["classId"] = v
+ v, ok := attendanceFirst(m, "id", "classId", "class_id")
+ identity, validIdentity := attendancePositiveInteger(v)
+ if !ok || !validIdentity {
+ return nil, responsecheck.Error(serverWukong+"/get_class_list", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少班次 ID", index))
}
+ row["classId"] = identity
if v, ok := attendanceFirst(m, "name", "className", "class_name"); ok {
row["name"] = v
}
if v, ok := attendanceFirst(m, "ownerName", "owner_name", "owner"); ok {
row["ownerName"] = v
}
- if len(row) > 0 {
- out = append(out, row)
- }
+ out = append(out, row)
}
- return out
-}
-
-// attendanceResolveList locates the list payload inside a response, tolerating a
-// bare top-level array under a common container key or nesting one level deeper
-// under result/data. extraKeys are probed ahead of the generic containers.
-func attendanceResolveList(data map[string]any, extraKeys ...string) []any {
- keys := append(append([]string{}, extraKeys...), "result", "data", "list", "items")
- for _, key := range keys {
- v, ok := data[key]
- if !ok {
- continue
- }
- if arr, ok := v.([]any); ok {
- return arr
- }
- if inner, ok := v.(map[string]any); ok {
- for _, ik := range append(append([]string{}, extraKeys...), "list", "items", "result", "data") {
- if arr, ok := inner[ik].([]any); ok {
- return arr
- }
- }
- }
+ if err := attendanceValidatePositiveIntegerIDs(out, serverWukong+"/get_class_list", "classId"); err != nil {
+ return nil, err
}
- return []any{}
+ return out, nil
}
// attendanceFirst returns the first present candidate key's value.
@@ -673,14 +832,58 @@ var GetClass = shortcut.Shortcut{
Description: "根据班次 ID 查询班次详情",
Intent: "当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。",
Risk: shortcut.RiskRead,
+ Safety: contract.SafetySpec{
+ Effect: "read", Risk: "low",
+ Confirmation: "not_required", Idempotency: "idempotent",
+ },
+ Contract: corecmd.ContractDecl{
+ Identity: contract.ToolIdentitySpec{
+ ProductID: "attendance",
+ Name: "shortcut_get_class",
+ CanonicalPath: "attendance.shortcut_get_class",
+ CLIPath: "attendance +get-class",
+ PrimaryCLIPath: "attendance +get-class",
+ },
+ Description: "根据班次 ID 查询班次详情",
+ Interface: &contract.InterfaceSpec{
+ Mode: "composite",
+ Availability: "available",
+ Reason: "Reviewed built-in shortcut adapter: the executable CLI owns strict response validation and output projection for this attendance detail operation.",
+ },
+ Selection: contract.SelectionSpec{
+ AgentSummary: "根据班次 ID 查询班次详情",
+ UseWhen: []string{"当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。"},
+ AvoidWhen: []string{"不知道班次 ID 时先用 +search-class;不要用本读取命令代替创建或更新班次。"},
+ Examples: []string{"dws attendance +get-class --class-id 12345"},
+ },
+ },
Flags: []shortcut.Flag{
- {Name: "class-id", Type: shortcut.FlagInt, Desc: "班次 ID", Required: true},
+ {Name: "class-id", Type: shortcut.FlagInt, Desc: "--class-id 必须大于 0,表示班次 ID", Required: true},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"class-id"}, Description: "--class-id 必须大于 0"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if rt.Int("class-id") <= 0 {
+ return fmt.Errorf("--class-id 必须大于 0")
+ }
+ return nil
},
Tips: []string{`dws attendance +get-class --class-id 1170996821`},
Execute: func(rt *shortcut.RuntimeContext) error {
- return rt.CallMCP("get_class_detail", map[string]any{
- "classId": int64(rt.Int("class-id")),
- })
+ requested := int64(rt.Int("class-id"))
+ data, err := rt.CallMCPData(serverWukong, "get_class_detail", map[string]any{"classId": requested})
+ if err != nil {
+ return err
+ }
+ value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/get_class_detail")
+ if err != nil {
+ return err
+ }
+ identityValue, ok := attendanceNestedValue(value, "shiftVO", "id")
+ identity, valid := attendancePositiveInteger(identityValue)
+ if !ok || !valid || identity != requested {
+ return responsecheck.Error(serverWukong+"/get_class_detail", "object_identity_mismatch", "响应 shiftVO.id 与请求 classId 不一致")
+ }
+ return rt.Output(map[string]any{"value": value})
},
}
@@ -784,9 +987,9 @@ var GetAdjustmentRule = shortcut.Shortcut{
},
Description: "根据补卡规则主键 ID 查询补卡规则详情",
Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
+ Mode: contract.InterfaceModeComposite,
+ Availability: contract.InterfaceAvailable,
+ Reason: attendanceCompatibilityInterfaceReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "根据补卡规则主键 ID 查询补卡规则详情",
@@ -800,7 +1003,7 @@ var GetAdjustmentRule = shortcut.Shortcut{
},
Tips: []string{`dws attendance +get-adjustment-rule --adjustment-id 12345`},
Execute: func(rt *shortcut.RuntimeContext) error {
- return rt.CallMCP("get_adjustment_rule_detail", map[string]any{
+ return attendanceCallObject(rt, serverWukong, "get_adjustment_rule_detail", map[string]any{
"adjustmentId": int64(rt.Int("adjustment-id")),
})
},
@@ -812,7 +1015,7 @@ var SearchAdjustmentRule = shortcut.Shortcut{
Command: "+search-adjustment-rule",
Product: serverWukong,
Description: "查询当前用户可管理的补卡规则列表",
- Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。",
+ Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则时使用;可选按规则名关键字模糊搜索并分页,返回带稳定候选 ID 的补卡规则列表。当前下游详情接口对搜索得到的 ID 返回空 result,因此本命令不承诺可继续读取详情。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -834,29 +1037,30 @@ var SearchAdjustmentRule = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户可管理的补卡规则列表",
- UseWhen: []string{"当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
+ UseWhen: []string{"当你要浏览或按名称查找当前用户可管理的补卡规则时使用;可选按规则名关键字模糊搜索并分页,返回带稳定候选 ID 的补卡规则列表。当前下游详情接口对搜索得到的 ID 返回空 result,因此本命令不承诺可继续读取详情。"},
+ AvoidWhen: []string{"需要完整补卡规则详情时应报告当前下游详情能力 unavailable,不要把搜索摘要或空 result 当作详情成功"},
Examples: []string{"dws attendance +search-adjustment-rule --query \"标准\" --page 1 --limit 50"},
},
},
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "补卡规则名称关键字,模糊搜索"},
- {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"},
- {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"},
+ {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"},
+ {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"},
},
- Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`},
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"},
+ },
+ Validate: attendanceValidatePageRequest,
+ Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`},
Execute: func(rt *shortcut.RuntimeContext) error {
param := map[string]any{}
if v := rt.Str("query"); v != "" {
param["name"] = v
}
- page := rt.Int("page")
- if page <= 0 {
- page = 1
- }
- limit := rt.Int("limit")
- if limit <= 0 {
- limit = 20
+ page, limit, err := attendancePageInput(rt)
+ if err != nil {
+ return err
}
param["currentPage"] = page
param["pageSize"] = limit
@@ -866,44 +1070,62 @@ var SearchAdjustmentRule = shortcut.Shortcut{
if err != nil {
return err
}
- rules := searchRuleProject(data)
- return rt.Output(map[string]any{"count": len(rules), "rules": rules})
+ rules, err := searchRuleProject(data, serverWukong+"/get_adjustment_rule", "result.adjustmentList")
+ if err != nil {
+ return err
+ }
+ complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_adjustment_rule", page, limit, len(rules))
+ if err != nil {
+ return err
+ }
+ nextToken := ""
+ if !complete {
+ nextToken = strconv.Itoa(page + 1)
+ }
+ return attendanceOutputCollection(rt, "rules", rules, complete, extra, true, nextToken)
},
}
// searchRuleProject reshapes the raw get_adjustment_rule / get_overtime_rule
-// response into a clean rule list ({ruleId, name}) — output-projection fidelity
-// for clean output. The list container and per-item field names are probed defensively
-// across candidate keys, so an unknown/empty shape yields an empty list rather
-// than a crash or fabricated data.
-func searchRuleProject(data map[string]any) []map[string]any {
+// response into a clean rule list ({ruleId, name}). Each caller supplies the
+// single reviewed collection path; missing/wrong collections and malformed
+// rows fail closed instead of becoming an empty list.
+func searchRuleProject(data map[string]any, operation string, paths ...string) ([]map[string]any, error) {
// get_overtime_rule nests the list under result.atRuleList and
// get_adjustment_rule under result.adjustmentList; both wrap each rule's
// identity/label under entityVO. Probe those container keys AND unwrap
// entityVO, otherwise +search-overtime-rule / +search-adjustment-rule
// silently return empty despite the backend returning rules.
- raw := attendanceResolveList(data, "atRuleList", "adjustmentList", "ruleList", "rules", "list", "items")
+ raw, err := responsecheck.RequireObjectCollection(data, operation, paths...)
+ if err != nil {
+ return nil, err
+ }
out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- if vo, ok := m["entityVO"].(map[string]any); ok {
+ for index, item := range raw {
+ m := item
+ if value, present := m["entityVO"]; present {
+ vo, ok := value.(map[string]any)
+ if !ok || len(vo) == 0 {
+ return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("规则结果第 %d 项 entityVO 不是非空对象", index))
+ }
m = vo
}
row := map[string]any{}
- if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok {
- row["ruleId"] = v
+ v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId")
+ identity, validIdentity := attendancePositiveInteger(v)
+ if !ok || !validIdentity {
+ return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("规则结果第 %d 项缺少规则 ID", index))
}
+ row["ruleId"] = identity
if v, ok := attendanceFirst(m, "name", "ruleName", "rule_name"); ok {
row["name"] = v
}
- if len(row) > 0 {
- out = append(out, row)
- }
+ out = append(out, row)
}
- return out
+ if err := attendanceValidatePositiveIntegerIDs(out, operation, "ruleId"); err != nil {
+ return nil, err
+ }
+ return out, nil
}
// ── overtime rule ───────────────────────────────────────────
@@ -942,13 +1164,31 @@ var GetOvertimeRule = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
- {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "加班规则主键 ID", Required: true},
+ {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "--overtime-id 必须大于 0,表示加班规则主键 ID", Required: true},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"overtime-id"}, Description: "--overtime-id 必须大于 0"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if rt.Int("overtime-id") <= 0 {
+ return fmt.Errorf("--overtime-id 必须大于 0")
+ }
+ return nil
},
Tips: []string{`dws attendance +get-overtime-rule --overtime-id 12345`},
Execute: func(rt *shortcut.RuntimeContext) error {
- return rt.CallMCP("get_overtime_rule_detail", map[string]any{
- "overtimeId": int64(rt.Int("overtime-id")),
- })
+ requested := int64(rt.Int("overtime-id"))
+ data, err := rt.CallMCPData(serverWukong, "get_overtime_rule_detail", map[string]any{"overtimeId": requested})
+ if err != nil {
+ return err
+ }
+ value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/get_overtime_rule_detail")
+ if err != nil {
+ return err
+ }
+ identity, valid := attendancePositiveInteger(value["id"])
+ if !valid || identity != requested {
+ return responsecheck.Error(serverWukong+"/get_overtime_rule_detail", "object_identity_mismatch", "响应 id 与请求 overtimeId 不一致")
+ }
+ return rt.Output(map[string]any{"value": value})
},
}
@@ -987,22 +1227,23 @@ var SearchOvertimeRule = shortcut.Shortcut{
},
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "加班规则名称关键字,模糊搜索"},
- {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"},
- {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"},
+ {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"},
+ {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"},
},
- Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`},
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"},
+ },
+ Validate: attendanceValidatePageRequest,
+ Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`},
Execute: func(rt *shortcut.RuntimeContext) error {
param := map[string]any{}
if v := rt.Str("query"); v != "" {
param["name"] = v
}
- page := rt.Int("page")
- if page <= 0 {
- page = 1
- }
- limit := rt.Int("limit")
- if limit <= 0 {
- limit = 20
+ page, limit, err := attendancePageInput(rt)
+ if err != nil {
+ return err
}
param["currentPage"] = page
param["pageSize"] = limit
@@ -1012,8 +1253,19 @@ var SearchOvertimeRule = shortcut.Shortcut{
if err != nil {
return err
}
- rules := searchRuleProject(data)
- return rt.Output(map[string]any{"count": len(rules), "rules": rules})
+ rules, err := searchRuleProject(data, serverWukong+"/get_overtime_rule", "result.atRuleList")
+ if err != nil {
+ return err
+ }
+ complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_overtime_rule", page, limit, len(rules))
+ if err != nil {
+ return err
+ }
+ nextToken := ""
+ if !complete {
+ nextToken = strconv.Itoa(page + 1)
+ }
+ return attendanceOutputCollection(rt, "rules", rules, complete, extra, true, nextToken)
},
}
@@ -1041,9 +1293,9 @@ var SearchGroup = shortcut.Shortcut{
},
Description: "查询当前用户可管理的考勤组列表",
Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
+ Mode: contract.InterfaceModeComposite,
+ Availability: contract.InterfaceAvailable,
+ Reason: attendanceCompatibilityInterfaceReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "查询当前用户可管理的考勤组列表",
@@ -1079,13 +1331,9 @@ var SearchGroup = shortcut.Shortcut{
searchParam["queryPositionAndWifiNames"] = false
searchParam["queryBleDeviceList"] = false
}
- page := rt.Int("page")
- if page <= 0 {
- page = 1
- }
- limit := rt.Int("limit")
- if limit <= 0 {
- limit = 20
+ page, limit, err := attendancePageInput(rt)
+ if err != nil {
+ return err
}
data, err := rt.CallMCPData(serverWukong, "get_simple_groups", map[string]any{
"param": searchParam,
@@ -1097,39 +1345,44 @@ var SearchGroup = shortcut.Shortcut{
if err != nil {
return err
}
- groups := searchGroupProject(data)
- return rt.Output(map[string]any{"count": len(groups), "groups": groups})
+ groups, err := searchGroupProject(data)
+ if err != nil {
+ return err
+ }
+ complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_simple_groups", page, limit, len(groups))
+ if err != nil {
+ return err
+ }
+ return rt.Output(attendanceCollectionPayload("groups", groups, complete, extra))
},
}
// searchGroupProject reshapes the raw get_simple_groups response into a clean
// attendance-group list ({groupId, name, type}) — clean output projection.
-// The list container and per-item field names are probed defensively
-// across candidate keys, so an unknown/empty shape yields an empty list rather
-// than a crash or fabricated data.
-func searchGroupProject(data map[string]any) []map[string]any {
- raw := attendanceResolveList(data, "groupList", "groups", "list", "items")
+// The exact result.items collection and each row identity are required, so an
+// unknown shape cannot masquerade as a legitimate empty search result.
+func searchGroupProject(data map[string]any) ([]map[string]any, error) {
+ raw, err := responsecheck.RequireObjectCollection(data, serverWukong+"/get_simple_groups", "result.items")
+ if err != nil {
+ return nil, err
+ }
out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
+ for index, m := range raw {
row := map[string]any{}
- if v, ok := attendanceFirst(m, "id", "groupId", "group_id"); ok {
- row["groupId"] = v
+ v, ok := attendanceFirst(m, "id", "groupId", "group_id")
+ if !ok || v == nil {
+ return nil, responsecheck.Error(serverWukong+"/get_simple_groups", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少考勤组 ID", index))
}
+ row["groupId"] = v
if v, ok := attendanceFirst(m, "name", "groupName", "group_name"); ok {
row["name"] = v
}
if v, ok := attendanceFirst(m, "type", "groupType", "group_type"); ok {
row["type"] = v
}
- if len(row) > 0 {
- out = append(out, row)
- }
+ out = append(out, row)
}
- return out
+ return out, nil
}
// GetGroup 根据考勤组 ID 查询考勤组全量信息(get_group_detail)。
@@ -1379,7 +1632,7 @@ var GetSummary = shortcut.Shortcut{
if err != nil {
return err
}
- return rt.CallMCP("get_user_attendance_summary", map[string]any{
+ return attendanceCallObject(rt, serverWukong, "get_user_attendance_summary", map[string]any{
"userId": rt.Str("user"),
"queryDate": queryDate,
"statsType": rt.Str("stats-type"),
@@ -1427,19 +1680,80 @@ var GetSelfSetting = shortcut.Shortcut{
},
Flags: []shortcut.Flag{
{Name: "setting-scene", Type: shortcut.FlagString, Enum: []string{"checkRemind", "fastCheck", "checkResultNotify", "lackRemind", "personalAttendStatNotify", "bossAttendStatNotify"}, Desc: "查询设置项场景", Required: true},
- {Name: "user", Type: shortcut.FlagString, Desc: "查询用户 userId", Required: true},
+ {Name: "user", Type: shortcut.FlagString, Desc: "--user 不能为空,表示查询用户 userId", Required: true},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"user"}, Description: "--user 不能为空"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if rt.Str("user") == "" {
+ return fmt.Errorf("--user 不能为空")
+ }
+ return nil
},
Tips: []string{`dws attendance +get-self-setting --setting-scene checkRemind --user USER_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
- return rt.CallMCP("query_self_setting", map[string]any{
+ userID := rt.Str("user")
+ scene := rt.Str("setting-scene")
+ data, err := rt.CallMCPData(serverWukong, "query_self_setting", map[string]any{
"RuleMcpQuerySelfSettingRequest": map[string]any{
- "settingScene": rt.Str("setting-scene"),
- "userId": rt.Str("user"),
+ "settingScene": scene,
+ "userId": userID,
},
})
+ if err != nil {
+ return err
+ }
+ value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/query_self_setting")
+ if err != nil {
+ return err
+ }
+ if err := attendanceValidateSelfSetting(value, userID, scene); err != nil {
+ return err
+ }
+ return rt.Output(map[string]any{"value": value})
},
}
+func attendanceValidateSelfSetting(value map[string]any, requestedUser, scene string) error {
+ operation := serverWukong + "/query_self_setting"
+ requestedUser = strings.TrimSpace(requestedUser)
+ if requestedUser == "" {
+ return responsecheck.Error(operation, "object_identity_mismatch", "请求 userId 不能为空")
+ }
+ returnedUser, ok := value["userId"].(string)
+ if !ok || returnedUser == "" || returnedUser != requestedUser {
+ return responsecheck.Error(operation, "object_identity_mismatch", "响应 userId 与请求用户不一致")
+ }
+ type sceneContract struct {
+ field string
+ valid func(any) bool
+ }
+ contracts := map[string]sceneContract{
+ "checkRemind": {field: "checkRemindSetting", valid: func(v any) bool { _, ok := v.(map[string]any); return ok }},
+ "fastCheck": {field: "fastCheckLateNeedConfirm", valid: func(v any) bool { _, ok := v.(bool); return ok }},
+ "checkResultNotify": {field: "checkResultMsg", valid: attendanceSettingInteger},
+ "lackRemind": {field: "lackRemindUser", valid: attendanceSettingInteger},
+ "personalAttendStatNotify": {field: "personDailyReportSwitch", valid: attendanceSettingInteger},
+ "bossAttendStatNotify": {field: "bossMonthReportType", valid: attendanceSettingInteger},
+ }
+ expected, ok := contracts[scene]
+ if !ok {
+ return fmt.Errorf("无效的 --setting-scene: %s", scene)
+ }
+ setting, present := value[expected.field]
+ if !present || setting == nil {
+ return responsecheck.Error(operation, "setting_scene_mismatch", "响应缺少请求场景对应的非空设置字段")
+ }
+ if !expected.valid(setting) {
+ return responsecheck.Error(operation, "malformed_setting", "响应中请求场景的设置字段类型不正确")
+ }
+ return nil
+}
+
+func attendanceSettingInteger(value any) bool {
+ number, ok := value.(float64)
+ return ok && !math.IsNaN(number) && !math.IsInf(number, 0) && math.Trunc(number) == number
+}
+
// ── global setting ──────────────────────────────────────────
// GetGlobalSetting 查询全局规则设置(query_global_setting,仅管理员)。
@@ -1502,9 +1816,9 @@ var QueryReportData = shortcut.Shortcut{
},
Description: "根据字段查询考勤报表数据(仅管理员)",
Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
+ Mode: contract.InterfaceModeComposite,
+ Availability: contract.InterfaceAvailable,
+ Reason: attendanceCompatibilityInterfaceReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "根据字段查询考勤报表数据(仅管理员)",
@@ -1541,7 +1855,7 @@ var QueryReportData = shortcut.Shortcut{
}
columnIds = append(columnIds, id)
}
- return rt.CallMCP("get_report_columns_value", map[string]any{
+ return attendanceCallValue(rt, serverWukong, "get_report_columns_value", map[string]any{
"McpQueryParam": map[string]any{
"targetUserIds": rt.StrSlice("users"),
"columnIds": columnIds,
@@ -1624,9 +1938,11 @@ var ListLeaveTypes = shortcut.Shortcut{
},
Tips: []string{`dws attendance +list-leave-types`},
Execute: func(rt *shortcut.RuntimeContext) error {
- return rt.CallMCP("get_leave_types", map[string]any{
+ return attendanceCallCollection(rt, serverWukong, "get_leave_types", map[string]any{
"McpLeaveTypeRequest": map[string]any{},
- })
+ }, "leaveTypes", true, nil, func(items []map[string]any) error {
+ return attendanceValidateExpectedStrings(items, serverWukong+"/get_leave_types", "leaveCode", "")
+ }, "result")
},
}
@@ -1695,6 +2011,27 @@ var GetLeaveRecords = shortcut.Shortcut{
{Name: "start", Type: shortcut.FlagString, Desc: "查询开始日期 YYYY-MM-DD", Required: true},
{Name: "end", Type: shortcut.FlagString, Desc: "查询结束日期 YYYY-MM-DD", Required: true},
},
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"user"}, Description: "--user 去空白后必须非空"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 YYYY-MM-DD 且 end 不早于 start"},
+ },
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if rt.Str("user") == "" {
+ return fmt.Errorf("--user 不能为空")
+ }
+ start, err := dayMillis(rt.Str("start"))
+ if err != nil {
+ return err
+ }
+ end, err := dayMillis(rt.Str("end"))
+ if err != nil {
+ return err
+ }
+ if end < start {
+ return fmt.Errorf("--end 不能早于 --start")
+ }
+ return nil
+ },
Tips: []string{`dws attendance +get-leave-records --user USER_ID --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --start 2026-04-01 --end 2026-04-22`},
Execute: func(rt *shortcut.RuntimeContext) error {
start, err := dayMillis(rt.Str("start"))
@@ -1713,9 +2050,9 @@ var GetLeaveRecords = shortcut.Shortcut{
if v := rt.Str("leave-code"); v != "" {
req["leaveCode"] = v
}
- return rt.CallMCP("get_leave_balance_records_v2", map[string]any{
+ return attendanceCallCollection(rt, serverWukong, "get_leave_balance_records_v2", map[string]any{
"McpLeaveRecordRequest": req,
- })
+ }, "records", true, nil, nil, "result")
},
}
@@ -1879,6 +2216,33 @@ var GetCheckinRecord = shortcut.Shortcut{
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 yyyy-MM-dd HH:mm:ss", Required: true},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 yyyy-MM-dd HH:mm:ss,跨度最多 7 天", Required: true},
},
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"staff-ids"}, Description: "--staff-ids 去空白后必须为 1..100 个且不能重复"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 yyyy-MM-dd HH:mm:ss,end 不早于 start,且跨度不超过 7 天"},
+ },
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if err := attendanceValidateUserIDs(rt.StrSlice("staff-ids"), 100); err != nil {
+ return err
+ }
+ if rt.Str("operator-corp-id") == "" || rt.Str("operator-staff-id") == "" {
+ return fmt.Errorf("--operator-corp-id 与 --operator-staff-id 不能为空")
+ }
+ start, err := time.ParseInLocation("2006-01-02 15:04:05", rt.Str("start"), time.Local)
+ if err != nil {
+ return fmt.Errorf("--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err)
+ }
+ end, err := time.ParseInLocation("2006-01-02 15:04:05", rt.Str("end"), time.Local)
+ if err != nil {
+ return fmt.Errorf("--end 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err)
+ }
+ if end.Before(start) {
+ return fmt.Errorf("--end 不能早于 --start")
+ }
+ if end.Sub(start) > 7*24*time.Hour {
+ return fmt.Errorf("--start 到 --end 的跨度不能超过 7 天")
+ }
+ return nil
+ },
Tips: []string{`dws attendance +get-checkin-record --operator-corp-id dingXXX --operator-staff-id op001 --staff-ids user001,user002 --start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00"`},
Execute: func(rt *shortcut.RuntimeContext) error {
startStr := rt.Str("start")
@@ -1894,7 +2258,7 @@ var GetCheckinRecord = shortcut.Shortcut{
if endT.Before(startT) {
return fmt.Errorf("--end 不能早于 --start")
}
- return rt.CallMCP("get_checkin_record", map[string]any{
+ return attendanceCallCollection(rt, serverWukong, "get_checkin_record", map[string]any{
"QueryMcpUserRecordRequest": map[string]any{
"operatorCorpId": rt.Str("operator-corp-id"),
"operatorStaffId": rt.Str("operator-staff-id"),
@@ -1902,7 +2266,7 @@ var GetCheckinRecord = shortcut.Shortcut{
"startTime": startStr,
"endTime": endStr,
},
- })
+ }, "records", true, nil, nil, "result.list")
},
}
@@ -1954,6 +2318,7 @@ var BossCheck = shortcut.Shortcut{
}
func init() {
+ hardenPublicAttendanceContracts()
shortcut.Register(
CheckResult,
CheckRecord,
diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go
new file mode 100644
index 00000000..53d19d38
--- /dev/null
+++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go
@@ -0,0 +1,1176 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package attendance
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+func attendanceRuntimeForTest(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext {
+ t.Helper()
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ for name, value := range values {
+ if err := cmd.Flags().Set(name, value); err != nil {
+ t.Fatalf("set --%s: %v", name, err)
+ }
+ }
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ return shortcut.RuntimeContextForTest(cmd, declaration)
+}
+
+type attendanceCoverageCaller struct {
+ text string
+ err error
+ calls int
+ tools []string
+ arguments []map[string]any
+}
+
+func (caller *attendanceCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
+ caller.calls++
+ caller.tools = append(caller.tools, tool)
+ caller.arguments = append(caller.arguments, arguments)
+ if caller.err != nil {
+ return nil, caller.err
+ }
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.text}}}, nil
+}
+
+func (*attendanceCoverageCaller) Format() string { return "json" }
+func (*attendanceCoverageCaller) DryRun() bool { return false }
+func (*attendanceCoverageCaller) Fields() string { return "" }
+func (*attendanceCoverageCaller) JQ() string { return "" }
+
+func executeAttendanceResponse(t *testing.T, declaration shortcut.Shortcut, values map[string]string, response string) (*attendanceCoverageCaller, error) {
+ t.Helper()
+ caller := &attendanceCoverageCaller{text: response}
+ helpers.InitDepsForTest(t, caller)
+ err := declaration.Execute(attendanceRuntimeForTest(t, declaration, values))
+ return caller, err
+}
+
+func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) {
+ userCases := []struct {
+ name string
+ items []map[string]any
+ valid bool
+ }{
+ {"valid", []map[string]any{{"userId": "u1", "workDate": float64(1000)}}, true},
+ {"missing user", []map[string]any{{"workDate": float64(1000)}}, false},
+ {"wrong user type", []map[string]any{{"userId": 1, "workDate": float64(1000)}}, false},
+ {"blank user", []map[string]any{{"userId": " ", "workDate": float64(1000)}}, false},
+ {"wrong user", []map[string]any{{"userId": "u2", "workDate": float64(1000)}}, false},
+ {"missing time", []map[string]any{{"userId": "u1"}}, false},
+ {"wrong time type", []map[string]any{{"userId": "u1", "workDate": "1000"}}, false},
+ {"before range", []map[string]any{{"userId": "u1", "workDate": float64(899)}}, false},
+ {"after range", []map[string]any{{"userId": "u1", "workDate": float64(1101)}}, false},
+ }
+ for _, tc := range userCases {
+ err := attendanceValidateUserAndTimeBinding(tc.items, "attendance/test", []string{"u1"}, "userId", "workDate", 900, 1100)
+ if (err == nil) != tc.valid {
+ t.Errorf("user/time %s: err=%v valid=%t", tc.name, err, tc.valid)
+ }
+ }
+
+ approvalCases := []struct {
+ name string
+ item map[string]any
+ valid bool
+ }{
+ {"valid", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, true},
+ {"missing user", map[string]any{"bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, false},
+ {"wrong user", map[string]any{"userId": "u2", "bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, false},
+ {"missing type", map[string]any{"userId": "u1", "beginTime": float64(900), "endTime": float64(1100)}, false},
+ {"wrong type", map[string]any{"userId": "u1", "bizType": float64(4), "beginTime": float64(900), "endTime": float64(1100)}, false},
+ {"invalid range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1001), "endTime": float64(999)}, false},
+ {"before range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(700), "endTime": float64(899)}, false},
+ {"crosses start", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(800), "endTime": float64(900)}, true},
+ {"crosses end", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1100), "endTime": float64(1200)}, true},
+ {"after range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1101), "endTime": float64(1200)}, false},
+ }
+ for _, tc := range approvalCases {
+ err := attendanceValidateApprovalBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, map[int]struct{}{3: {}}, 900, 1100)
+ if (err == nil) != tc.valid {
+ t.Errorf("approval %s: err=%v valid=%t", tc.name, err, tc.valid)
+ }
+ }
+ if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}}, "attendance/test", "approveType", "OVERTIME"); err == nil {
+ t.Fatal("expected string mismatch was accepted")
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceCheckRecordActualTimeBinding(t *testing.T) {
+ const startMillis, endMillis = int64(1000), int64(2000)
+ items := []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}}
+ if err := attendanceValidateUserAndTimeBinding(items, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis); err != nil {
+ t.Fatalf("cross-midnight work date with in-range actual check was rejected: %v", err)
+ }
+
+ invalid := []struct {
+ name string
+ item map[string]any
+ reason string
+ }{
+ {"before range", map[string]any{"userId": "u1", "userCheckTime": startMillis - 1}, "request_range_mismatch"},
+ {"after range", map[string]any{"userId": "u1", "userCheckTime": endMillis + 1}, "request_range_mismatch"},
+ {"missing actual time", map[string]any{"userId": "u1", "workDate": startMillis}, "missing_request_binding"},
+ {"null actual time", map[string]any{"userId": "u1", "userCheckTime": nil}, "malformed_request_binding"},
+ {"wrong actual time type", map[string]any{"userId": "u1", "userCheckTime": "1000"}, "malformed_request_binding"},
+ {"zero actual time", map[string]any{"userId": "u1", "userCheckTime": 0}, "malformed_request_binding"},
+ }
+ for _, tc := range invalid {
+ t.Run(tc.name, func(t *testing.T) {
+ err := attendanceValidateUserAndTimeBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis)
+ typed, ok := err.(*apperrors.Error)
+ reason := ""
+ if ok {
+ reason = typed.Reason
+ }
+ if err == nil || !ok || typed.Reason != tc.reason {
+ t.Fatalf("err=%v reason=%q want=%q", err, reason, tc.reason)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceCheckRecordExecuteBindsActualCheckTime(t *testing.T) {
+ values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}
+ startMillis, _ := dayMillis(values["start"])
+ execute := func(t *testing.T, records []map[string]any) (map[string]any, error) {
+ t.Helper()
+ caller := &attendanceCoverageCaller{text: attendanceResponseJSON(t, map[string]any{"success": true, "result": records})}
+ helpers.InitDepsForTest(t, caller)
+ cmd := corecmd.New(shortcut.FromShortcut(CheckRecord))
+ for name, value := range values {
+ if err := cmd.Flags().Set(name, value); err != nil {
+ t.Fatalf("set --%s: %v", name, err)
+ }
+ }
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(io.Discard)
+ err := CheckRecord.Execute(shortcut.RuntimeContextForTest(cmd, CheckRecord))
+ if err != nil {
+ return nil, err
+ }
+ if caller.calls != 1 {
+ t.Fatalf("calls=%d", caller.calls)
+ }
+ if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 {
+ t.Fatalf("emit code=%d emitted=%t err=%v", code, emitted, err)
+ }
+ var envelope map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode envelope: %v", err)
+ }
+ return envelope, nil
+ }
+
+ t.Run("keeps cross-midnight work date when actual check is in range", func(t *testing.T) {
+ envelope, err := execute(t, []map[string]any{
+ {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis + 1000},
+ {"id": 2, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ data, ok := envelope["data"].(map[string]any)
+ records, recordsOK := data["records"].([]any)
+ if !ok || !recordsOK || data["count"] != float64(2) || len(records) != 2 || records[0].(map[string]any)["id"] != float64(1) || records[1].(map[string]any)["id"] != float64(2) {
+ t.Fatalf("data=%#v", envelope["data"])
+ }
+ })
+
+ t.Run("actual-time overfetch fails closed", func(t *testing.T) {
+ if _, err := execute(t, []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis - 1}}); err == nil {
+ t.Fatal("out-of-range actual check was silently filtered")
+ }
+ })
+
+ invalid := []struct {
+ name string
+ records []map[string]any
+ }{
+ {"missing identity outside range", []map[string]any{{"userId": "u1", "userCheckTime": startMillis - 1}}},
+ {"zero identity outside range", []map[string]any{{"id": 0, "userId": "u1", "userCheckTime": startMillis - 1}}},
+ {"wrong user outside range", []map[string]any{{"id": 1, "userId": "other", "userCheckTime": startMillis - 1}}},
+ {"missing actual time", []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 1}}},
+ {"malformed actual time outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": "bad"}}},
+ {"duplicate identities outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": startMillis - 2}, {"id": 1, "userId": "u1", "userCheckTime": startMillis - 1}}},
+ }
+ for _, tc := range invalid {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := execute(t, tc.records); err == nil {
+ t.Fatal("invalid raw overfetch was hidden")
+ }
+ })
+ }
+
+ t.Run("downstream failure", func(t *testing.T) {
+ caller := &attendanceCoverageCaller{err: fmt.Errorf("backend unavailable")}
+ helpers.InitDepsForTest(t, caller)
+ if err := CheckRecord.Execute(attendanceRuntimeForTest(t, CheckRecord, values)); err == nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+
+ t.Run("missing collection", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, CheckRecord, values, `{"success":true,"result":null}`)
+ if err == nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageAttendanceDetailExecuteFailsClosed(t *testing.T) {
+ detailCases := []struct {
+ name string
+ declaration shortcut.Shortcut
+ flags map[string]string
+ valid string
+ invalid []string
+ }{
+ {
+ name: "class",
+ declaration: GetClass,
+ flags: map[string]string{"class-id": "7"},
+ valid: `{"success":true,"result":{"shiftVO":{"id":7}}}`,
+ invalid: []string{
+ `{"result":{"shiftVO":{"id":7}}}`,
+ `{"success":false,"result":{"shiftVO":{"id":7}}}`,
+ `{"success":true,"result":null}`,
+ `{"success":true,"result":"bad"}`,
+ `{"success":true,"result":{"shiftVO":{"id":8}}}`,
+ },
+ },
+ {
+ name: "overtime",
+ declaration: GetOvertimeRule,
+ flags: map[string]string{"overtime-id": "9"},
+ valid: `{"success":true,"result":[{"id":9}]}`,
+ invalid: []string{
+ `{"result":[{"id":9}]}`,
+ `{"success":false,"result":[{"id":9}]}`,
+ `{"success":true,"result":null}`,
+ `{"success":true,"result":["bad"]}`,
+ `{"success":true,"result":[{"id":10}]}`,
+ },
+ },
+ }
+ for _, detail := range detailCases {
+ t.Run(detail.name+" valid", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, detail.declaration, detail.flags, detail.valid)
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("valid detail err=%v calls=%d", err, caller.calls)
+ }
+ })
+ for index, response := range detail.invalid {
+ t.Run(fmt.Sprintf("%s invalid %d", detail.name, index), func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, detail.declaration, detail.flags, response)
+ if err == nil || caller.calls != 1 {
+ t.Fatalf("invalid detail err=%v calls=%d", err, caller.calls)
+ }
+ })
+ }
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceSelfSettingExecuteBinding(t *testing.T) {
+ invalid := []string{
+ `{"result":{"userId":"user","checkRemindSetting":{}}}`,
+ `{"success":false,"result":{"userId":"user","checkRemindSetting":{}}}`,
+ `{"success":true,"result":null}`,
+ `{"success":true,"result":"bad"}`,
+ `{"success":true,"result":{"userId":"other","checkRemindSetting":{}}}`,
+ `{"success":true,"result":{"userId":"user"}}`,
+ `{"success":true,"result":{"userId":"user","checkRemindSetting":null}}`,
+ `{"success":true,"result":{"userId":"user","checkRemindSetting":true}}`,
+ }
+ for index, response := range invalid {
+ t.Run(fmt.Sprintf("invalid %d", index), func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, GetSelfSetting, map[string]string{"user": "user", "setting-scene": "checkRemind"}, response)
+ if err == nil || caller.calls != 1 {
+ t.Fatalf("invalid self setting err=%v calls=%d", err, caller.calls)
+ }
+ })
+ }
+ t.Run("normalizes user once", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, GetSelfSetting, map[string]string{"user": " user ", "setting-scene": "checkRemind"}, `{"success":true,"result":{"userId":"user","checkRemindSetting":{}}}`)
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("normalized self setting err=%v calls=%d", err, caller.calls)
+ }
+ request, ok := caller.arguments[0]["RuleMcpQuerySelfSettingRequest"].(map[string]any)
+ if !ok || request["userId"] != "user" {
+ t.Fatalf("normalized request = %#v", caller.arguments[0])
+ }
+ })
+
+ validTypes := []struct {
+ scene string
+ field string
+ value any
+ }{
+ {"checkRemind", "checkRemindSetting", map[string]any{}},
+ {"fastCheck", "fastCheckLateNeedConfirm", false},
+ {"checkResultNotify", "checkResultMsg", float64(0)},
+ {"lackRemind", "lackRemindUser", float64(1)},
+ {"personalAttendStatNotify", "personDailyReportSwitch", float64(1)},
+ {"bossAttendStatNotify", "bossMonthReportType", float64(3)},
+ }
+ for _, item := range validTypes {
+ value := map[string]any{"userId": "user", item.field: item.value}
+ if err := attendanceValidateSelfSetting(value, "user", item.scene); err != nil {
+ t.Errorf("scene %s rejected observed type: %v", item.scene, err)
+ }
+ }
+
+}
+
+func TestCrossPlatformCoverageAttendanceParamMappingsAreExplicit(t *testing.T) {
+ expected := map[*shortcut.Shortcut]map[string]string{
+ &CheckResult: {"users": "users", "start": "start", "end": "end", "offset": "offset", "limit": "limit"},
+ &CheckRecord: {"users": "users", "start": "start", "end": "end"},
+ &ListApprove: {"users": "users", "types": "types", "start": "start", "end": "end"},
+ &GetApproveTemplate: {"type": "type"},
+ &GetSchedule: {"users": "users", "start": "start", "end": "end"},
+ &SearchClass: {"query": "query", "filter-type": "filterType", "page": "page", "limit": "limit"},
+ &GetClass: {"class-id": "classId"},
+ &SearchAdjustmentRule: {"query": "query", "page": "page", "limit": "limit"},
+ &GetOvertimeRule: {"overtime-id": "overtimeId"},
+ &SearchOvertimeRule: {"query": "query", "page": "page", "limit": "limit"},
+ &GetSelfSetting: {"setting-scene": "settingScene", "user": "user"},
+ }
+ for declaration, want := range expected {
+ got := make(map[string]string, len(declaration.Contract.Parameters))
+ for _, parameter := range declaration.Contract.Parameters {
+ got[parameter.Name] = parameter.Property
+ }
+ if len(got) != len(want) {
+ t.Errorf("%s parameter count=%d want=%d: %#v", declaration.Command, len(got), len(want), got)
+ }
+ for name, property := range want {
+ if got[name] != property {
+ t.Errorf("%s --%s property=%q want=%q", declaration.Command, name, got[name], property)
+ }
+ }
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceRuntimeConstraintsFailBeforeCall(t *testing.T) {
+ manyUsers := make([]string, 101)
+ for index := range manyUsers {
+ manyUsers[index] = fmt.Sprintf("u%d", index)
+ }
+ cases := []struct {
+ name string
+ declaration shortcut.Shortcut
+ values map[string]string
+ }{
+ {"check result too many users", CheckResult, map[string]string{"users": strings.Join(manyUsers, ","), "start": "2026-01-01", "end": "2026-01-31"}},
+ {"check result reversed dates", CheckResult, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}},
+ {"check result over month", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-02-02"}},
+ {"check result bad limit", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1001"}},
+ {"check result bad offset", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "offset": "-1"}},
+ {"check record duplicate users", CheckRecord, map[string]string{"users": "u1,u1", "start": "2026-01-01", "end": "2026-01-31"}},
+ {"approve empty types", ListApprove, map[string]string{"users": "u1", "types": ",", "start": "2026-01-01", "end": "2026-01-31"}},
+ {"approve reversed dates", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-02-01", "end": "2026-01-31"}},
+ {"schedule empty users", GetSchedule, map[string]string{"users": ",", "start": "2026-01-01", "end": "2026-01-31"}},
+ {"schedule reversed dates", GetSchedule, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}},
+ {"search class page zero", SearchClass, map[string]string{"page": "0"}},
+ {"search adjustment limit too large", SearchAdjustmentRule, map[string]string{"limit": "201"}},
+ {"search overtime limit zero", SearchOvertimeRule, map[string]string{"limit": "0"}},
+ {"class id zero", GetClass, map[string]string{"class-id": "0"}},
+ {"overtime id zero", GetOvertimeRule, map[string]string{"overtime-id": "0"}},
+ {"leave records reversed dates", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-02-01", "end": "2026-01-31"}},
+ {"checkin duplicate users", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "operator", "staff-ids": "u1,u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}},
+ {"checkin over seven days", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "operator", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-08 00:00:01"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &attendanceCoverageCaller{text: `{"success":true,"result":[]}`}
+ helpers.InitDepsForTest(t, caller)
+ rt := attendanceRuntimeForTest(t, tc.declaration, tc.values)
+ if tc.declaration.Validate == nil {
+ t.Fatalf("%s has no Validate", tc.declaration.Command)
+ }
+ if err := tc.declaration.Validate(rt); err == nil {
+ t.Fatalf("invalid values accepted for %s", tc.declaration.Command)
+ }
+ if caller.calls != 0 {
+ t.Fatalf("%s made %d downstream calls before validation", tc.declaration.Command, caller.calls)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageAttendancePaginationLivesInEnvelopeMeta(t *testing.T) {
+ emit := func(complete bool, nextToken string) map[string]any {
+ t.Helper()
+ cmd := corecmd.New(shortcut.FromShortcut(CheckResult))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(io.Discard)
+ rt := shortcut.RuntimeContextForTest(cmd, CheckResult)
+ items := []map[string]any{{"id": int64(1), "userId": "redacted", "workDate": int64(1)}}
+ if err := attendanceOutputCollection(rt, "records", items, complete, map[string]any{"limit": 1, "nextOffset": 1}, true, nextToken); err != nil {
+ t.Fatal(err)
+ }
+ if _, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted {
+ t.Fatalf("emit err=%v emitted=%t", err, emitted)
+ }
+ var envelope map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode envelope: %v", err)
+ }
+ return envelope
+ }
+
+ envelope := emit(false, "1")
+ data, ok := envelope["data"].(map[string]any)
+ if !ok || data["count"] != float64(1) {
+ t.Fatalf("business data=%#v", envelope["data"])
+ }
+ for _, forbidden := range []string{"complete", "limit", "nextOffset", "nextPage", "totalCount", "totalPage"} {
+ if _, present := data[forbidden]; present {
+ t.Errorf("pagination field %q leaked into business data", forbidden)
+ }
+ }
+ meta, ok := envelope["meta"].(map[string]any)
+ if !ok {
+ t.Fatalf("meta=%#v", envelope["meta"])
+ }
+ pagination, ok := meta["pagination"].(map[string]any)
+ if !ok || pagination["endpoint_exhausted"] != false || pagination["next_token"] != "1" {
+ t.Fatalf("pagination=%#v", meta["pagination"])
+ }
+ exhaustedEnvelope := emit(true, "")
+ exhaustedMeta := exhaustedEnvelope["meta"].(map[string]any)
+ exhaustedPagination := exhaustedMeta["pagination"].(map[string]any)
+ if exhaustedPagination["endpoint_exhausted"] != true {
+ t.Fatalf("exhausted pagination=%#v", exhaustedPagination)
+ }
+ if _, present := exhaustedPagination["next_token"]; present {
+ t.Fatalf("exhausted pagination published next_token: %#v", exhaustedPagination)
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceCommonCallersFailClosed(t *testing.T) {
+ tests := []struct {
+ name string
+ run func(*shortcut.RuntimeContext) error
+ text string
+ err error
+ want bool
+ }{
+ {
+ name: "collection downstream error",
+ run: func(rt *shortcut.RuntimeContext) error {
+ return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, nil, "result")
+ },
+ err: fmt.Errorf("downstream unavailable"),
+ want: false,
+ },
+ {
+ name: "collection malformed",
+ run: func(rt *shortcut.RuntimeContext) error {
+ return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, nil, "result")
+ },
+ text: `{"success":true,"result":null}`,
+ want: false,
+ },
+ {
+ name: "collection validator",
+ run: func(rt *shortcut.RuntimeContext) error {
+ return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, func([]map[string]any) error {
+ return fmt.Errorf("binding mismatch")
+ }, "result")
+ },
+ text: `{"success":true,"result":[]}`,
+ want: false,
+ },
+ {
+ name: "collection success",
+ run: func(rt *shortcut.RuntimeContext) error {
+ return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, map[string]any{"source": "test"}, nil, "result")
+ },
+ text: `{"success":true,"result":[]}`,
+ want: true,
+ },
+ {
+ name: "value downstream error",
+ run: func(rt *shortcut.RuntimeContext) error {
+ return attendanceCallValue(rt, "attendance-test", "value", nil)
+ },
+ err: fmt.Errorf("downstream unavailable"),
+ want: false,
+ },
+ {
+ name: "value malformed",
+ run: func(rt *shortcut.RuntimeContext) error {
+ return attendanceCallValue(rt, "attendance-test", "value", nil)
+ },
+ text: `{"success":true}`,
+ want: false,
+ },
+ {
+ name: "value success",
+ run: func(rt *shortcut.RuntimeContext) error {
+ return attendanceCallValue(rt, "attendance-test", "value", nil)
+ },
+ text: `{"success":true,"result":false}`,
+ want: true,
+ },
+ {
+ name: "object downstream error",
+ run: func(rt *shortcut.RuntimeContext) error {
+ return attendanceCallObject(rt, "attendance-test", "object", nil)
+ },
+ err: fmt.Errorf("downstream unavailable"),
+ want: false,
+ },
+ {
+ name: "object malformed",
+ run: func(rt *shortcut.RuntimeContext) error {
+ return attendanceCallObject(rt, "attendance-test", "object", nil)
+ },
+ text: `{"success":true,"result":[]}`,
+ want: false,
+ },
+ {
+ name: "object success",
+ run: func(rt *shortcut.RuntimeContext) error {
+ return attendanceCallObject(rt, "attendance-test", "object", nil)
+ },
+ text: `{"success":true,"result":{"id":1}}`,
+ want: true,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &attendanceCoverageCaller{text: tc.text, err: tc.err}
+ helpers.InitDepsForTest(t, caller)
+ rt := attendanceRuntimeForTest(t, CheckResult, nil)
+ err := tc.run(rt)
+ if (err == nil) != tc.want || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d wantSuccess=%t", err, caller.calls, tc.want)
+ }
+ })
+ }
+
+ t.Run("legacy output", func(t *testing.T) {
+ rt := attendanceRuntimeForTest(t, ListLeaveTypes, nil)
+ if err := attendanceOutputCollection(rt, "items", []map[string]any{{"leaveCode": "code"}}, true, map[string]any{"source": "legacy"}, false, ""); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("invalid continuation", func(t *testing.T) {
+ rt := attendanceRuntimeForTest(t, CheckResult, nil)
+ if err := attendanceOutputCollection(rt, "records", nil, false, nil, true, ""); err == nil {
+ t.Fatal("non-terminal pagination without next token was accepted")
+ }
+ })
+}
+
+func TestCrossPlatformCoverageAttendanceIdentityAndInputHelpers(t *testing.T) {
+ if value, ok := attendanceNestedValue(map[string]any{"outer": map[string]any{"id": int32(7)}}, "outer", "id"); !ok || value != int32(7) {
+ t.Fatalf("nested identity=%#v ok=%t", value, ok)
+ }
+ for _, path := range [][]string{{"missing"}, {"outer", "id"}} {
+ item := map[string]any{"outer": "not-an-object"}
+ if _, ok := attendanceNestedValue(item, path...); ok {
+ t.Fatalf("invalid nested path accepted: %#v", path)
+ }
+ }
+
+ positive := []any{int(1), int32(2), int64(3), float64(4), json.Number("5")}
+ for _, value := range positive {
+ if _, ok := attendancePositiveInteger(value); !ok {
+ t.Errorf("positive identity rejected: %#v", value)
+ }
+ }
+ invalid := []any{int(0), int32(-1), int64(0), 1.5, json.Number("bad"), "1", nil}
+ for _, value := range invalid {
+ if _, ok := attendancePositiveInteger(value); ok {
+ t.Errorf("invalid identity accepted: %#v", value)
+ }
+ }
+ if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"nested": map[string]any{"id": int32(1)}}}, "attendance/test", "nested", "id"); err != nil {
+ t.Fatal(err)
+ }
+ for _, items := range [][]map[string]any{
+ {{}},
+ {{"id": 0}},
+ {{"id": 1}, {"id": 1}},
+ } {
+ if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil {
+ t.Fatalf("invalid identity collection accepted: %#v", items)
+ }
+ }
+ if err := attendanceValidateExpectedStrings([]map[string]any{{"code": " A "}, {"code": "B"}}, "attendance/test", "code", ""); err != nil {
+ t.Fatal(err)
+ }
+ for _, items := range [][]map[string]any{
+ {{}},
+ {{"code": 1}},
+ {{"code": " "}},
+ {{"code": "A"}, {"code": "A"}},
+ } {
+ if err := attendanceValidateExpectedStrings(items, "attendance/test", "code", ""); err == nil {
+ t.Fatalf("invalid string identity collection accepted: %#v", items)
+ }
+ }
+
+ userCases := []struct {
+ values []string
+ maximum int
+ valid bool
+ }{
+ {[]string{"u1"}, 1, true},
+ {nil, 1, false},
+ {[]string{"u1", "u2"}, 1, false},
+ {[]string{""}, 1, false},
+ {[]string{" u1"}, 1, false},
+ {[]string{"u1", "u1"}, 2, false},
+ }
+ for _, tc := range userCases {
+ err := attendanceValidateUserIDs(tc.values, tc.maximum)
+ if (err == nil) != tc.valid {
+ t.Errorf("users=%#v maximum=%d err=%v", tc.values, tc.maximum, err)
+ }
+ }
+
+ for _, tc := range []struct {
+ start string
+ end string
+ valid bool
+ }{
+ {"2026-01-01", "2026-02-01", true},
+ {"bad", "2026-01-01", false},
+ {"2026-01-01", "bad", false},
+ {"2026-02-01", "2026-01-01", false},
+ {"2026-01-01", "2026-02-02", false},
+ } {
+ err := attendanceValidateMonthRange(tc.start, tc.end)
+ if (err == nil) != tc.valid {
+ t.Errorf("month range %q..%q err=%v", tc.start, tc.end, err)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageAttendancePaginationEvidenceBoundaries(t *testing.T) {
+ tests := []struct {
+ name string
+ data map[string]any
+ page int
+ limit int
+ itemCount int
+ complete bool
+ valid bool
+ }{
+ {"invalid request", map[string]any{"success": true, "result": map[string]any{}}, 0, 10, 0, false, false},
+ {"malformed result", map[string]any{"success": true, "result": []any{}}, 1, 10, 0, false, false},
+ {"current page type", map[string]any{"success": true, "result": map[string]any{"currentPage": "1", "totalPage": 1}}, 1, 10, 0, false, false},
+ {"current page mismatch", map[string]any{"success": true, "result": map[string]any{"currentPage": 2, "totalPage": 1}}, 1, 10, 0, false, false},
+ {"total page type", map[string]any{"success": true, "result": map[string]any{"totalPage": "1"}}, 1, 10, 0, false, false},
+ {"negative total page", map[string]any{"success": true, "result": map[string]any{"totalPage": -1}}, 1, 10, 0, false, false},
+ {"zero pages invalid current page", map[string]any{"success": true, "result": map[string]any{"totalPage": 0}}, 2, 10, 0, false, false},
+ {"page out of range", map[string]any{"success": true, "result": map[string]any{"totalPage": 1}}, 2, 10, 0, false, false},
+ {"total count type", map[string]any{"success": true, "result": map[string]any{"totalCount": "1"}}, 1, 10, 0, false, false},
+ {"negative total count", map[string]any{"success": true, "result": map[string]any{"totalCount": -1}}, 1, 10, 0, false, false},
+ {"empty with nonzero total", map[string]any{"success": true, "result": map[string]any{"totalCount": 1}}, 1, 10, 0, false, false},
+ {"count before page", map[string]any{"success": true, "result": map[string]any{"totalCount": 5}}, 2, 10, 0, false, false},
+ {"items exceed total", map[string]any{"success": true, "result": map[string]any{"totalCount": 5}}, 1, 10, 6, false, false},
+ {"missing evidence", map[string]any{"success": true, "result": map[string]any{}}, 1, 10, 0, false, false},
+ {"conflicting evidence", map[string]any{"success": true, "result": map[string]any{"totalPage": 2, "totalCount": 5}}, 1, 10, 5, false, false},
+ {"total mismatch", map[string]any{"success": true, "result": map[string]any{"totalPage": 3, "totalCount": 15}}, 1, 10, 10, false, false},
+ {"no progress", map[string]any{"success": true, "result": map[string]any{"totalPage": 2}}, 1, 10, 0, false, false},
+ {"short page", map[string]any{"success": true, "result": map[string]any{"totalPage": 2}}, 1, 10, 5, false, false},
+ {"terminal count mismatch", map[string]any{"success": true, "result": map[string]any{"totalCount": 15}}, 2, 10, 4, false, false},
+ {"empty terminal", map[string]any{"success": true, "result": map[string]any{"totalPage": 0, "totalCount": 0}}, 1, 10, 0, true, true},
+ {"full nonterminal", map[string]any{"success": true, "result": map[string]any{"currentPage": 1, "totalPage": 2, "totalCount": 20}}, 1, 10, 10, false, true},
+ {"short terminal", map[string]any{"success": true, "result": map[string]any{"currentPage": 2, "totalPage": 2, "totalCount": 15}}, 2, 10, 5, true, true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ complete, extra, err := attendancePageEvidence(tc.data, "attendance/test", tc.page, tc.limit, tc.itemCount)
+ if (err == nil) != tc.valid {
+ t.Fatalf("err=%v extra=%#v", err, extra)
+ }
+ if tc.valid && complete != tc.complete {
+ t.Fatalf("complete=%t want=%t extra=%#v", complete, tc.complete, extra)
+ }
+ if tc.valid && !tc.complete && extra["nextPage"] != tc.page+1 {
+ t.Fatalf("nonterminal extra=%#v", extra)
+ }
+ })
+ }
+
+ for _, tc := range []struct {
+ page string
+ limit string
+ valid bool
+ }{
+ {"1", "200", true},
+ {"0", "20", false},
+ {"1", "0", false},
+ {"1", "201", false},
+ } {
+ rt := attendanceRuntimeForTest(t, SearchClass, map[string]string{"page": tc.page, "limit": tc.limit})
+ page, limit, err := attendancePageInput(rt)
+ if (err == nil) != tc.valid {
+ t.Errorf("page=%s limit=%s got=(%d,%d) err=%v", tc.page, tc.limit, page, limit, err)
+ }
+ if err2 := attendanceValidatePageRequest(rt); (err2 == nil) != tc.valid {
+ t.Errorf("validator page=%s limit=%s err=%v", tc.page, tc.limit, err2)
+ }
+ }
+
+ for _, tc := range []struct {
+ value any
+ want int
+ valid bool
+ }{
+ {int(1), 1, true}, {int32(2), 2, true}, {int64(3), 3, true}, {float64(4), 4, true},
+ {float64(1.5), 0, false}, {"1", 0, false},
+ } {
+ got, ok := attendanceInt(tc.value)
+ if got != tc.want || ok != tc.valid {
+ t.Errorf("attendanceInt(%#v)=(%d,%t), want (%d,%t)", tc.value, got, ok, tc.want, tc.valid)
+ }
+ }
+}
+
+func attendanceResponseJSON(t *testing.T, value any) string {
+ t.Helper()
+ raw, err := json.Marshal(value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(raw)
+}
+
+func TestCrossPlatformCoverageAttendanceChangedValidators(t *testing.T) {
+ tests := []struct {
+ name string
+ declaration shortcut.Shortcut
+ values map[string]string
+ valid bool
+ }{
+ {"check result valid", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1", "offset": "0"}, true},
+ {"check record valid", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true},
+ {"approve empty users", ListApprove, map[string]string{"types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, false},
+ {"approve empty types", ListApprove, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, false},
+ {"approve invalid start", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "bad", "end": "2026-01-31"}, false},
+ {"approve invalid end", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "bad"}, false},
+ {"approve duplicate mapped type", ListApprove, map[string]string{"users": "u1", "types": "leave,请假", "start": "2026-01-01", "end": "2026-01-31"}, false},
+ {"approve invalid type", ListApprove, map[string]string{"users": "u1", "types": "invalid", "start": "2026-01-01", "end": "2026-01-31"}, false},
+ {"approve valid", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, true},
+ {"schedule invalid start", GetSchedule, map[string]string{"users": "u1", "start": "bad", "end": "2026-01-31"}, false},
+ {"schedule invalid end", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "bad"}, false},
+ {"schedule reverse", GetSchedule, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}, false},
+ {"schedule valid", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true},
+ {"class valid", GetClass, map[string]string{"class-id": "1"}, true},
+ {"overtime valid", GetOvertimeRule, map[string]string{"overtime-id": "1"}, true},
+ {"self setting empty user", GetSelfSetting, map[string]string{"user": "", "setting-scene": "checkRemind"}, false},
+ {"self setting valid", GetSelfSetting, map[string]string{"user": "u1", "setting-scene": "checkRemind"}, true},
+ {"leave records empty user", GetLeaveRecords, map[string]string{"user": "", "start": "2026-01-01", "end": "2026-01-31"}, false},
+ {"leave records invalid start", GetLeaveRecords, map[string]string{"user": "u1", "start": "bad", "end": "2026-01-31"}, false},
+ {"leave records invalid end", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-01-01", "end": "bad"}, false},
+ {"leave records valid", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true},
+ {"checkin missing operator", GetCheckinRecord, map[string]string{"operator-corp-id": "", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, false},
+ {"checkin invalid start", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "bad", "end": "2026-01-02 00:00:00"}, false},
+ {"checkin invalid end", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "bad"}, false},
+ {"checkin reverse", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-02 00:00:00", "end": "2026-01-01 00:00:00"}, false},
+ {"checkin valid", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ rt := attendanceRuntimeForTest(t, tc.declaration, tc.values)
+ if tc.declaration.Validate == nil {
+ t.Fatalf("%s has no validator", tc.declaration.Command)
+ }
+ err := tc.declaration.Validate(rt)
+ if (err == nil) != tc.valid {
+ t.Fatalf("err=%v wantValid=%t", err, tc.valid)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) {
+ startMillis, _ := dayMillis("2026-01-01")
+ endStartMillis, _ := dayMillis("2026-01-31")
+ endMillis, _ := dateToMillis("2026-01-31", true)
+ nextDayStartMillis, _ := dayMillis("2026-02-01")
+ finalDayNoon := endStartMillis + 12*60*60*1000
+ validRecord := map[string]any{"id": 1, "userId": "u1", "workDate": startMillis}
+ if endMillis != nextDayStartMillis-1 {
+ t.Fatalf("end-of-day=%d want=%d", endMillis, nextDayStartMillis-1)
+ }
+ explicitEnd, err := dateToMillis("2026-01-31 12:34:56", true)
+ explicitWant, _ := flexMillis("2026-01-31 12:34:56")
+ if err != nil || explicitEnd != explicitWant {
+ t.Fatalf("explicit datetime end=%d want=%d err=%v", explicitEnd, explicitWant, err)
+ }
+
+ t.Run("check result bad execute bounds", func(t *testing.T) {
+ for _, values := range []map[string]string{
+ {"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "0"},
+ {"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "offset": "-1"},
+ } {
+ caller, err := executeAttendanceResponse(t, CheckResult, values, `{"success":true,"result":[]}`)
+ if err == nil || caller.calls != 0 {
+ t.Fatalf("values=%#v err=%v calls=%d", values, err, caller.calls)
+ }
+ }
+ })
+
+ t.Run("check result downstream and response failures", func(t *testing.T) {
+ values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"}
+ responses := []string{
+ `{"success":true,"result":null}`,
+ attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{map[string]any{"id": 0, "userId": "u1", "workDate": startMillis}}}),
+ attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}}),
+ }
+ for _, response := range responses {
+ caller, err := executeAttendanceResponse(t, CheckResult, values, response)
+ if err == nil || caller.calls != 1 {
+ t.Fatalf("response=%s err=%v calls=%d", response, err, caller.calls)
+ }
+ }
+ caller := &attendanceCoverageCaller{err: fmt.Errorf("backend unavailable")}
+ helpers.InitDepsForTest(t, caller)
+ if err := CheckResult.Execute(attendanceRuntimeForTest(t, CheckResult, values)); err == nil || caller.calls != 1 {
+ t.Fatalf("downstream err=%v calls=%d", err, caller.calls)
+ }
+ })
+
+ t.Run("check result short and full pages", func(t *testing.T) {
+ shortValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2", "offset": "0"}
+ caller, err := executeAttendanceResponse(t, CheckResult, shortValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{validRecord}}))
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("short err=%v calls=%d", err, caller.calls)
+ }
+ fullValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1", "offset": "4"}
+ caller, err = executeAttendanceResponse(t, CheckResult, fullValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{validRecord}}))
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("full err=%v calls=%d", err, caller.calls)
+ }
+ })
+
+ t.Run("end date includes the full business day", func(t *testing.T) {
+ checkValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"}
+ checkRow := map[string]any{"id": 1, "userId": "u1", "workDate": finalDayNoon}
+ caller, err := executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}}))
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("check result rejected final-day midday row: err=%v calls=%d", err, caller.calls)
+ }
+
+ approveValues := map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}
+ approval := map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": finalDayNoon, "endTime": finalDayNoon + 60*60*1000}
+ caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}}))
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("approval rejected final-day midday interval: err=%v calls=%d", err, caller.calls)
+ }
+
+ checkRow["workDate"] = endMillis
+ caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}}))
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("check result rejected final millisecond: err=%v calls=%d", err, caller.calls)
+ }
+ checkRow["workDate"] = nextDayStartMillis
+ if caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})); err == nil || caller.calls != 1 {
+ t.Fatalf("check result accepted next-day row: err=%v calls=%d", err, caller.calls)
+ }
+
+ approval["beginTime"] = nextDayStartMillis
+ approval["endTime"] = nextDayStartMillis + 1000
+ caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}}))
+ typed, ok := err.(*apperrors.Error)
+ reason := ""
+ if ok {
+ reason = typed.Reason
+ }
+ if err == nil || caller.calls != 1 || !ok || typed.Reason != "request_range_mismatch" {
+ t.Fatalf("approval next-day result err=%v calls=%d reason=%q", err, caller.calls, reason)
+ }
+ })
+
+ collectionCases := []struct {
+ name string
+ declaration shortcut.Shortcut
+ values map[string]string
+ valid map[string]any
+ invalid map[string]any
+ }{
+ {"check record", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}, map[string]any{"id": 1, "userId": "other", "userCheckTime": startMillis}},
+ {"approve", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": startMillis, "endTime": endMillis}, map[string]any{"id": 1, "userId": "u1", "bizType": 4, "beginTime": startMillis, "endTime": endMillis}},
+ {"schedule", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}},
+ }
+ t.Run("schedule null responses fail closed", func(t *testing.T) {
+ values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}
+ for _, tc := range []struct {
+ name string
+ response string
+ reason string
+ }{
+ {name: "literal null", response: `null`, reason: "empty_tool_response"},
+ {name: "null collection", response: `{"success":true,"result":null}`, reason: "malformed_collection"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, GetSchedule, values, tc.response)
+ typed, ok := err.(*apperrors.Error)
+ actualReason := ""
+ if ok {
+ actualReason = typed.Reason
+ }
+ if err == nil || caller.calls != 1 || !ok || actualReason != tc.reason {
+ t.Fatalf("err=%v calls=%d reason=%q want=%q", err, caller.calls, actualReason, tc.reason)
+ }
+ })
+ }
+ })
+ for _, tc := range collectionCases {
+ t.Run(tc.name+" valid", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.valid}}))
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+ t.Run(tc.name+" invalid binding", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.invalid}}))
+ if err == nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+ t.Run(tc.name+" invalid identity", func(t *testing.T) {
+ invalidIdentity := make(map[string]any, len(tc.valid))
+ for key, value := range tc.valid {
+ invalidIdentity[key] = value
+ }
+ invalidIdentity["id"] = 0
+ caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{invalidIdentity}}))
+ if err == nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceSearchProjectorsFailClosed(t *testing.T) {
+ classValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"shiftVO": map[string]any{"id": 1, "name": "n", "ownerName": "o"}}}}}
+ classes, err := searchClassProject(classValid)
+ if err != nil || len(classes) != 1 || classes[0]["classId"] != int64(1) || classes[0]["name"] != "n" || classes[0]["ownerName"] != "o" {
+ t.Fatalf("class projection=%#v err=%v", classes, err)
+ }
+ for _, data := range []map[string]any{
+ {"success": true, "result": map[string]any{"items": []any{map[string]any{"shiftVO": "bad"}}}},
+ {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}},
+ {"success": true, "result": map[string]any{"items": []any{map[string]any{"id": 1}, map[string]any{"classId": 1}}}},
+ } {
+ if _, err := searchClassProject(data); err == nil {
+ t.Fatalf("invalid class projection accepted: %#v", data)
+ }
+ }
+
+ ruleValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"entityVO": map[string]any{"id": 2, "name": "rule"}}}}}
+ rules, err := searchRuleProject(ruleValid, "attendance/test", "result.items")
+ if err != nil || len(rules) != 1 || rules[0]["ruleId"] != int64(2) || rules[0]["name"] != "rule" {
+ t.Fatalf("rule projection=%#v err=%v", rules, err)
+ }
+ for _, data := range []map[string]any{
+ {"success": true, "result": map[string]any{"items": []any{map[string]any{"entityVO": "bad"}}}},
+ {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}},
+ {"success": true, "result": map[string]any{"items": []any{map[string]any{"id": 1}, map[string]any{"ruleId": 1}}}},
+ } {
+ if _, err := searchRuleProject(data, "attendance/test", "result.items"); err == nil {
+ t.Fatalf("invalid rule projection accepted: %#v", data)
+ }
+ }
+
+ groupValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"group_id": "g1", "group_name": "group", "group_type": "FIXED"}}}}
+ groups, err := searchGroupProject(groupValid)
+ if err != nil || len(groups) != 1 || groups[0]["groupId"] != "g1" || groups[0]["name"] != "group" || groups[0]["type"] != "FIXED" {
+ t.Fatalf("group projection=%#v err=%v", groups, err)
+ }
+ if _, err := searchGroupProject(map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}); err == nil {
+ t.Fatal("group without identity was accepted")
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceSearchExecutorsPagination(t *testing.T) {
+ type searchCase struct {
+ name string
+ declaration shortcut.Shortcut
+ tool string
+ resultKey string
+ item map[string]any
+ }
+ tests := []searchCase{
+ {"class", SearchClass, "get_class_list", "items", map[string]any{"shiftVO": map[string]any{"id": 1}}},
+ {"adjustment", SearchAdjustmentRule, "get_adjustment_rule", "adjustmentList", map[string]any{"entityVO": map[string]any{"id": 1}}},
+ {"overtime", SearchOvertimeRule, "get_overtime_rule", "atRuleList", map[string]any{"entityVO": map[string]any{"id": 1}}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name+" invalid page", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"page": "0", "limit": "1"}, `{"success":true}`)
+ if err == nil || caller.calls != 0 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+ t.Run(tc.name+" malformed collection", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"query": "q", "page": "1", "limit": "1"}, `{"success":true,"result":{}}`)
+ if err == nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+ t.Run(tc.name+" missing pagination evidence", func(t *testing.T) {
+ result := map[string]any{tc.resultKey: []any{tc.item}}
+ caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"page": "1", "limit": "1"}, attendanceResponseJSON(t, map[string]any{"success": true, "result": result}))
+ if err == nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+ for _, page := range []struct {
+ name string
+ totalPage int
+ total int
+ }{
+ {"terminal", 1, 1},
+ {"nonterminal", 2, 2},
+ } {
+ t.Run(tc.name+" "+page.name, func(t *testing.T) {
+ result := map[string]any{tc.resultKey: []any{tc.item}, "currentPage": 1, "totalPage": page.totalPage, "totalCount": page.total}
+ caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"query": "q", "page": "1", "limit": "1"}, attendanceResponseJSON(t, map[string]any{"success": true, "result": result}))
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ if caller.tools[0] != tc.tool {
+ t.Fatalf("tool=%q want=%q", caller.tools[0], tc.tool)
+ }
+ })
+ }
+ }
+
+ t.Run("group invalid page", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"page": "0", "limit": "1"}, `{"success":true}`)
+ if err == nil || caller.calls != 0 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+ t.Run("group malformed and pagination failure", func(t *testing.T) {
+ for _, response := range []string{
+ `{"success":true,"result":{}}`,
+ `{"success":true,"result":{"items":[{"id":"g1"}]}}`,
+ } {
+ caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"query": "q", "type": "FIXED", "query-position": "true", "query-ble": "false", "page": "1", "limit": "1"}, response)
+ if err == nil || caller.calls != 1 {
+ t.Fatalf("response=%s err=%v calls=%d", response, err, caller.calls)
+ }
+ }
+ })
+ t.Run("group terminal", func(t *testing.T) {
+ response := `{"success":true,"result":{"items":[{"id":"g1","name":"group","type":"FIXED"}],"currentPage":1,"totalPage":1,"totalCount":1}}`
+ caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"page": "1", "limit": "1"}, response)
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageAttendanceRemainingChangedExecutors(t *testing.T) {
+ t.Run("approve template exact type", func(t *testing.T) {
+ for _, tc := range []struct {
+ value string
+ response string
+ valid bool
+ calls int
+ }{
+ {"invalid", `{"success":true,"result":[]}`, false, 0},
+ {"leave", `{"success":true,"result":[{"processCode":"p1","approveType":"OVERTIME","submitUrl":"https://example.invalid/1"}]}`, false, 1},
+ {"leave", `{"success":true,"result":[{"processCode":"p1","approveType":"LEAVE","submitUrl":"https://example.invalid/1"}]}`, true, 1},
+ {"TRAVEL", `{"success":true,"result":[{"processCode":"p1","approveType":"TRAVEL","submitUrl":"https://example.invalid/1"},{"processCode":"p2","approveType":"TRAVEL","submitUrl":"https://example.invalid/2"}]}`, true, 1},
+ {"OUT", `{"success":true,"result":[{"processCode":"p1","approveType":"OUT","submitUrl":"https://example.invalid/1"},{"processCode":"p2","approveType":"OUT","submitUrl":"https://example.invalid/2"}]}`, true, 1},
+ } {
+ caller, err := executeAttendanceResponse(t, GetApproveTemplate, map[string]string{"type": tc.value}, tc.response)
+ if (err == nil) != tc.valid || caller.calls != tc.calls {
+ t.Errorf("type=%q err=%v calls=%d wantValid=%t wantCalls=%d", tc.value, err, caller.calls, tc.valid, tc.calls)
+ }
+ }
+ })
+
+ t.Run("hidden adjustment detail still fails closed", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, GetAdjustmentRule, map[string]string{"adjustment-id": "7"}, `{"success":true,"result":{"id":7}}`)
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+
+ t.Run("summary object", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, GetSummary, map[string]string{"user": "u1", "date": "2026-01-01", "stats-type": "week"}, `{"success":true,"result":{"days":1}}`)
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+
+ t.Run("self setting helper rejects invalid request and scene", func(t *testing.T) {
+ if err := attendanceValidateSelfSetting(map[string]any{"userId": "u1", "checkRemindSetting": map[string]any{}}, " ", "checkRemind"); err == nil {
+ t.Fatal("empty requested user accepted")
+ }
+ if err := attendanceValidateSelfSetting(map[string]any{"userId": "u1"}, "u1", "not-a-scene"); err == nil {
+ t.Fatal("invalid setting scene accepted")
+ }
+ })
+
+ t.Run("report value", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, QueryReportData, map[string]string{"users": "u1", "columns": "1, 2", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, `{"success":true,"result":false}`)
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+
+ t.Run("leave type exact collection", func(t *testing.T) {
+ caller, err := executeAttendanceResponse(t, ListLeaveTypes, nil, `{"success":true,"result":[{"leaveCode":"code"}]}`)
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+
+ t.Run("leave records optional code", func(t *testing.T) {
+ values := map[string]string{"user": "u1", "leave-code": "code", "start": "2026-01-01", "end": "2026-01-31"}
+ caller, err := executeAttendanceResponse(t, GetLeaveRecords, values, `{"success":true,"result":[]}`)
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+
+ t.Run("checkin record nested collection", func(t *testing.T) {
+ values := map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}
+ caller, err := executeAttendanceResponse(t, GetCheckinRecord, values, `{"success":true,"result":{"list":[]}}`)
+ if err != nil || caller.calls != 1 {
+ t.Fatalf("err=%v calls=%d", err, caller.calls)
+ }
+ })
+}
+
+var _ edition.ToolCaller = (*attendanceCoverageCaller)(nil)
diff --git a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go
new file mode 100644
index 00000000..900f2ea2
--- /dev/null
+++ b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go
@@ -0,0 +1,267 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package attendance
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *testing.T) {
+ shortcuts := []struct {
+ name string
+ rollout output.RolloutState
+ hasResult bool
+ resultSchema string
+ identity string
+ cursor string
+ }{
+ {CheckResult.Command, CheckResult.OutputRollout, CheckResult.Contract.Result != nil, string(CheckResult.Contract.Result.DataSchema), `"id"`, "offset"},
+ {CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema), `"id"`, ""},
+ {ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema), `"id"`, ""},
+ {GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema), `"processCode"`, ""},
+ {SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema), `"classId"`, "page"},
+ {SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema), `"ruleId"`, "page"},
+ {GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema), `"id"`, ""},
+ {SearchOvertimeRule.Command, SearchOvertimeRule.OutputRollout, SearchOvertimeRule.Contract.Result != nil, string(SearchOvertimeRule.Contract.Result.DataSchema), `"ruleId"`, "page"},
+ }
+ for _, item := range shortcuts {
+ if item.rollout != output.RolloutUnifiedActive {
+ t.Errorf("%s rollout = %q, want unified_active", item.name, item.rollout)
+ }
+ if !item.hasResult || strings.TrimSpace(item.resultSchema) == "" {
+ t.Errorf("%s must publish a non-empty Result", item.name)
+ }
+ if !json.Valid([]byte(item.resultSchema)) || !strings.Contains(item.resultSchema, item.identity) {
+ t.Errorf("%s Result must declare stable identity %s: %s", item.name, item.identity, item.resultSchema)
+ }
+ if len(attendanceDeclarationByName(item.name).Contract.Result.SensitivePaths) == 0 {
+ t.Errorf("%s must publish reviewed sensitive_paths", item.name)
+ }
+ if strings.Contains(item.resultSchema, `"complete"`) || strings.Contains(item.resultSchema, `"nextPage"`) || strings.Contains(item.resultSchema, `"nextOffset"`) {
+ t.Errorf("%s Result must not place pagination controls in business data", item.name)
+ }
+ if item.cursor == "" {
+ if itemPagination := attendanceDeclarationByName(item.name).Contract.Pagination; itemPagination != nil {
+ t.Errorf("%s unexpectedly publishes Pagination", item.name)
+ }
+ } else if pagination := attendanceDeclarationByName(item.name).Contract.Pagination; pagination == nil || pagination.CursorParameter != item.cursor {
+ t.Errorf("%s Pagination = %#v, want cursor_parameter=%s", item.name, pagination, item.cursor)
+ }
+ }
+}
+
+func attendanceDeclarationByName(name string) *shortcut.Shortcut {
+ for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &SearchClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule} {
+ if declaration.Command == name {
+ return declaration
+ }
+ }
+ return &shortcut.Shortcut{}
+}
+
+func TestCrossPlatformCoverageAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) {
+ for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetSchedule, &GetSelfSetting} {
+ if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
+ t.Errorf("%s unavailable contract = rollout %q result=%v pagination=%v", declaration.Command, declaration.OutputRollout, declaration.Contract.Result, declaration.Contract.Pagination)
+ }
+ if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != attendanceCompatibilityInterfaceReason {
+ t.Errorf("%s must preserve its Schema-compatible non-public Interface", declaration.Command)
+ }
+ }
+ if GetClass.OutputRollout != output.RolloutLegacyOnly || GetClass.Contract.Result != nil || GetClass.Contract.Pagination != nil || GetClass.Contract.Interface == nil || GetClass.Contract.Interface.Availability != contract.InterfaceUnavailable || strings.TrimSpace(GetClass.Contract.Interface.Reason) == "" {
+ t.Error("get-class must publish a precise unavailable Interface")
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceProjectorsDistinguishEmptyFromBroken(t *testing.T) {
+ explicitEmpty := map[string]any{
+ "success": true,
+ "result": map[string]any{
+ "items": []any{},
+ "adjustmentList": []any{},
+ "atRuleList": []any{},
+ },
+ }
+ if got, err := searchClassProject(explicitEmpty); err != nil || len(got) != 0 {
+ t.Fatalf("explicit empty class list must succeed: got=%v err=%v", got, err)
+ }
+ if got, err := searchRuleProject(explicitEmpty, "attendance-wukong/test", "result.adjustmentList"); err != nil || len(got) != 0 {
+ t.Fatalf("explicit empty rule list must succeed: got=%v err=%v", got, err)
+ }
+ if got, err := searchGroupProject(explicitEmpty); err != nil || len(got) != 0 {
+ t.Fatalf("explicit empty group list must succeed: got=%v err=%v", got, err)
+ }
+
+ broken := []map[string]any{
+ {},
+ {"success": false, "result": []any{}},
+ {"success": true, "result": map[string]any{}},
+ {"success": true, "result": map[string]any{"items": "not-an-array"}},
+ {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing-id"}}}},
+ }
+ for index, data := range broken {
+ if got, err := searchClassProject(data); err == nil {
+ t.Errorf("broken class response %d returned success: %v", index, got)
+ }
+ if got, err := searchGroupProject(data); err == nil {
+ t.Errorf("broken group response %d returned success: %v", index, got)
+ }
+ }
+
+ ruleBroken := map[string]any{
+ "success": true,
+ "result": map[string]any{
+ "adjustmentList": []any{map[string]any{"entityVO": map[string]any{"name": "missing-id"}}},
+ },
+ }
+ if got, err := searchRuleProject(ruleBroken, "attendance-wukong/test", "result.adjustmentList"); err == nil {
+ t.Fatalf("rule row without stable ID returned success: %v", got)
+ }
+}
+
+func TestCrossPlatformCoverageAttendancePaginationRequiresCompletionEvidence(t *testing.T) {
+ withoutEvidence := map[string]any{"success": true, "result": map[string]any{"items": []any{}}}
+ if _, _, err := attendancePageEvidence(withoutEvidence, "attendance-wukong/test", 1, 20, 0); err == nil {
+ t.Fatal("missing totalCount/totalPage must not be reported as a complete page")
+ }
+ firstPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(21)}}
+ if _, _, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 0); err == nil {
+ t.Fatal("nonzero total with empty page must fail closed")
+ }
+ complete, extra, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 20)
+ if err != nil {
+ t.Fatalf("valid page evidence: %v", err)
+ }
+ if complete || extra["nextPage"] != 2 {
+ t.Fatalf("first page should be incomplete with nextPage=2: complete=%v extra=%v", complete, extra)
+ }
+ lastPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(2)}}
+ complete, extra, err = attendancePageEvidence(lastPage, "attendance-wukong/test", 2, 20, 1)
+ if err != nil || !complete {
+ t.Fatalf("last page should be complete: complete=%v extra=%v err=%v", complete, extra, err)
+ }
+ conflicting := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(1), "totalCount": float64(21)}}
+ if _, _, err := attendancePageEvidence(conflicting, "attendance-wukong/test", 1, 20, 20); err == nil {
+ t.Fatal("conflicting totalPage/totalCount evidence must fail closed")
+ }
+ mismatchedPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "totalCount": float64(0)}}
+ if _, _, err := attendancePageEvidence(mismatchedPage, "attendance-wukong/test", 1, 20, 0); err == nil {
+ t.Fatal("mismatched currentPage must fail closed")
+ }
+ negativeTotal := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(-1)}}
+ if _, _, err := attendancePageEvidence(negativeTotal, "attendance-wukong/test", 1, 20, 0); err == nil {
+ t.Fatal("negative pagination evidence must fail closed")
+ }
+ wrongType := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": "0"}}
+ if _, _, err := attendancePageEvidence(wrongType, "attendance-wukong/test", 1, 20, 0); err == nil {
+ t.Fatal("wrong pagination type must fail closed")
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceStableIdentityRejectsZeroBlankAndDuplicates(t *testing.T) {
+ for _, items := range [][]map[string]any{{{"id": 0}}, {{"id": ""}}, {{"id": 1}, {"id": 1}}} {
+ if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil {
+ t.Fatalf("invalid integer identity accepted: %#v", items)
+ }
+ }
+ if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"id": float64(1)}, {"id": int64(2)}}, "attendance/test", "id"); err != nil {
+ t.Fatalf("valid integer identities rejected: %v", err)
+ }
+ if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": ""}}, "attendance/test", "approveType", "LEAVE"); err == nil {
+ t.Fatal("blank string identity accepted")
+ }
+ if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}, {"approveType": "LEAVE"}}, "attendance/test", "approveType", "LEAVE"); err == nil {
+ t.Fatal("duplicate string identity accepted")
+ }
+}
+
+func TestCrossPlatformCoverageAttendanceApproveTemplateIdentityAndBinding(t *testing.T) {
+ valid := []map[string]any{
+ {"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"},
+ {"processCode": "process-2", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"},
+ }
+ if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil {
+ t.Fatalf("same approveType with unique processCode rejected: %v", err)
+ }
+ valid[0]["processCode"] = " process-1 "
+ if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil || valid[0]["processCode"] != "process-1" {
+ t.Fatalf("processCode was not normalized: value=%#v err=%v", valid[0]["processCode"], err)
+ }
+ valid[0]["approveType"] = "OUT"
+ valid[1]["approveType"] = "OUT"
+ if err := attendanceValidateApproveTemplates(valid, "attendance/test", "OUT"); err != nil {
+ t.Fatalf("OUT templates with unique processCode rejected: %v", err)
+ }
+
+ invalid := []struct {
+ name string
+ items []map[string]any
+ }{
+ {"missing processCode", []map[string]any{{"approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}},
+ {"wrong processCode type", []map[string]any{{"processCode": 1, "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}},
+ {"blank processCode", []map[string]any{{"processCode": " ", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}},
+ {"duplicate processCode", []map[string]any{{"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, {"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}}},
+ {"missing approveType", []map[string]any{{"processCode": "process-1", "submitUrl": "https://example.invalid/1"}}},
+ {"null approveType", []map[string]any{{"processCode": "process-1", "approveType": nil, "submitUrl": "https://example.invalid/1"}}},
+ {"wrong approveType type", []map[string]any{{"processCode": "process-1", "approveType": 1, "submitUrl": "https://example.invalid/1"}}},
+ {"wrong approveType", []map[string]any{{"processCode": "process-1", "approveType": "OUT", "submitUrl": "https://example.invalid/1"}}},
+ {"missing submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL"}}},
+ {"null submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": nil}}},
+ {"wrong submitUrl type", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": 1}}},
+ {"blank submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": " "}}},
+ }
+ for _, tc := range invalid {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := attendanceValidateApproveTemplates(tc.items, "attendance/test", "TRAVEL"); err == nil {
+ t.Fatalf("invalid templates accepted: %#v", tc.items)
+ }
+ })
+ }
+
+ var schema map[string]any
+ if err := json.Unmarshal(GetApproveTemplate.Contract.Result.DataSchema, &schema); err != nil {
+ t.Fatal(err)
+ }
+ items := schema["properties"].(map[string]any)["templates"].(map[string]any)["items"].(map[string]any)
+ required := items["required"].([]any)
+ for _, name := range []string{"processCode", "approveType", "submitUrl"} {
+ found := false
+ for _, value := range required {
+ if value == name {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("template schema required=%#v missing %s", required, name)
+ }
+ }
+ if got, want := strings.Join(GetApproveTemplate.Contract.Result.SensitivePaths, ","), "templates.formName,templates.processCode,templates.submitUrl"; got != want {
+ t.Fatalf("template sensitive paths = %q, want %q", got, want)
+ }
+ properties := items["properties"].(map[string]any)
+ for _, name := range []string{"processCode", "submitUrl"} {
+ property := properties[name].(map[string]any)
+ if property["type"] != "string" || property["minLength"] != float64(1) {
+ t.Errorf("template schema %s = %#v, want nonempty string", name, property)
+ }
+ }
+ approveType := properties["approveType"].(map[string]any)
+ if approveType["type"] != "string" {
+ t.Errorf("template schema approveType = %#v, want string", approveType)
+ }
+ enum := approveType["enum"].([]any)
+ values := make([]string, 0, len(enum))
+ for _, value := range enum {
+ values = append(values, value.(string))
+ }
+ if got, want := strings.Join(values, ","), "REPAIR_CHECK,LEAVE,OVERTIME,TRAVEL,OUT"; got != want {
+ t.Errorf("template approveType enum = %q, want %q", got, want)
+ }
+}
diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go
new file mode 100644
index 00000000..1b1a15e5
--- /dev/null
+++ b/internal/shortcut/attendance/common.go
@@ -0,0 +1,560 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package attendance
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
+)
+
+const attendanceCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or downstream proof gap is closed."
+
+func attendanceIntegerObjectResult(description, identity string) *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(fmt.Sprintf(
+ `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验后的考勤业务结果","properties":{%q:{"type":"integer","minimum":1,"description":"与请求精确绑定的稳定业务 ID"}},"required":[%q],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
+ description, identity, identity,
+ )),
+ }
+}
+
+func attendanceApproveTemplateResult() *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{"type":"object","description":"严格校验且绑定请求类型的考勤审批模板","properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效审批模板数量"},"templates":{"type":"array","description":"可发起的考勤审批模板","items":{"type":"object","description":"具备稳定流程身份与提交入口的考勤审批模板","properties":{"processCode":{"type":"string","minLength":1,"description":"模板的稳定唯一流程编码"},"approveType":{"type":"string","enum":["REPAIR_CHECK","LEAVE","OVERTIME","TRAVEL","OUT"],"description":"与请求精确绑定的审批类型"},"submitUrl":{"type":"string","minLength":1,"description":"非空审批提交入口"},"formName":{"type":"string","description":"审批模板展示名称"}},"required":["processCode","approveType","submitUrl"],"additionalProperties":true}}},"required":["count","templates"],"additionalProperties":false}`),
+ }
+}
+
+func attendanceCollectionResult(collection, description, identity, identityType string) *contract.ResultSpec {
+ identityConstraint := `"minLength":1`
+ if identityType == "integer" {
+ identityConstraint = `"minimum":1`
+ }
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(fmt.Sprintf(
+ `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具备稳定身份的考勤业务记录","properties":{%q:{"type":%q,%s,"description":"记录的稳定业务身份"}},"required":[%q],"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`,
+ description, collection, description, identity, identityType, identityConstraint, identity, collection,
+ )),
+ }
+}
+
+func attendanceCursorPagination(cursorParameter string) *contract.PaginationSpec {
+ return &contract.PaginationSpec{
+ Kind: contract.PaginationKindCursor,
+ CursorParameter: cursorParameter,
+ MetaPath: contract.PaginationMetaPath,
+ EndpointExhaustedPath: contract.PaginationExhaustedPath,
+ NextTokenPath: contract.PaginationNextTokenPath,
+ }
+}
+
+func hardenPublicAttendanceContracts() {
+ // Composite Shortcut properties are the stable published workflow inputs.
+ // Execute owns the explicit adapters to the differently named MCP fields;
+ // redirecting an existing non-empty Schema property requires a versioned
+ // migration and cannot be folded into this hardening change.
+ CheckResult.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "offset", Property: "offset"}, {Name: "limit", Property: "limit"}}
+ CheckRecord.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}}
+ ListApprove.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "types", Property: "types"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}}
+ GetApproveTemplate.Contract.Parameters = []contract.ParamDecl{{Name: "type", Property: "type"}}
+ GetSchedule.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}}
+ SearchClass.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "filter-type", Property: "filterType"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}
+ GetClass.Contract.Parameters = []contract.ParamDecl{{Name: "class-id", Property: "classId"}}
+ SearchAdjustmentRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}
+ GetOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "overtime-id", Property: "overtimeId"}}
+ SearchOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}
+ GetSelfSetting.Contract.Parameters = []contract.ParamDecl{{Name: "setting-scene", Property: "settingScene"}, {Name: "user", Property: "user"}}
+
+ collections := []struct {
+ declaration *shortcut.Shortcut
+ collection string
+ description string
+ identity string
+ identityType string
+ cursor string
+ }{
+ {&CheckResult, "records", "严格校验的员工打卡结果", "id", "integer", "offset"},
+ {&CheckRecord, "records", "严格校验的员工打卡流水", "id", "integer", ""},
+ {&ListApprove, "approvals", "严格校验的考勤审批记录", "id", "integer", ""},
+ {&SearchClass, "classes", "严格校验的班次搜索结果", "classId", "integer", "page"},
+ {&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果", "ruleId", "integer", "page"},
+ {&SearchOvertimeRule, "rules", "严格校验的加班规则搜索结果", "ruleId", "integer", "page"},
+ }
+ for _, item := range collections {
+ item.declaration.OutputRollout = output.RolloutUnifiedActive
+ item.declaration.Contract.Result = attendanceCollectionResult(item.collection, item.description, item.identity, item.identityType)
+ if item.cursor != "" {
+ item.declaration.Contract.Pagination = attendanceCursorPagination(item.cursor)
+ }
+ }
+ CheckResult.Contract.Result.SensitivePaths = []string{"records.corpId", "records.record", "records.userId"}
+ CheckRecord.Contract.Result.SensitivePaths = []string{"records.baseAddress", "records.baseLatitude", "records.baseLongitude", "records.corpId", "records.deviceId", "records.features", "records.userAddress", "records.userId", "records.userLatitude", "records.userLongitude"}
+ ListApprove.Contract.Result.SensitivePaths = []string{"approvals.corpId", "approvals.originId", "approvals.subType", "approvals.tagName", "approvals.userId"}
+ GetApproveTemplate.OutputRollout = output.RolloutUnifiedActive
+ GetApproveTemplate.Contract.Result = attendanceApproveTemplateResult()
+ GetApproveTemplate.Contract.Result.SensitivePaths = []string{"templates.formName", "templates.processCode", "templates.submitUrl"}
+ SearchClass.Contract.Result.SensitivePaths = []string{"classes.name", "classes.ownerName"}
+ SearchAdjustmentRule.Contract.Result.SensitivePaths = []string{"rules.name"}
+ SearchOvertimeRule.Contract.Result.SensitivePaths = []string{"rules.name"}
+ GetOvertimeRule.OutputRollout = output.RolloutUnifiedActive
+ GetOvertimeRule.Contract.Result = attendanceIntegerObjectResult("严格校验的加班规则详情", "id")
+ GetOvertimeRule.Contract.Result.SensitivePaths = []string{"value.content", "value.groupIdAndNames", "value.name", "value.owner", "value.ownerList", "value.scopes"}
+ markAttendanceCompatibilityOnly(&GetSummary)
+ markAttendanceCompatibilityOnly(&ListLeaveTypes)
+ markAttendanceCompatibilityOnly(&GetLeaveRecords)
+ markAttendanceCompatibilityOnly(&GetCheckinRecord)
+ markAttendanceCompatibilityOnly(&GetSchedule)
+ markAttendanceUnavailable(&GetClass, "下游 get_class_detail 对搜索得到的真实 classId 返回非空详情,但 shiftVO 不回显 id/classId,Shortcut 无法诚实地把详情精确绑定到请求 ID;需要下游补充稳定 ID 回显。")
+ markAttendanceCompatibilityOnly(&GetSelfSetting)
+}
+
+func markAttendanceCompatibilityOnly(declaration *shortcut.Shortcut) {
+ declaration.OutputRollout = output.RolloutLegacyOnly
+ declaration.Contract.Result = nil
+ declaration.Contract.Pagination = nil
+ declaration.Contract.Interface = &contract.InterfaceSpec{
+ Mode: contract.InterfaceModeComposite,
+ Availability: contract.InterfaceAvailable,
+ Reason: attendanceCompatibilityInterfaceReason,
+ }
+}
+
+func markAttendanceUnavailable(declaration *shortcut.Shortcut, reason string) {
+ declaration.OutputRollout = output.RolloutLegacyOnly
+ declaration.Contract.Result = nil
+ declaration.Contract.Pagination = nil
+ declaration.Contract.Interface = &contract.InterfaceSpec{
+ Mode: contract.InterfaceModeComposite,
+ Availability: contract.InterfaceUnavailable,
+ Reason: reason,
+ }
+}
+
+func attendanceCallCollection(
+ rt *shortcut.RuntimeContext,
+ product, tool string,
+ params map[string]any,
+ collection string,
+ complete bool,
+ extra map[string]any,
+ validate func([]map[string]any) error,
+ paths ...string,
+) error {
+ data, err := rt.CallMCPData(product, tool, params)
+ if err != nil {
+ return err
+ }
+ items, err := responsecheck.RequireObjectCollection(data, product+"/"+tool, paths...)
+ if err != nil {
+ return err
+ }
+ if validate != nil {
+ if err := validate(items); err != nil {
+ return err
+ }
+ }
+ return attendanceOutputCollection(rt, collection, items, complete, extra, false, "")
+}
+
+func attendanceCallValue(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error {
+ data, err := rt.CallMCPData(product, tool, params)
+ if err != nil {
+ return err
+ }
+ value, err := responsecheck.RequireResult(data, product+"/"+tool)
+ if err != nil {
+ return err
+ }
+ return rt.Output(map[string]any{"value": value})
+}
+
+func attendanceCallObject(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error {
+ data, err := rt.CallMCPData(product, tool, params)
+ if err != nil {
+ return err
+ }
+ value, err := responsecheck.RequireSingleObjectResult(data, product+"/"+tool)
+ if err != nil {
+ return err
+ }
+ return rt.Output(map[string]any{"value": value})
+}
+
+func attendanceCollectionPayload(collection string, items []map[string]any, complete bool, extra map[string]any) map[string]any {
+ payload := map[string]any{
+ "count": len(items),
+ collection: items,
+ "complete": complete,
+ }
+ for key, value := range extra {
+ payload[key] = value
+ }
+ return payload
+}
+
+func attendanceOutputCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, legacyExtra map[string]any, paginated bool, nextToken string) error {
+ payload := attendanceCollectionPayload(collection, items, complete, legacyExtra)
+ if !output.UsesUnifiedResult(rt.Command()) {
+ return rt.Output(payload)
+ }
+ business := map[string]any{"count": len(items), collection: items}
+ meta := &output.Meta{Count: output.NewCount(len(items))}
+ if paginated {
+ pagination, err := output.NewPagination(complete, nextToken)
+ if err != nil {
+ return responsecheck.Error("attendance/pagination", "invalid_pagination", err.Error())
+ }
+ meta.Pagination = pagination
+ }
+ return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
+}
+
+func attendanceValidatePositiveIntegerIDs(items []map[string]any, operation string, path ...string) error {
+ seen := make(map[int64]struct{}, len(items))
+ for index, item := range items {
+ value, ok := attendanceNestedValue(item, path...)
+ if !ok {
+ return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 ID", index))
+ }
+ identity, ok := attendancePositiveInteger(value)
+ if !ok {
+ return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项稳定 ID 必须是大于 0 的整数", index))
+ }
+ if _, duplicate := seen[identity]; duplicate {
+ return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定 ID 重复", index))
+ }
+ seen[identity] = struct{}{}
+ }
+ return nil
+}
+
+func attendanceValidateExpectedStrings(items []map[string]any, operation, field, expected string) error {
+ expected = strings.TrimSpace(expected)
+ seen := make(map[string]struct{}, len(items))
+ for index, item := range items {
+ value, ok := item[field].(string)
+ value = strings.TrimSpace(value)
+ if !ok || value == "" {
+ return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 %s 必须是非空字符串", index, field))
+ }
+ if expected != "" && value != expected {
+ return responsecheck.Error(operation, "item_identity_mismatch", fmt.Sprintf("第 %d 项 %s 与请求不一致", index, field))
+ }
+ if _, duplicate := seen[value]; duplicate {
+ return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 %s 重复", index, field))
+ }
+ seen[value] = struct{}{}
+ }
+ return nil
+}
+
+func attendanceValidateApproveTemplates(items []map[string]any, operation, expectedType string) error {
+ expectedType = strings.TrimSpace(expectedType)
+ seenProcessCodes := make(map[string]struct{}, len(items))
+ for index, item := range items {
+ processCodeValue, present := item["processCode"]
+ if !present {
+ return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 processCode", index))
+ }
+ processCode, ok := processCodeValue.(string)
+ processCode = strings.TrimSpace(processCode)
+ if !ok || processCode == "" {
+ return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 processCode 必须是非空字符串", index))
+ }
+ item["processCode"] = processCode
+ if _, duplicate := seenProcessCodes[processCode]; duplicate {
+ return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 processCode 重复", index))
+ }
+ seenProcessCodes[processCode] = struct{}{}
+
+ approveType, ok := item["approveType"].(string)
+ if !ok || approveType != expectedType {
+ return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项 approveType 与请求不一致", index))
+ }
+ submitURL, ok := item["submitUrl"].(string)
+ if !ok || strings.TrimSpace(submitURL) == "" {
+ return responsecheck.Error(operation, "malformed_item", fmt.Sprintf("第 %d 项缺少非空 submitUrl", index))
+ }
+ }
+ return nil
+}
+
+func attendanceNestedValue(item map[string]any, path ...string) (any, bool) {
+ var current any = item
+ for _, key := range path {
+ object, ok := current.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ current, ok = object[key]
+ if !ok {
+ return nil, false
+ }
+ }
+ return current, true
+}
+
+func attendancePositiveInteger(value any) (int64, bool) {
+ switch number := value.(type) {
+ case int:
+ return int64(number), number > 0
+ case int32:
+ return int64(number), number > 0
+ case int64:
+ return number, number > 0
+ case float64:
+ return int64(number), number > 0 && number == float64(int64(number))
+ case json.Number:
+ parsed, err := strconv.ParseInt(string(number), 10, 64)
+ return parsed, err == nil && parsed > 0
+ default:
+ return 0, false
+ }
+}
+
+func attendanceValidateUserIDs(values []string, maximum int) error {
+ if len(values) == 0 {
+ return fmt.Errorf("用户 ID 列表不能为空")
+ }
+ if maximum > 0 && len(values) > maximum {
+ return fmt.Errorf("用户 ID 数量不能超过 %d", maximum)
+ }
+ seen := make(map[string]struct{}, len(values))
+ for _, value := range values {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return fmt.Errorf("用户 ID 不能为空字符串")
+ }
+ if value != trimmed {
+ return fmt.Errorf("用户 ID 不能包含首尾空白")
+ }
+ if _, duplicate := seen[trimmed]; duplicate {
+ return fmt.Errorf("用户 ID 不能重复")
+ }
+ seen[trimmed] = struct{}{}
+ }
+ return nil
+}
+
+func attendanceValidateUserAndTimeBinding(items []map[string]any, operation string, requestedUsers []string, userField, timeField string, startMillis, endMillis int64) error {
+ users := make(map[string]struct{}, len(requestedUsers))
+ for _, user := range requestedUsers {
+ users[user] = struct{}{}
+ }
+ for index, item := range items {
+ rawUser, present := item[userField]
+ if !present {
+ return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index))
+ }
+ user, ok := rawUser.(string)
+ user = strings.TrimSpace(user)
+ if !ok || user == "" {
+ return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index))
+ }
+ if _, requested := users[user]; !requested {
+ return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index))
+ }
+ rawTimestamp, present := item[timeField]
+ if !present {
+ return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", index))
+ }
+ timestamp, ok := attendancePositiveInteger(rawTimestamp)
+ if !ok {
+ return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项时间回显必须是大于 0 的整数毫秒时间戳", index))
+ }
+ if timestamp < startMillis || timestamp > endMillis {
+ return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项时间不在请求范围内", index))
+ }
+ }
+ return nil
+}
+
+func attendanceValidateApprovalBinding(items []map[string]any, operation string, requestedUsers []string, requestedTypes map[int]struct{}, startMillis, endMillis int64) error {
+ users := make(map[string]struct{}, len(requestedUsers))
+ for _, user := range requestedUsers {
+ users[user] = struct{}{}
+ }
+ for index, item := range items {
+ user, ok := item["userId"].(string)
+ user = strings.TrimSpace(user)
+ if !ok || user == "" {
+ return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index))
+ }
+ if _, requested := users[user]; !requested {
+ return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index))
+ }
+ bizType, ok := attendancePositiveInteger(item["bizType"])
+ if !ok {
+ return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少审批类型回显", index))
+ }
+ if _, requested := requestedTypes[int(bizType)]; !requested {
+ return responsecheck.Error(operation, "request_type_mismatch", fmt.Sprintf("第 %d 项审批类型不在请求集合中", index))
+ }
+ begin, beginOK := attendancePositiveInteger(item["beginTime"])
+ end, endOK := attendancePositiveInteger(item["endTime"])
+ if !beginOK || !endOK || end < begin || end < startMillis || begin > endMillis {
+ return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项审批时间不与请求范围相交", index))
+ }
+ }
+ return nil
+}
+
+func attendanceValidateMonthRange(startText, endText string) error {
+ start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startText), time.Local)
+ if err != nil {
+ return fmt.Errorf("--start 日期格式错误,应为 YYYY-MM-DD: %w", err)
+ }
+ end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endText), time.Local)
+ if err != nil {
+ return fmt.Errorf("--end 日期格式错误,应为 YYYY-MM-DD: %w", err)
+ }
+ if end.Before(start) {
+ return fmt.Errorf("--end 不能早于 --start")
+ }
+ if end.After(start.AddDate(0, 1, 0)) {
+ return fmt.Errorf("--start 到 --end 的跨度不能超过 1 个月")
+ }
+ return nil
+}
+
+func attendanceValidatePageRequest(rt *shortcut.RuntimeContext) error {
+ _, _, err := attendancePageInput(rt)
+ return err
+}
+
+func attendancePageEvidence(data map[string]any, operation string, page, limit, itemCount int) (bool, map[string]any, error) {
+ if page < 1 || limit < 1 || itemCount < 0 || itemCount > limit {
+ return false, nil, responsecheck.Error(operation, "invalid_pagination_request", "页码、页大小或当前项数无效")
+ }
+ result, err := responsecheck.RequireObjectResult(data, operation)
+ if err != nil {
+ return false, nil, err
+ }
+ extra := map[string]any{"page": page, "limit": limit}
+ if raw, present := result["currentPage"]; present {
+ currentPage, ok := attendanceInt(raw)
+ if !ok {
+ return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 currentPage 必须是整数")
+ }
+ if currentPage != page {
+ return false, nil, responsecheck.Error(operation, "pagination_page_mismatch", "服务端 currentPage 与请求页码不一致")
+ }
+ }
+ var evidence []bool
+ var totalPageValue, totalCountValue *int
+ if raw, present := result["totalPage"]; present {
+ totalPage, ok := attendanceInt(raw)
+ if !ok {
+ return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 必须是整数")
+ }
+ if totalPage < 0 {
+ return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 不能为负数")
+ }
+ extra["totalPage"] = totalPage
+ totalPageValue = &totalPage
+ if totalPage == 0 {
+ if page != 1 || itemCount != 0 {
+ return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "totalPage=0 只允许第一页为空")
+ }
+ evidence = append(evidence, true)
+ } else {
+ if page > totalPage {
+ return false, nil, responsecheck.Error(operation, "pagination_page_out_of_range", "请求页码超过服务端 totalPage")
+ }
+ evidence = append(evidence, page == totalPage)
+ }
+ }
+ if raw, present := result["totalCount"]; present {
+ totalCount, ok := attendanceInt(raw)
+ if !ok {
+ return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 必须是整数")
+ }
+ if totalCount < 0 {
+ return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 不能为负数")
+ }
+ extra["totalCount"] = totalCount
+ totalCountValue = &totalCount
+ if totalCount > 0 && itemCount == 0 {
+ return false, nil, responsecheck.Error(operation, "empty_page_with_nonzero_total", "totalCount>0 但当前集合为空,不能证明前进或终止")
+ }
+ pageStart := (page - 1) * limit
+ if pageStart > totalCount || pageStart+itemCount > totalCount {
+ return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "当前页项数与 totalCount/page/limit 矛盾")
+ }
+ evidence = append(evidence, page*limit >= totalCount)
+ }
+ if len(evidence) == 0 {
+ return false, nil, responsecheck.Error(operation, "missing_pagination_evidence", "分页响应缺少 totalCount/totalPage,无法证明当前页是否完整")
+ }
+ complete := evidence[0]
+ for _, candidate := range evidence[1:] {
+ if candidate != complete {
+ return false, nil, responsecheck.Error(operation, "conflicting_pagination_evidence", "服务端 totalCount 与 totalPage 对当前页是否完成给出矛盾证据")
+ }
+ }
+ if totalPageValue != nil && totalCountValue != nil {
+ expectedPages := 0
+ if *totalCountValue > 0 {
+ expectedPages = (*totalCountValue + limit - 1) / limit
+ }
+ if *totalPageValue != expectedPages {
+ return false, nil, responsecheck.Error(operation, "pagination_total_mismatch", "totalPage 与 totalCount/limit 矛盾")
+ }
+ }
+ if !complete && itemCount == 0 {
+ return false, nil, responsecheck.Error(operation, "pagination_no_progress", "未完成页没有返回任何稳定项,无法安全前进")
+ }
+ if !complete && itemCount != limit {
+ return false, nil, responsecheck.Error(operation, "pagination_short_page", "未完成页返回项数小于 limit,与继续分页证据矛盾")
+ }
+ if complete && totalCountValue != nil {
+ pageStart := (page - 1) * limit
+ if pageStart+itemCount != *totalCountValue {
+ return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "终止页项数未精确覆盖 totalCount")
+ }
+ }
+ if !complete {
+ extra["nextPage"] = page + 1
+ }
+ return complete, extra, nil
+}
+
+func attendancePageInput(rt *shortcut.RuntimeContext) (int, int, error) {
+ page, limit := rt.Int("page"), rt.Int("limit")
+ if page < 1 {
+ return 0, 0, fmt.Errorf("--page 必须大于等于 1")
+ }
+ if limit < 1 || limit > 200 {
+ return 0, 0, fmt.Errorf("--limit 必须在 1 到 200 之间")
+ }
+ return page, limit, nil
+}
+
+func attendanceInt(value any) (int, bool) {
+ switch number := value.(type) {
+ case int:
+ return number, true
+ case int32:
+ return int(number), true
+ case int64:
+ return int(number), true
+ case float64:
+ if number != float64(int(number)) {
+ return 0, false
+ }
+ return int(number), true
+ default:
+ return 0, false
+ }
+}
diff --git a/internal/shortcut/attendance/rule_class_projection_test.go b/internal/shortcut/attendance/rule_class_projection_test.go
index 1d6e0165..149ecb22 100644
--- a/internal/shortcut/attendance/rule_class_projection_test.go
+++ b/internal/shortcut/attendance/rule_class_projection_test.go
@@ -18,12 +18,12 @@ import (
"testing"
)
-// TestSearchClassProjectShiftVOShape guards against projection-data-loss:
+// TestCrossPlatformCoverageSearchClassProjectShiftVOShape guards against projection-data-loss:
// get_class_list nests the list under result.items and wraps each shift's
// identity under shiftVO. The projection must unwrap shiftVO or +search-class
// silently returns empty despite the backend returning shifts.
-func TestSearchClassProjectShiftVOShape(t *testing.T) {
- const raw = `{"result":{"items":[
+func TestCrossPlatformCoverageSearchClassProjectShiftVOShape(t *testing.T) {
+ const raw = `{"success":true,"result":{"items":[
{"shiftVO":{"id":957395083,"name":"default shift"}},
{"shiftVO":{"id":957395084,"name":"morning shift"}}
]}}`
@@ -31,7 +31,10 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) {
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
- got := searchClassProject(data)
+ got, err := searchClassProject(data)
+ if err != nil {
+ t.Fatalf("project class response: %v", err)
+ }
if len(got) != 2 {
t.Fatalf("lower/upper mismatch: 2 shifts in backend, projection returned %d (%v)", len(got), got)
}
@@ -40,18 +43,18 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) {
}
}
-// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList)
+// TestCrossPlatformCoverageSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList)
// and get_adjustment_rule (result.adjustmentList), both wrapping the rule under
// entityVO. The resolver must probe those keys and unwrap entityVO or
// +search-overtime-rule / +search-adjustment-rule silently return empty.
-func TestSearchRuleProjectEntityVOShape(t *testing.T) {
+func TestCrossPlatformCoverageSearchRuleProjectEntityVOShape(t *testing.T) {
for _, tc := range []struct {
name, key string
}{
{"overtime", "atRuleList"},
{"adjustment", "adjustmentList"},
} {
- raw := `{"result":{"` + tc.key + `":[
+ raw := `{"success":true,"result":{"` + tc.key + `":[
{"entityVO":{"id":11,"name":"weekday overtime"},"permissionVO":{}},
{"entityVO":{"id":12,"name":"holiday overtime"},"permissionVO":{}}
]}}`
@@ -59,7 +62,10 @@ func TestSearchRuleProjectEntityVOShape(t *testing.T) {
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err)
}
- got := searchRuleProject(data)
+ got, err := searchRuleProject(data, "attendance-wukong/test", "result."+tc.key)
+ if err != nil {
+ t.Fatalf("[%s] project rule response: %v", tc.name, err)
+ }
if len(got) != 2 {
t.Fatalf("[%s] lower/upper mismatch: result.%s has 2 entries, projection returned %d", tc.name, tc.key, len(got))
}
diff --git a/internal/shortcut/builtin/attendance_semantic_catalog_test.go b/internal/shortcut/builtin/attendance_semantic_catalog_test.go
new file mode 100644
index 00000000..9a8b0f65
--- /dev/null
+++ b/internal/shortcut/builtin/attendance_semantic_catalog_test.go
@@ -0,0 +1,114 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package builtin_test
+
+import (
+ "encoding/json"
+ "os"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
+ raw, err := os.ReadFile("../semantic_catalog_attendance.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var source chatSemanticCatalogFixture
+ if err := json.Unmarshal(raw, &source); err != nil {
+ t.Fatal(err)
+ }
+ if source.Service != "attendance" {
+ t.Fatalf("service = %q", source.Service)
+ }
+ registered := map[string]shortcut.Shortcut{}
+ for _, item := range shortcut.All() {
+ if item.Service != "attendance" {
+ continue
+ }
+ if _, exists := registered[item.Command]; exists {
+ t.Fatalf("duplicate %s", item.Command)
+ }
+ registered[item.Command] = item
+ }
+ if len(registered) != 35 || len(source.Shortcuts) != 35 {
+ t.Fatalf("registered/catalog = %d/%d, want 35/35", len(registered), len(source.Shortcuts))
+ }
+ public := 0
+ compatibilityVisible := 0
+ wantCompatibilityVisible := map[string]bool{
+ "+get-adjustment-rule": true,
+ "+get-checkin-record": true,
+ "+get-leave-records": true,
+ "+get-self-setting": true,
+ "+get-schedule": true,
+ "+get-summary": true,
+ "+list-leave-types": true,
+ "+my-attendance": true,
+ "+query-report-data": true,
+ "+search-group": true,
+ "+this-month": true,
+ }
+ var missing, stale []string
+ for command, item := range registered {
+ record, ok := source.Shortcuts[command]
+ if !ok {
+ missing = append(missing, command)
+ continue
+ }
+ availability := record.Availability
+ if availability == "" {
+ availability = source.Availability
+ }
+ if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
+ t.Errorf("%s: semantic review facts drifted", command)
+ }
+ if item.Risk != record.Risk {
+ t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
+ }
+ if record.Public {
+ public++
+ if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
+ t.Errorf("%s: public available shortcut is not executable/visible", command)
+ }
+ if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
+ t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command)
+ }
+ } else if availability == shortcut.AvailabilityAvailable || item.Hidden == record.CompatibilityVisible {
+ t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command)
+ }
+ if availability == shortcut.AvailabilityUnavailable && item.Contract.Interface != nil {
+ wantInterfaceAvailability := "unavailable"
+ if record.CompatibilityVisible {
+ wantInterfaceAvailability = "available"
+ }
+ if item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" {
+ t.Errorf("%s: runtime compatibility interface is not explicit", command)
+ }
+ }
+ if record.CompatibilityVisible {
+ compatibilityVisible++
+ }
+ if record.CompatibilityVisible != wantCompatibilityVisible[command] {
+ t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
+ }
+ }
+ for command := range source.Shortcuts {
+ if _, ok := registered[command]; !ok {
+ stale = append(stale, command)
+ }
+ }
+ sort.Strings(missing)
+ sort.Strings(stale)
+ if len(missing) > 0 || len(stale) > 0 {
+ t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
+ }
+ if public != 8 || compatibilityVisible != 11 {
+ t.Fatalf("public/compatibility-visible attendance shortcuts = %d/%d, want 8/11", public, compatibilityVisible)
+ }
+}
diff --git a/internal/shortcut/builtin/chat_semantic_catalog_test.go b/internal/shortcut/builtin/chat_semantic_catalog_test.go
index b83ec229..ef390ee2 100644
--- a/internal/shortcut/builtin/chat_semantic_catalog_test.go
+++ b/internal/shortcut/builtin/chat_semantic_catalog_test.go
@@ -17,13 +17,14 @@ type chatSemanticCatalogFixture struct {
Service string `json:"service"`
Availability shortcut.Availability `json:"default_availability"`
Shortcuts map[string]struct {
- Disposition shortcut.SemanticDisposition `json:"disposition"`
- SemanticDelta string `json:"semantic_delta"`
- Risk shortcut.Risk `json:"risk"`
- Availability shortcut.Availability `json:"availability"`
- Primary string `json:"primary"`
- Public bool `json:"public"`
- Reviewed bool `json:"reviewed"`
+ Disposition shortcut.SemanticDisposition `json:"disposition"`
+ SemanticDelta string `json:"semantic_delta"`
+ Risk shortcut.Risk `json:"risk"`
+ Availability shortcut.Availability `json:"availability"`
+ Primary string `json:"primary"`
+ Public bool `json:"public"`
+ CompatibilityVisible bool `json:"compatibility_visible"`
+ Reviewed bool `json:"reviewed"`
} `json:"shortcuts"`
}
diff --git a/internal/shortcut/builtin/ding_semantic_catalog_test.go b/internal/shortcut/builtin/ding_semantic_catalog_test.go
new file mode 100644
index 00000000..af96a75d
--- /dev/null
+++ b/internal/shortcut/builtin/ding_semantic_catalog_test.go
@@ -0,0 +1,101 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package builtin_test
+
+import (
+ "encoding/json"
+ "os"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
+ raw, err := os.ReadFile("../semantic_catalog_ding.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var source chatSemanticCatalogFixture
+ if err := json.Unmarshal(raw, &source); err != nil {
+ t.Fatal(err)
+ }
+ registered := map[string]shortcut.Shortcut{}
+ for _, item := range shortcut.All() {
+ if item.Service == "ding" {
+ registered[item.Command] = item
+ }
+ }
+ if source.Service != "ding" || len(registered) != 5 || len(source.Shortcuts) != 5 {
+ t.Fatalf("service/registered/catalog=%s/%d/%d, want ding/5/5", source.Service, len(registered), len(source.Shortcuts))
+ }
+ wantCompatibilityVisible := map[string]bool{
+ "+list": true,
+ "+recall-personal": true,
+ "+send-personal": true,
+ }
+ wantCompatibilityAvailability := map[string]shortcut.Availability{
+ "+list": shortcut.AvailabilityUnavailable,
+ "+recall-personal": shortcut.AvailabilityAvailable,
+ "+send-personal": shortcut.AvailabilityAvailable,
+ }
+ public, unavailable, compatibilityVisible := 0, 0, 0
+ var missing, stale []string
+ for command, item := range registered {
+ record, ok := source.Shortcuts[command]
+ if !ok {
+ missing = append(missing, command)
+ continue
+ }
+ availability := record.Availability
+ if availability == "" {
+ availability = source.Availability
+ }
+ if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
+ t.Errorf("%s semantic review facts drifted", command)
+ }
+ if item.Risk != record.Risk || item.Availability != availability {
+ t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
+ }
+ if record.Public {
+ public++
+ if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("ding", command) {
+ t.Errorf("%s public availability/visibility drift", command)
+ }
+ if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
+ t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
+ }
+ } else {
+ if record.CompatibilityVisible {
+ compatibilityVisible++
+ if item.Hidden || !item.CompatibilityVisible || availability != wantCompatibilityAvailability[command] || shortcut.InPublicCatalog("ding", command) {
+ t.Errorf("%s compatibility-visible boundary drift", command)
+ }
+ } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) {
+ t.Errorf("%s nonpublic shortcut visibility drift", command)
+ }
+ if availability == shortcut.AvailabilityUnavailable {
+ unavailable++
+ }
+ }
+ if record.CompatibilityVisible != wantCompatibilityVisible[command] {
+ t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
+ }
+ }
+ for command := range source.Shortcuts {
+ if _, ok := registered[command]; !ok {
+ stale = append(stale, command)
+ }
+ }
+ sort.Strings(missing)
+ sort.Strings(stale)
+ if len(missing) > 0 || len(stale) > 0 {
+ t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
+ }
+ if public != 1 || unavailable != 2 || compatibilityVisible != len(wantCompatibilityVisible) {
+ t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/2/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible))
+ }
+}
diff --git a/internal/shortcut/builtin/mail_semantic_catalog_test.go b/internal/shortcut/builtin/mail_semantic_catalog_test.go
new file mode 100644
index 00000000..63e3dd03
--- /dev/null
+++ b/internal/shortcut/builtin/mail_semantic_catalog_test.go
@@ -0,0 +1,111 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package builtin_test
+
+import (
+ "encoding/json"
+ "os"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
+ raw, err := os.ReadFile("../semantic_catalog_mail.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var source chatSemanticCatalogFixture
+ if err := json.Unmarshal(raw, &source); err != nil {
+ t.Fatal(err)
+ }
+ if source.Service != "mail" {
+ t.Fatalf("service = %q", source.Service)
+ }
+ registered := map[string]shortcut.Shortcut{}
+ for _, item := range shortcut.All() {
+ if item.Service == "mail" {
+ registered[item.Command] = item
+ }
+ }
+ if len(registered) != 18 || len(source.Shortcuts) != 18 {
+ t.Fatalf("registered/catalog = %d/%d, want 18/18", len(registered), len(source.Shortcuts))
+ }
+ var missing, stale []string
+ public := 0
+ unavailable := 0
+ compatibilityVisible := 0
+ wantCompatibilityVisible := map[string]bool{
+ "+contact-list": true,
+ "+recent-mail": true,
+ "+tag-list": true,
+ "+template-list": true,
+ "+thread-list": true,
+ "+unread-mail": true,
+ }
+ for command, item := range registered {
+ record, ok := source.Shortcuts[command]
+ if !ok {
+ missing = append(missing, command)
+ continue
+ }
+ availability := record.Availability
+ if availability == "" {
+ availability = source.Availability
+ }
+ if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
+ t.Errorf("%s: semantic review facts drifted", command)
+ }
+ if item.Risk != record.Risk {
+ t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
+ }
+ if record.Public {
+ public++
+ if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable {
+ t.Errorf("%s: public available shortcut is not executable/visible", command)
+ }
+ if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
+ t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command)
+ }
+ }
+ if availability == shortcut.AvailabilityUnavailable {
+ unavailable++
+ if item.Hidden == record.CompatibilityVisible || item.Availability != shortcut.AvailabilityUnavailable {
+ t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command)
+ }
+ wantInterfaceAvailability := "unavailable"
+ if record.CompatibilityVisible {
+ wantInterfaceAvailability = "available"
+ }
+ if item.Contract.Interface == nil || item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" {
+ t.Errorf("%s: runtime compatibility interface is not explicit", command)
+ }
+ if item.Contract.Result != nil || item.Contract.Pagination != nil || item.OutputRollout != output.RolloutLegacyOnly {
+ t.Errorf("%s: unavailable runtime still publishes result/pagination/unified rollout", command)
+ }
+ }
+ if record.CompatibilityVisible {
+ compatibilityVisible++
+ }
+ if record.CompatibilityVisible != wantCompatibilityVisible[command] {
+ t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
+ }
+ }
+ for command := range source.Shortcuts {
+ if _, ok := registered[command]; !ok {
+ stale = append(stale, command)
+ }
+ }
+ sort.Strings(missing)
+ sort.Strings(stale)
+ if len(missing) > 0 || len(stale) > 0 {
+ t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
+ }
+ if public != 8 || unavailable != 10 || compatibilityVisible != 6 {
+ t.Fatalf("mail public/unavailable/compatibility-visible shortcuts = %d/%d/%d, want 8/10/6", public, unavailable, compatibilityVisible)
+ }
+}
diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go
new file mode 100644
index 00000000..9efc0bef
--- /dev/null
+++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go
@@ -0,0 +1,102 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package builtin_test
+
+import (
+ "encoding/json"
+ "os"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
+ raw, err := os.ReadFile("../semantic_catalog_oa.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var source chatSemanticCatalogFixture
+ if err := json.Unmarshal(raw, &source); err != nil {
+ t.Fatal(err)
+ }
+ registered := map[string]shortcut.Shortcut{}
+ for _, item := range shortcut.All() {
+ if item.Service == "oa" {
+ registered[item.Command] = item
+ }
+ }
+ if source.Service != "oa" || len(registered) != 10 || len(source.Shortcuts) != 10 {
+ t.Fatalf("service/registered/catalog=%s/%d/%d, want oa/10/10", source.Service, len(registered), len(source.Shortcuts))
+ }
+ wantCompatibilityVisible := map[string]bool{
+ "+list-cc": true,
+ "+list-executed": true,
+ "+list-forms": true,
+ "+list-pending": true,
+ "+list-submitted": true,
+ "+my-initiated": true,
+ }
+ public, unavailable, compatibilityVisible := 0, 0, 0
+ var missing, stale []string
+ for command, item := range registered {
+ record, ok := source.Shortcuts[command]
+ if !ok {
+ missing = append(missing, command)
+ continue
+ }
+ availability := record.Availability
+ if availability == "" {
+ availability = source.Availability
+ }
+ if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
+ t.Errorf("%s semantic review facts drifted", command)
+ }
+ if item.Risk != record.Risk || item.Availability != availability {
+ t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
+ }
+ if item.Contract.Interface == nil || item.Contract.Interface.Availability != string(availability) {
+ t.Errorf("%s final interface availability=%v, want %q", command, item.Contract.Interface, availability)
+ }
+ if record.Public {
+ public++
+ if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("oa", command) {
+ t.Errorf("%s public availability/visibility drift", command)
+ }
+ if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
+ t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
+ }
+ } else {
+ if record.CompatibilityVisible {
+ compatibilityVisible++
+ if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityAvailable || shortcut.InPublicCatalog("oa", command) {
+ t.Errorf("%s compatibility-visible boundary drift", command)
+ }
+ } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("oa", command) {
+ t.Errorf("%s nonpublic shortcut visibility drift", command)
+ }
+ if availability == shortcut.AvailabilityUnavailable {
+ unavailable++
+ }
+ }
+ if record.CompatibilityVisible != wantCompatibilityVisible[command] {
+ t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
+ }
+ }
+ for command := range source.Shortcuts {
+ if _, ok := registered[command]; !ok {
+ stale = append(stale, command)
+ }
+ }
+ sort.Strings(missing)
+ sort.Strings(stale)
+ if len(missing) > 0 || len(stale) > 0 {
+ t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
+ }
+ if public != 1 || unavailable != 0 || compatibilityVisible != len(wantCompatibilityVisible) {
+ t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/0/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible))
+ }
+}
diff --git a/internal/shortcut/builtin/report_semantic_catalog_test.go b/internal/shortcut/builtin/report_semantic_catalog_test.go
new file mode 100644
index 00000000..7e2e66de
--- /dev/null
+++ b/internal/shortcut/builtin/report_semantic_catalog_test.go
@@ -0,0 +1,83 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package builtin_test
+
+import (
+ "encoding/json"
+ "os"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func TestCrossPlatformCoverageReportSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
+ raw, err := os.ReadFile("../semantic_catalog_report.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var source chatSemanticCatalogFixture
+ if err := json.Unmarshal(raw, &source); err != nil {
+ t.Fatal(err)
+ }
+ registered := map[string]shortcut.Shortcut{}
+ for _, item := range shortcut.All() {
+ if item.Service == "report" {
+ registered[item.Command] = item
+ }
+ }
+ if source.Service != "report" || len(registered) != 4 || len(source.Shortcuts) != 4 {
+ t.Fatalf("service/registered/catalog=%s/%d/%d, want report/4/4", source.Service, len(registered), len(source.Shortcuts))
+ }
+ public, unavailable := 0, 0
+ var missing, stale []string
+ for command, item := range registered {
+ record, ok := source.Shortcuts[command]
+ if !ok {
+ missing = append(missing, command)
+ continue
+ }
+ availability := record.Availability
+ if availability == "" {
+ availability = source.Availability
+ }
+ if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
+ t.Errorf("%s semantic review facts drifted", command)
+ }
+ if item.Risk != record.Risk || item.Availability != availability {
+ t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability)
+ }
+ if record.Public {
+ public++
+ if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("report", command) {
+ t.Errorf("%s public availability/visibility drift", command)
+ }
+ if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive {
+ t.Errorf("%s lacks public Contract/Safety/Result/unified output", command)
+ }
+ } else {
+ if !item.Hidden || shortcut.InPublicCatalog("report", command) {
+ t.Errorf("%s nonpublic shortcut is visible", command)
+ }
+ if availability == shortcut.AvailabilityUnavailable {
+ unavailable++
+ }
+ }
+ }
+ for command := range source.Shortcuts {
+ if _, ok := registered[command]; !ok {
+ stale = append(stale, command)
+ }
+ }
+ sort.Strings(missing)
+ sort.Strings(stale)
+ if len(missing) > 0 || len(stale) > 0 {
+ t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
+ }
+ if public != 4 || unavailable != 0 {
+ t.Fatalf("public/unavailable=%d/%d, want 4/0", public, unavailable)
+ }
+}
diff --git a/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go b/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go
index e3f2ec4a..342d07b1 100644
--- a/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go
+++ b/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go
@@ -72,14 +72,24 @@ func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPaginatio
if err != nil || len(events) != 0 || !page.Known || page.HasMore {
t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err)
}
+ var serviceEmptySentinel map[string]any
+ if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"attendees":null,"categories":null,"meetingRooms":null,"reminders":null}],"hasMore":false}}`), &serviceEmptySentinel); err != nil {
+ t.Fatal(err)
+ }
+ events, page, err = eventListProject(serviceEmptySentinel)
+ if err != nil || events == nil || len(events) != 0 || !page.Known || page.HasMore {
+ t.Fatalf("service empty sentinel: events=%#v page=%+v err=%v", events, page, err)
+ }
for name, payload := range map[string]string{
- "missing collection": `{"success":true,"result":{"hasMore":false}}`,
- "bad collection": `{"success":true,"result":{"events":{}}}`,
- "bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
- "empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
- "missing pagination": `{"success":true,"result":{"events":[]}}`,
- "missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
+ "missing collection": `{"success":true,"result":{"hasMore":false}}`,
+ "bad collection": `{"success":true,"result":{"events":{}}}`,
+ "bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
+ "empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
+ "unknown null item": `{"success":true,"result":{"events":[{"summary":null}],"hasMore":false}}`,
+ "sentinel with cursor": `{"success":true,"result":{"events":[{"attendees":null}],"hasMore":false,"nextCursor":"unexpected"}}`,
+ "missing pagination": `{"success":true,"result":{"events":[]}}`,
+ "missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
} {
t.Run(name, func(t *testing.T) {
var data map[string]any
diff --git a/internal/shortcut/calendar/common.go b/internal/shortcut/calendar/common.go
index e0d7c27f..f54b6fa0 100644
--- a/internal/shortcut/calendar/common.go
+++ b/internal/shortcut/calendar/common.go
@@ -16,6 +16,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
)
const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
@@ -205,6 +206,9 @@ func requireCalendarCollection(data map[string]any, operation string, keys ...st
if !ok {
return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
}
+ if key == "events" {
+ items, _ = calendarcompat.NormalizeTerminalEmptyEvents(items, container)
+ }
if err := validateCalendarCollectionItems(items, operation, key); err != nil {
return nil, nil, err
}
diff --git a/internal/shortcut/calendarcompat/empty_events.go b/internal/shortcut/calendarcompat/empty_events.go
new file mode 100644
index 00000000..fc96e300
--- /dev/null
+++ b/internal/shortcut/calendarcompat/empty_events.go
@@ -0,0 +1,48 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+// Package calendarcompat contains narrow compatibility adapters for observed
+// Calendar wire shapes shared by the calendar and smart Shortcut packages.
+package calendarcompat
+
+// NormalizeTerminalEmptyEvents recognizes the Calendar service's legacy empty
+// page sentinel. Some deployments encode an exhausted empty page as one object
+// whose collection fields are all null instead of events:[]. Only that exact
+// terminal shape is normalized; arbitrary id-less or mixed event rows remain
+// available to the strict caller validation and must fail closed.
+func NormalizeTerminalEmptyEvents(items []any, container map[string]any) ([]any, bool) {
+ if len(items) != 1 || container == nil {
+ return items, false
+ }
+ hasMore, ok := container["hasMore"].(bool)
+ if !ok || hasMore || hasNonEmptyCursor(container) {
+ return items, false
+ }
+ placeholder, ok := items[0].(map[string]any)
+ knownNullFields := map[string]struct{}{
+ "attendees": {},
+ "categories": {},
+ "meetingRooms": {},
+ "reminders": {},
+ }
+ if !ok || len(placeholder) != len(knownNullFields) {
+ return items, false
+ }
+ for key, value := range placeholder {
+ if _, known := knownNullFields[key]; !known || value != nil {
+ return items, false
+ }
+ }
+ return []any{}, true
+}
+
+func hasNonEmptyCursor(container map[string]any) bool {
+ for _, key := range []string{"nextCursor", "nextToken", "pageToken"} {
+ value, present := container[key]
+ if !present || value == nil || value == "" {
+ continue
+ }
+ return true
+ }
+ return false
+}
diff --git a/internal/shortcut/calendarcompat/empty_events_test.go b/internal/shortcut/calendarcompat/empty_events_test.go
new file mode 100644
index 00000000..c5e5d930
--- /dev/null
+++ b/internal/shortcut/calendarcompat/empty_events_test.go
@@ -0,0 +1,44 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package calendarcompat
+
+import "testing"
+
+func TestCrossPlatformCoverageNormalizeTerminalEmptyEvents(t *testing.T) {
+ sentinel := []any{map[string]any{
+ "attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
+ }}
+ if got, ok := NormalizeTerminalEmptyEvents(sentinel, map[string]any{"hasMore": false}); !ok || got == nil || len(got) != 0 {
+ t.Fatalf("terminal sentinel = %#v, %v; want explicit empty slice", got, ok)
+ }
+
+ for name, items := range map[string][]any{
+ "empty object": {map[string]any{}},
+ "missing fields": {map[string]any{"attendees": nil}},
+ "unknown field": {map[string]any{
+ "attendees": nil, "categories": nil, "meetingRooms": nil, "summary": nil,
+ }},
+ "non-null field": {map[string]any{
+ "attendees": []any{}, "categories": nil, "meetingRooms": nil, "reminders": nil,
+ }},
+ "mixed rows": {sentinel[0], map[string]any{"id": "event-1"}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, ok := NormalizeTerminalEmptyEvents(items, map[string]any{"hasMore": false}); ok {
+ t.Fatalf("invalid sentinel accepted: %#v", items)
+ }
+ })
+ }
+ for name, container := range map[string]map[string]any{
+ "missing terminal evidence": {},
+ "more pages": {"hasMore": true},
+ "cursor": {"hasMore": false, "nextCursor": "next"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, ok := NormalizeTerminalEmptyEvents(sentinel, container); ok {
+ t.Fatalf("non-terminal sentinel accepted with %#v", container)
+ }
+ })
+ }
+}
diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go
index 0bdbe1ab..2ea6714a 100644
--- a/internal/shortcut/chat/coverage_completion_test.go
+++ b/internal/shortcut/chat/coverage_completion_test.go
@@ -437,7 +437,7 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}{
{
name: "user group mentions",
- args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
+ args: []string{"--identity", "user", "--group", "cid", "--text", "@" + fixtureCurrentDOpenID + " <@" + fixtureCurrentDOpenID2 + "> x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
},
{
@@ -505,6 +505,69 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T
}
}
+func TestCrossPlatformCoverageMessagesSendRejectsMismatchedCurrentUserMentions(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ atIDs string
+ atAll bool
+ wantErr string
+ }{
+ {
+ name: "declared member missing from body",
+ body: "请查收",
+ atIDs: fixtureCurrentDOpenID,
+ wantErr: "必须在正文中使用对应",
+ },
+ {
+ name: "declared member only appears as a longer token prefix",
+ body: "@" + fixtureCurrentDOpenID + "_suffix 请查收",
+ atIDs: fixtureCurrentDOpenID,
+ wantErr: "必须在正文中使用对应",
+ },
+ {
+ name: "body member missing from declaration",
+ body: "<@" + fixtureCurrentDOpenID + "> 请查收",
+ wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
+ },
+ {
+ name: "body at-all missing from declaration",
+ body: "<@all> 请查收",
+ wantErr: "必须同时指定 --at-all",
+ },
+ {
+ name: "bare body member missing from declaration",
+ body: "@" + fixtureCurrentDOpenID + " 请查收",
+ wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fake := &larkAlignmentCaller{}
+ helpers.InitDeps(fake)
+ root := newPlatformCoverageRoot()
+ args := []string{
+ "chat", "+messages-send", "--identity", "user",
+ "--group", "cid", "--markdown", tt.body, "--yes",
+ }
+ if tt.atIDs != "" {
+ args = append(args, "--at-open-dingtalk-ids", tt.atIDs)
+ }
+ if tt.atAll {
+ args = append(args, "--at-all")
+ }
+ root.SetArgs(args)
+ err := root.Execute()
+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
+ t.Fatalf("error = %v, want %q", err, tt.wantErr)
+ }
+ if len(fake.calls) != 0 {
+ t.Fatalf("invalid mention contract reached lower service: %#v", fake.calls)
+ }
+ })
+ }
+}
+
func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) {
flags := append([]shortcut.Flag(nil), MessagesSend.Flags...)
for i := range flags {
diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go
index 2526af79..0946632c 100644
--- a/internal/shortcut/chat/gap_fill_test.go
+++ b/internal/shortcut/chat/gap_fill_test.go
@@ -585,6 +585,119 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T)
}
}
+func TestCrossPlatformCoverageMessagesSendCurrentUserDirectLocalFileUsesTransportSpecificTargets(t *testing.T) {
+ t.Chdir(t.TempDir())
+ if err := os.WriteFile("direct.bin", []byte("direct-file"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut {
+ t.Errorf("upload method = %s", r.Method)
+ }
+ if _, err := io.Copy(io.Discard, r.Body); err != nil {
+ t.Error(err)
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(uploadServer.Close)
+
+ tests := []struct {
+ name string
+ targetArgs []string
+ requestedType string
+ wantOpenID string
+ wantUserResolve bool
+ }{
+ {
+ name: "explicit open id file",
+ targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID},
+ requestedType: "file",
+ wantOpenID: fixtureCurrentDOpenID,
+ },
+ {
+ name: "resolved user audio",
+ targetArgs: []string{"--user", "user-id"},
+ requestedType: "audio",
+ wantOpenID: "D-resolved",
+ wantUserResolve: true,
+ },
+ {
+ name: "explicit open id video",
+ targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID2},
+ requestedType: "video",
+ wantOpenID: fixtureCurrentDOpenID2,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fake := &larkAlignmentCaller{responses: map[string]string{
+ "im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"upload-key"}`,
+ "im/commit_conversation_file_upload": `{"result":{"dentryId":31,"spaceId":41}}`,
+ "chat/send_personal_message": `{"result":{"openMessageId":"sent-direct-file"}}`,
+ }}
+ helpers.InitDeps(fake)
+ root := newPlatformCoverageRoot()
+ var output bytes.Buffer
+ root.SetOut(&output)
+ args := []string{
+ "chat", "+messages-send", "--identity", "user",
+ "--msg-type", tt.requestedType,
+ "--file", "./direct.bin",
+ "--idempotency-key", "direct-" + tt.requestedType,
+ "--yes",
+ }
+ args = append(args, tt.targetArgs...)
+ root.SetArgs(args)
+ if err := root.Execute(); err != nil {
+ t.Fatal(err)
+ }
+
+ callOffset := 0
+ if tt.wantUserResolve {
+ if len(fake.calls) == 0 || fake.calls[0].product != "contact" ||
+ fake.calls[0].tool != "search_contact_by_key_word" {
+ t.Fatalf("resolution call = %#v", fake.calls)
+ }
+ callOffset = 1
+ }
+ if len(fake.calls) != callOffset+3 {
+ t.Fatalf("calls = %#v, want resolution plus init, commit, send", fake.calls)
+ }
+ initCall := fake.calls[callOffset]
+ commitCall := fake.calls[callOffset+1]
+ sendCall := fake.calls[callOffset+2]
+ for _, call := range []larkAlignmentCall{initCall, commitCall} {
+ if call.product != "im" || call.args["openDingTalkId"] != tt.wantOpenID {
+ t.Fatalf("upload call = %#v, want openDingTalkId %q", call, tt.wantOpenID)
+ }
+ if _, exists := call.args["receiverOpenDingTalkId"]; exists {
+ t.Fatalf("send-only receiver field leaked into upload call: %#v", call.args)
+ }
+ }
+ if initCall.tool != "init_conversation_file_upload" ||
+ commitCall.tool != "commit_conversation_file_upload" {
+ t.Fatalf("upload calls = %#v, %#v", initCall, commitCall)
+ }
+ if sendCall.product != "chat" || sendCall.tool != "send_personal_message" ||
+ sendCall.args["receiverOpenDingTalkId"] != tt.wantOpenID {
+ t.Fatalf("send call = %#v, want receiverOpenDingTalkId %q", sendCall, tt.wantOpenID)
+ }
+ if _, exists := sendCall.args["openDingTalkId"]; exists {
+ t.Fatalf("upload-only target field leaked into send call: %#v", sendCall.args)
+ }
+
+ var payload map[string]any
+ if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload["requestedMessageType"] != tt.requestedType ||
+ payload["effectiveMessageType"] != "file" {
+ t.Fatalf("output = %#v", payload)
+ }
+ })
+ }
+}
+
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil {
diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go
index cea41825..18ba4296 100644
--- a/internal/shortcut/chat/lark_alignment_test.go
+++ b/internal/shortcut/chat/lark_alignment_test.go
@@ -10,6 +10,7 @@ import (
"encoding/json"
"errors"
"reflect"
+ "strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -337,8 +338,15 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te
}
}
if tt.bodyKey == "content" {
+ rawContent := call.args[tt.bodyKey].(string)
+ if strings.Contains(rawContent, `\u003c`) || strings.Contains(rawContent, `\u003e`) {
+ t.Errorf("content = %q; current-user mention tokens must remain literal", rawContent)
+ }
+ if !strings.Contains(rawContent, "<@"+fixtureCurrentDOpenID+">") {
+ t.Errorf("content = %q; missing literal current-user mention token", rawContent)
+ }
var content map[string]string
- if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil {
+ if err := json.Unmarshal([]byte(rawContent), &content); err != nil {
t.Fatal(err)
}
if content["text"] != tt.body {
diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go
index 1977d077..670ae6e8 100644
--- a/internal/shortcut/chat/unified_send.go
+++ b/internal/shortcut/chat/unified_send.go
@@ -11,6 +11,7 @@ import (
"os"
"strings"
"time"
+ "unicode"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -18,6 +19,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
@@ -181,6 +183,12 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
}
+ if (group != "" || chatQuery != "") && (contentType == "text" || contentType == "markdown") {
+ if err := validateCurrentUserMentionConsistency(
+ messagesSendBody(rt), atOpenIDs, rt.Bool("at-all")); err != nil {
+ return err
+ }
+ }
case "bot":
if chatQuery != "" || userQuery != "" {
return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID")
@@ -234,6 +242,89 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
return nil
}
+func validateCurrentUserMentionConsistency(body string, atOpenIDs []string, atAll bool) error {
+ declared := make(map[string]struct{}, len(atOpenIDs))
+ for _, id := range atOpenIDs {
+ declared[id] = struct{}{}
+ }
+
+ missingPlaceholders := make([]string, 0)
+ for _, id := range atOpenIDs {
+ if !containsCurrentUserMentionToken(body, id) {
+ missingPlaceholders = append(missingPlaceholders, id)
+ }
+ }
+ if len(missingPlaceholders) > 0 {
+ return apperrors.NewValidation(fmt.Sprintf(
+ "--at-open-dingtalk-ids 中的成员必须在正文中使用对应 <@openDingTalkId> 占位符;缺少: %s",
+ strings.Join(missingPlaceholders, ","),
+ ))
+ }
+
+ undeclared := make([]string, 0)
+ for _, id := range currentUserMentionBodyIDs(body) {
+ if _, ok := declared[id]; !ok {
+ undeclared = append(undeclared, id)
+ }
+ }
+ if len(undeclared) > 0 {
+ return apperrors.NewValidation(fmt.Sprintf(
+ "正文中的成员 <@openDingTalkId> 占位符必须同时通过 --at-open-dingtalk-ids 声明;未声明: %s",
+ strings.Join(undeclared, ","),
+ ))
+ }
+ if !atAll && containsCurrentUserMentionToken(body, "all") {
+ return apperrors.NewValidation("正文中的 <@all> 占位符必须同时指定 --at-all")
+ }
+ return nil
+}
+
+func containsCurrentUserMentionToken(body, id string) bool {
+ placeholder := "@" + id
+ for searchFrom := 0; ; {
+ offset := strings.Index(body[searchFrom:], placeholder)
+ if offset < 0 {
+ return false
+ }
+ end := searchFrom + offset + len(placeholder)
+ if end == len(body) {
+ return true
+ }
+ next, _ := utf8.DecodeRuneInString(body[end:])
+ if !unicode.IsLetter(next) && !unicode.IsDigit(next) && next != '_' && next != '-' {
+ return true
+ }
+ searchFrom = end
+ }
+}
+
+func currentUserMentionBodyIDs(body string) []string {
+ ids := make([]string, 0)
+ for searchFrom := 0; ; {
+ offset := strings.IndexByte(body[searchFrom:], '@')
+ if offset < 0 {
+ break
+ }
+ start := searchFrom + offset + 1
+ end := start
+ for end < len(body) {
+ current := body[end]
+ if (current < 'a' || current > 'z') &&
+ (current < 'A' || current > 'Z') &&
+ (current < '0' || current > '9') {
+ break
+ }
+ end++
+ }
+ id := body[start:end]
+ if targetresolver.LooksLikeCurrentDOpenDingTalkID(id) {
+ ids = appendUniqueShortcutString(ids, id)
+ }
+ searchFrom = end
+ }
+ return ids
+}
+
func executeMessagesSend(rt *shortcut.RuntimeContext) error {
identity := messagesSendIdentity(rt)
body := messagesSendBody(rt)
@@ -499,7 +590,7 @@ func resolvedUserMarkdownParams(
if target.GroupID != "" {
body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true)
}
- content, _ := json.Marshal(map[string]string{"title": title, "text": body})
+ content, _ := jsonutil.Marshal(map[string]string{"title": title, "text": body})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "markdown",
"content": string(content),
@@ -573,8 +664,10 @@ func executeMessagesSendUserFile(
if err != nil {
return err
}
- targetArgs := map[string]any{}
- addMessagesSendUserTarget(targetArgs, group, openID)
+ uploadTargetArgs := map[string]any{}
+ addMessagesSendUserUploadTarget(uploadTargetArgs, group, openID)
+ sendTargetArgs := map[string]any{}
+ addMessagesSendUserTarget(sendTargetArgs, group, openID)
idempotencyKey := messagesSendIdempotencyKey(rt)
if rt.DryRun() {
return rt.Output(map[string]any{
@@ -587,6 +680,7 @@ func executeMessagesSendUserFile(
{
"identity": "user",
"tool": "init/commit_conversation_file_upload",
+ "target": uploadTargetArgs,
"file": map[string]any{
"path": rawPath,
"name": meta.FileName,
@@ -598,7 +692,7 @@ func executeMessagesSendUserFile(
"tool": "send_personal_message",
"requestedMessageType": requestedType,
"effectiveMessageType": "file",
- "target": targetArgs,
+ "target": sendTargetArgs,
},
},
})
@@ -607,7 +701,7 @@ func executeMessagesSendUserFile(
rt.Command().Context(), messagesSendFileUploadTimeout)
defer cancelUpload()
commitText, err := helpers.UploadConversationLocalFile(
- uploadContext, targetArgs, meta, idempotencyKey)
+ uploadContext, uploadTargetArgs, meta, idempotencyKey)
if err != nil {
return err
}
@@ -653,6 +747,14 @@ func addMessagesSendUserTarget(params map[string]any, group, openID string) {
params["receiverOpenDingTalkId"] = openID
}
+func addMessagesSendUserUploadTarget(params map[string]any, group, openID string) {
+ if group != "" {
+ params["openConversationId"] = group
+ return
+ }
+ params["openDingTalkId"] = openID
+}
+
func nonEmptyStringCount(values ...string) int {
count := 0
for _, value := range values {
diff --git a/internal/shortcut/contact/contact.go b/internal/shortcut/contact/contact.go
index df7ad96e..d84b2819 100644
--- a/internal/shortcut/contact/contact.go
+++ b/internal/shortcut/contact/contact.go
@@ -162,8 +162,13 @@ var SearchUser = shortcut.Shortcut{
// the clean output projection applied to every list command.
// Field names are probed defensively across candidate keys.
func searchUserProject(data map[string]any) []map[string]any {
- raw, ok := data["result"].([]any)
- if !ok {
+ var raw []any
+ switch result := data["result"].(type) {
+ case []any:
+ raw = result
+ case map[string]any:
+ raw = []any{result}
+ default:
return []map[string]any{}
}
out := make([]map[string]any, 0, len(raw))
diff --git a/internal/shortcut/contact/search_mobile_coverage_test.go b/internal/shortcut/contact/search_mobile_coverage_test.go
new file mode 100644
index 00000000..06e392d5
--- /dev/null
+++ b/internal/shortcut/contact/search_mobile_coverage_test.go
@@ -0,0 +1,45 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package contact
+
+import "testing"
+
+func TestCrossPlatformCoverageSearchMobileProjectsObjectEnvelope(t *testing.T) {
+ users := searchUserProject(map[string]any{
+ "success": true,
+ "result": map[string]any{
+ "name": "Alice",
+ "userId": "u1",
+ "flowerName": "A",
+ "openDingTalkId": "open-1",
+ "title": "Engineer",
+ "unrelatedPrivate": "drop-me",
+ },
+ })
+ if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" {
+ t.Fatalf("object envelope projection = %#v, want one user", users)
+ }
+ if _, exists := users[0]["unrelatedPrivate"]; exists {
+ t.Fatalf("projection leaked unrelated field: %#v", users[0])
+ }
+}
+
+func TestCrossPlatformCoverageSearchMobilePreservesEmptyObjectSemantics(t *testing.T) {
+ users := searchUserProject(map[string]any{"success": true, "result": map[string]any{}})
+ if len(users) != 0 {
+ t.Fatalf("empty result projected users: %#v", users)
+ }
+}
+
+func TestCrossPlatformCoverageSearchMobileProjectsArrayAndMissingResult(t *testing.T) {
+ users := searchUserProject(map[string]any{
+ "result": []any{map[string]any{"name": "Alice", "userId": "u1"}},
+ })
+ if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" {
+ t.Fatalf("array envelope projection = %#v, want one user", users)
+ }
+ if users := searchUserProject(map[string]any{"success": true}); len(users) != 0 {
+ t.Fatalf("missing result projected users: %#v", users)
+ }
+}
diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go
new file mode 100644
index 00000000..13408567
--- /dev/null
+++ b/internal/shortcut/ding/common.go
@@ -0,0 +1,316 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package ding
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+const (
+ dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection."
+ dingCompatibilityWriteReason = "Historical DING CLI write compatibility: the command remains executable behind user confirmation, but it is excluded from Agent public discovery because receiver identity and recall terminal-state verification are incomplete."
+ dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts."
+)
+
+type dingPageEvidence struct {
+ HasMore bool
+ Next string
+}
+
+func dingReadSafety() contract.SafetySpec {
+ return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
+}
+
+func dingWriteSafety(destructive bool) contract.SafetySpec {
+ effect, risk := "write", "medium"
+ if destructive {
+ effect, risk = "destructive", "high"
+ }
+ return contract.SafetySpec{Effect: effect, Risk: risk, Confirmation: "user_required", Idempotency: "unknown"}
+}
+
+func dingListResult() *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的 DING 消息页","properties":{"count":{"type":"integer","description":"当前页有效 DING 消息数量"},"messages":{"type":"array","description":"严格验证并投影后的 DING 消息","items":{"type":"object","description":"一条具有稳定 openDingId 的 DING 消息","properties":{"openDingId":{"type":"string","description":"稳定 DING 消息 ID"},"content":{"type":"string","description":"DING 消息内容"},"sourceMessageId":{"type":"string","description":"作为 DING 来源的聊天消息 ID"},"sendTime":{"type":"string","description":"服务端返回的发送时间"},"senderNick":{"type":"string","description":"发送人显示名"}},"required":["openDingId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"当前页是否具有服务端分页终态证据"}},"required":["count","messages","complete"],"additionalProperties":false}`),
+ SensitivePaths: []string{"messages.content", "messages.senderNick"},
+ }
+}
+
+func dingReceiverResult() *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{"type":"object","description":"指定 DING 的严格接收状态","properties":{"count":{"type":"integer","description":"经验证的接收状态数量"},"receivers":{"type":"array","description":"身份匹配的 DING 接收状态","items":{"type":"object","description":"一名接收人的 DING 确认状态","properties":{"openDingId":{"type":"string","description":"与请求一致的稳定 DING ID"},"confirmedStatus":{"type":"integer","description":"服务端 DING 确认状态码"},"receiverNick":{"type":"string","description":"接收人显示名"}},"required":["openDingId","confirmedStatus","receiverNick"],"additionalProperties":false}}},"required":["count","receivers"],"additionalProperties":false}`),
+ SensitivePaths: []string{"receivers.receiverNick"},
+ }
+}
+
+func dingPagination() *contract.PaginationSpec {
+ return &contract.PaginationSpec{
+ Kind: contract.PaginationKindCursor,
+ CursorParameter: "cursor",
+ MetaPath: contract.PaginationMetaPath,
+ EndpointExhaustedPath: contract.PaginationExhaustedPath,
+ NextTokenPath: contract.PaginationNextTokenPath,
+ }
+}
+
+func dingContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
+ name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
+ cliPath := "ding " + command
+ availability, reason := contract.InterfaceAvailable, dingCompositeReason
+ if !available {
+ availability, reason = contract.InterfaceUnavailable, dingWriteUnavailableReason
+ }
+ return corecmd.ContractDecl{
+ Description: description,
+ Result: result,
+ Pagination: pagination,
+ Parameters: params,
+ Identity: contract.ToolIdentitySpec{
+ ProductID: "ding", Name: name, CanonicalPath: "ding." + name,
+ CLIPath: cliPath, PrimaryCLIPath: cliPath,
+ },
+ Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason},
+ Selection: contract.SelectionSpec{
+ AgentSummary: description,
+ UseWhen: []string{intent},
+ AvoidWhen: []string{"普通聊天消息使用 chat;写能力在缺少可逆真实 fixture、精确读回或清理证据时不要执行"},
+ Examples: examples,
+ },
+ }
+}
+
+func dingResponseError(operation, reason, message string) error {
+ return apperrors.NewAPI(message,
+ apperrors.WithOperation(operation),
+ apperrors.WithOrigin("mcp"),
+ apperrors.WithFailureStage("response_validation"),
+ apperrors.WithRetryable(false),
+ apperrors.WithReason(reason),
+ )
+}
+
+func dingRequireResult(data map[string]any, operation string) (map[string]any, error) {
+ if len(data) == 0 {
+ return nil, dingResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 DING 调用成功或结果确实为空")
+ }
+ success, present := data["success"]
+ if !present {
+ return nil, dingResponseError(operation, "missing_success", "DING 响应缺少 success 业务状态")
+ }
+ succeeded, ok := success.(bool)
+ if !ok {
+ return nil, dingResponseError(operation, "invalid_success_type", fmt.Sprintf("DING 响应 success 应为布尔值,实际为 %T", success))
+ }
+ if !succeeded {
+ message := "DING 服务明确返回失败"
+ for _, key := range []string{"errorMsg", "errorMessage", "message"} {
+ if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
+ message = strings.TrimSpace(value)
+ break
+ }
+ }
+ return nil, dingResponseError(operation, "remote_failure", message)
+ }
+ result, ok := data["result"].(map[string]any)
+ if !ok || result == nil {
+ return nil, dingResponseError(operation, "missing_result", "DING 成功响应缺少 result 对象")
+ }
+ return result, nil
+}
+
+func dingRequireObjectCollection(result map[string]any, operation, field string) ([]map[string]any, error) {
+ raw, present := result[field]
+ if !present {
+ return nil, dingResponseError(operation, "missing_collection", fmt.Sprintf("DING 成功响应缺少 result.%s 数组", field))
+ }
+ values, ok := raw.([]any)
+ if !ok {
+ return nil, dingResponseError(operation, "malformed_collection", fmt.Sprintf("DING result.%s 应为数组,实际为 %T", field, raw))
+ }
+ items := make([]map[string]any, 0, len(values))
+ for index, value := range values {
+ item, ok := value.(map[string]any)
+ if !ok || len(item) == 0 {
+ return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING result.%s[%d] 不是非空对象", field, index))
+ }
+ items = append(items, item)
+ }
+ return items, nil
+}
+
+func dingRequiredString(item map[string]any, operation, field string, index int) (string, error) {
+ value, ok := item[field].(string)
+ value = strings.TrimSpace(value)
+ if !ok || value == "" {
+ return "", dingResponseError(operation, "missing_item_identity", fmt.Sprintf("DING 结果第 %d 项缺少非空 %s", index, field))
+ }
+ return value, nil
+}
+
+func dingOptionalString(item map[string]any, field string) (string, error) {
+ value, present := item[field]
+ if !present || value == nil {
+ return "", nil
+ }
+ text, ok := value.(string)
+ if !ok {
+ return "", fmt.Errorf("字段 %s 应为字符串,实际为 %T", field, value)
+ }
+ return strings.TrimSpace(text), nil
+}
+
+func dingInteger(value any) (int64, bool) {
+ switch typed := value.(type) {
+ case float64:
+ if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 {
+ return 0, false
+ }
+ return int64(typed), true
+ case int:
+ return int64(typed), true
+ case int64:
+ return typed, true
+ case json.Number:
+ parsed, err := typed.Int64()
+ return parsed, err == nil
+ default:
+ return 0, false
+ }
+}
+
+func dingProjectMessages(data map[string]any, operation string) ([]map[string]any, dingPageEvidence, error) {
+ result, err := dingRequireResult(data, operation)
+ if err != nil {
+ return nil, dingPageEvidence{}, err
+ }
+ items, err := dingRequireObjectCollection(result, operation, "dingMessages")
+ if err != nil {
+ return nil, dingPageEvidence{}, err
+ }
+ messages := make([]map[string]any, 0, len(items))
+ seen := make(map[string]struct{}, len(items))
+ for index, item := range items {
+ id, identityErr := dingRequiredString(item, operation, "openDingId", index)
+ if identityErr != nil {
+ return nil, dingPageEvidence{}, identityErr
+ }
+ if _, duplicate := seen[id]; duplicate {
+ return nil, dingPageEvidence{}, dingResponseError(operation, "duplicate_item_identity", "当前 DING 页包含重复 openDingId")
+ }
+ seen[id] = struct{}{}
+ projected := map[string]any{"openDingId": id}
+ for source, target := range map[string]string{
+ "dingContent": "content", "dingSourceOpenMessageId": "sourceMessageId",
+ "sendTime": "sendTime", "senderNick": "senderNick",
+ } {
+ value, valueErr := dingOptionalString(item, source)
+ if valueErr != nil {
+ return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 结果第 %d 项%s", index, valueErr.Error()))
+ }
+ if value != "" {
+ projected[target] = value
+ }
+ }
+ messages = append(messages, projected)
+ }
+ hasMoreValue, present := result["hasMore"]
+ if !present {
+ return nil, dingPageEvidence{}, dingResponseError(operation, "missing_pagination", "DING 列表缺少 result.hasMore,不能宣称当前页完整")
+ }
+ hasMore, ok := hasMoreValue.(bool)
+ if !ok {
+ return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_pagination", "DING result.hasMore 应为布尔值")
+ }
+ page := dingPageEvidence{HasMore: hasMore}
+ if !hasMore {
+ if next, exists := result["nextCursor"]; exists && next != nil {
+ if numeric, valid := dingInteger(next); !valid || numeric != 0 {
+ return nil, dingPageEvidence{}, dingResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 仍表示后续页")
+ }
+ }
+ return messages, page, nil
+ }
+ if len(messages) == 0 {
+ return nil, dingPageEvidence{}, dingResponseError(operation, "empty_page_with_continuation", "DING 空页仍声明 hasMore=true,无法证明游标可安全推进")
+ }
+ next, valid := dingInteger(result["nextCursor"])
+ if !valid || next <= 0 {
+ return nil, dingPageEvidence{}, dingResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 nextCursor")
+ }
+ page.Next = strconv.FormatInt(next, 10)
+ return messages, page, nil
+}
+
+func dingProjectReceivers(data map[string]any, operation, expectedID string) ([]map[string]any, error) {
+ result, err := dingRequireResult(data, operation)
+ if err != nil {
+ return nil, err
+ }
+ items, err := dingRequireObjectCollection(result, operation, "receivers")
+ if err != nil {
+ return nil, err
+ }
+ if len(items) == 0 {
+ return nil, dingResponseError(operation, "empty_receiver_status", "DING 接收状态显式为空,无法证明目标 DING 的接收状态")
+ }
+ receivers := make([]map[string]any, 0, len(items))
+ for index, item := range items {
+ id, identityErr := dingRequiredString(item, operation, "openDingId", index)
+ if identityErr != nil {
+ return nil, identityErr
+ }
+ if id != expectedID {
+ return nil, dingResponseError(operation, "identity_mismatch", "DING 接收状态的 openDingId 与请求目标不一致")
+ }
+ status, ok := dingInteger(item["confirmedStatus"])
+ if !ok {
+ return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 接收状态第 %d 项 confirmedStatus 不是整数", index))
+ }
+ nick, nickErr := dingRequiredString(item, operation, "receiverNick", index)
+ if nickErr != nil {
+ return nil, nickErr
+ }
+ receivers = append(receivers, map[string]any{"openDingId": id, "confirmedStatus": status, "receiverNick": nick})
+ }
+ return receivers, nil
+}
+
+func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page dingPageEvidence) error {
+ payload := map[string]any{"count": len(messages), "messages": messages, "complete": !page.HasMore}
+ if !output.UsesUnifiedResult(rt.Command()) {
+ if page.HasMore {
+ payload["nextCursor"] = page.Next
+ }
+ return rt.Output(payload)
+ }
+ pagination, err := output.NewPagination(!page.HasMore, page.Next)
+ if err != nil {
+ return dingResponseError("im/list_ding_messages", "invalid_pagination", err.Error())
+ }
+ meta := &output.Meta{Count: output.NewCount(len(messages)), Pagination: pagination}
+ return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
+}
+
+func dingUnavailable(operation string) error {
+ return apperrors.NewDiscovery("该 DING 写 Shortcut 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行",
+ apperrors.WithOperation(operation),
+ apperrors.WithOrigin("shortcut_registry"),
+ apperrors.WithFailureStage("capability_gate"),
+ apperrors.WithExecutionStarted(false),
+ apperrors.WithRetryable(false),
+ apperrors.WithReason("write_fixture_unavailable"),
+ )
+}
diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go
index e5e3bb2b..ba2a19a5 100644
--- a/internal/shortcut/ding/ding.go
+++ b/internal/shortcut/ding/ding.go
@@ -1,226 +1,205 @@
// Copyright 2026 Alibaba Group
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
+// Licensed under the Apache License, Version 2.0
-// Package ding holds the built-in DING (钉) shortcuts. Tool names and params are
-// copied verbatim from internal/helpers/ding.go. Some tools route to the "im"
-// MCP server (helper uses callMCPToolOnServer("im", ...)), reflected in Product.
+// Package ding registers strict declarative shortcuts for DingTalk DING.
package ding
import (
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "strconv"
+ "strings"
+
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var List = shortcut.Shortcut{
Service: "ding", Command: "+list", Product: "im",
- Description: "查询 DING 消息列表", Intent: "当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。", Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "ding",
- Name: "shortcut_list",
- CanonicalPath: "ding.shortcut_list",
- CLIPath: "ding +list",
- PrimaryCLIPath: "ding +list",
- },
- Description: "查询 DING 消息列表",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "查询 DING 消息列表",
- UseWhen: []string{"当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws ding +list"},
- },
- },
+ Description: "查询 DING 消息列表",
+ Intent: "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。",
+ Risk: shortcut.RiskRead,
+ Safety: dingReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: dingContract(
+ "+list", "查询 DING 消息列表",
+ "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。",
+ true, dingListResult(), dingPagination(),
+ []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "type", Property: "type"}},
+ "dws ding +list --type ALL", "dws ding +list --type DELETED",
+ ),
Flags: []shortcut.Flag{
- {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"},
- {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "类型 (可选,默认 ALL)"},
+ {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标,首次不传或传 0"},
+ {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型:ALL、UNREAD、SEND、NEW_COMMENT 或 DELETED"},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页响应必须返回严格前进的正整数 nextCursor"}},
+ Tips: []string{"dws ding +list --type ALL", "dws ding +list --type DELETED"},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if rt.Int("cursor") < 0 {
+ return apperrors.NewValidation("--cursor 不能小于 0")
+ }
+ return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
- // type 是服务端必填,空值会报错;不传或传空时兜底为 ALL(对齐 helper)。
- msgType := rt.Str("type")
- if msgType == "" {
- msgType = "ALL"
- }
- params := map[string]any{"type": msgType}
+ params := map[string]any{"type": rt.Str("type")}
if rt.Changed("cursor") {
params["cursor"] = rt.Int("cursor")
}
- return rt.CallMCP("list_ding_messages", params)
+ data, err := rt.CallMCPData("im", "list_ding_messages", params)
+ if err != nil {
+ return err
+ }
+ messages, page, err := dingProjectMessages(data, "im/list_ding_messages")
+ if err != nil {
+ return err
+ }
+ if page.HasMore {
+ current := int64(rt.Int("cursor"))
+ next, parseErr := strconv.ParseInt(page.Next, 10, 64)
+ if parseErr != nil || next <= current {
+ return dingResponseError("im/list_ding_messages", "stalled_cursor", "nextCursor 没有严格前进")
+ }
+ }
+ return outputDingPage(rt, messages, page)
},
}
var ReceiverStatus = shortcut.Shortcut{
Service: "ding", Command: "+receiver-status", Product: "im",
- Description: "查询 DING 消息接收人已读状态", Intent: "当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。", Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "ding",
- Name: "shortcut_receiver_status",
- CanonicalPath: "ding.shortcut_receiver_status",
- CLIPath: "ding +receiver-status",
- PrimaryCLIPath: "ding +receiver-status",
- },
- Description: "查询 DING 消息接收人已读状态",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "查询 DING 消息接收人已读状态",
- UseWhen: []string{"当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws ding +receiver-status --ding-id "},
- },
- },
- Flags: []shortcut.Flag{
- {Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true},
- },
+ Description: "查询 DING 消息接收人已读状态",
+ Intent: "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。",
+ Risk: shortcut.RiskRead,
+ Safety: dingReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: dingContract(
+ "+receiver-status", "查询 DING 消息接收人已读状态",
+ "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。",
+ true, dingReceiverResult(), nil,
+ []contract.ParamDecl{{Name: "ding-id", Property: "dingId"}},
+ "dws ding +receiver-status --ding-id ",
+ ),
+ Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
+ Tips: []string{"dws ding +receiver-status --ding-id "},
Execute: func(rt *shortcut.RuntimeContext) error {
- return rt.CallMCP("list_ding_receiver_status", map[string]any{
- "openDingId": rt.Str("ding-id"),
- })
+ const operation = "im/list_ding_receiver_status"
+ data, err := rt.CallMCPData("im", "list_ding_receiver_status", map[string]any{"openDingId": rt.Str("ding-id")})
+ if err != nil {
+ return err
+ }
+ receivers, err := dingProjectReceivers(data, operation, rt.Str("ding-id"))
+ if err != nil {
+ return err
+ }
+ payload := map[string]any{"count": len(receivers), "receivers": receivers}
+ if !output.UsesUnifiedResult(rt.Command()) {
+ return rt.Output(payload)
+ }
+ meta := &output.Meta{Count: output.NewCount(len(receivers))}
+ return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
},
}
-var SendPersonal = shortcut.Shortcut{
- Service: "ding", Command: "+send-personal", Product: "im",
- Description: "以本人身份发送 DING 给指定人", Intent: "当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。", Risk: shortcut.RiskWrite,
- Safety: contract.SafetySpec{
- Effect: "write", Risk: "medium",
- Confirmation: "user_required", Idempotency: "unknown",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "ding",
- Name: "shortcut_send_personal",
- CanonicalPath: "ding.shortcut_send_personal",
- CLIPath: "ding +send-personal",
- PrimaryCLIPath: "ding +send-personal",
- },
- Description: "以本人身份发送 DING 给指定人",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "以本人身份发送 DING 给指定人",
- UseWhen: []string{"当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws ding +send-personal --users --content "},
- },
- },
- Flags: []shortcut.Flag{
+var SendPersonal = compatibilityDingWrite(
+ "+send-personal", "以本人身份发送 DING 给指定人",
+ "仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。",
+ shortcut.RiskWrite, false,
+ []shortcut.Flag{
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true},
- {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"},
- {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"},
+ {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式:app、sms 或 call"},
+ {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
},
- Execute: func(rt *shortcut.RuntimeContext) error {
+ []contract.ParamDecl{
+ {Name: "users", Property: "users", InterfaceType: "array"},
+ {Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"},
+ },
+ "dws ding +send-personal --users --content ",
+ func(rt *shortcut.RuntimeContext) error {
+ remindType, err := dingPersonalRemindType(rt.Str("type"))
+ if err != nil {
+ return err
+ }
params := map[string]any{
"receiverOpenDingTalkIds": rt.StrSlice("users"),
"content": rt.Str("content"),
- "remindType": rt.Str("type"),
+ "remindType": remindType,
}
if rt.Changed("uuid") {
params["uuid"] = rt.Str("uuid")
}
return rt.CallMCP("send_personal_ding", params)
},
-}
+)
-var SendByMessage = shortcut.Shortcut{
- Service: "ding", Command: "+send-by-message", Product: "im",
- Description: "针对某条消息发起 DING 提醒", Intent: "当群里已有一条聊天消息需要被重点跟进,你想直接把它变成 DING 去强提醒相关人(而不是另写新内容)时使用;需提供群 openConversationId、该消息 openMessageId 和接收人 openDingTalkId 列表,会真实基于这条消息发出 DING。", Risk: shortcut.RiskWrite,
- Flags: []shortcut.Flag{
+var SendByMessage = unavailableDingWrite(
+ "+send-by-message", "针对某条消息发起 DING 提醒",
+ "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。",
+ shortcut.RiskWrite, false,
+ []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true},
- {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"},
- {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"},
+ {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}},
+ {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"},
},
- Execute: func(rt *shortcut.RuntimeContext) error {
- params := map[string]any{
- "openConversationId": rt.Str("group"),
- "openMessageId": rt.Str("message-id"),
- "receiverOpenDingTalkIds": rt.StrSlice("users"),
- "remindType": rt.Str("type"),
- }
- if rt.Changed("uuid") {
- params["uuid"] = rt.Str("uuid")
- }
- return rt.CallMCP("send_ding_by_message", params)
+ []contract.ParamDecl{
+ {Name: "group", Property: "openConversationId"}, {Name: "message-id", Property: "openMessageId"},
+ {Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"},
+ {Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"},
},
+ "dws ding +send-by-message --group --message-id --users ",
+)
+
+var RecallPersonal = compatibilityDingWrite(
+ "+recall-personal", "撤回本人发起的 DING",
+ "仅为历史 CLI 兼容保留:明确确认后按稳定 openDingId 撤回;因终态与残留通知尚不可查询验证,不进入 Agent 公共发现。",
+ shortcut.RiskHighWrite, true,
+ []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}},
+ []contract.ParamDecl{{Name: "id", Property: "id"}},
+ "dws ding +recall-personal --id ",
+ func(rt *shortcut.RuntimeContext) error {
+ return rt.CallMCP("recall_personal_ding", map[string]any{"openDingId": rt.Str("id")})
+ },
+)
+
+func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
+ declaration := dingContract(command, description, intent, true, nil, nil, params, example)
+ declaration.Interface.Reason = dingCompatibilityWriteReason
+ return shortcut.Shortcut{
+ Service: "ding", Command: command, Product: "im",
+ Description: description, Intent: intent, Risk: risk,
+ Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive,
+ Contract: declaration,
+ Flags: flags, Tips: []string{example}, Execute: execute,
+ }
}
-var RecallPersonal = shortcut.Shortcut{
- Service: "ding", Command: "+recall-personal", Product: "im",
- Description: "撤回本人发起的 DING", Intent: "当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。", Risk: shortcut.RiskHighWrite,
- Safety: contract.SafetySpec{
- Effect: "destructive", Risk: "high",
- Confirmation: "user_required", Idempotency: "unknown",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "ding",
- Name: "shortcut_recall_personal",
- CanonicalPath: "ding.shortcut_recall_personal",
- CLIPath: "ding +recall-personal",
- PrimaryCLIPath: "ding +recall-personal",
+func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut {
+ return shortcut.Shortcut{
+ Service: "ding", Command: command, Product: "im",
+ Description: description, Intent: intent, Risk: risk,
+ Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive,
+ Contract: dingContract(command, description, intent, false, nil, nil, params, example),
+ Flags: flags, Tips: []string{example},
+ Execute: func(*shortcut.RuntimeContext) error {
+ return dingUnavailable("ding/" + command)
},
- Description: "撤回本人发起的 DING",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "撤回本人发起的 DING",
- UseWhen: []string{"当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws ding +recall-personal --id "},
- },
- },
- Flags: []shortcut.Flag{
- {Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true},
- },
- Execute: func(rt *shortcut.RuntimeContext) error {
- return rt.CallMCP("recall_personal_ding", map[string]any{
- "openDingId": rt.Str("id"),
- })
- },
+ }
+}
+
+func dingPersonalRemindType(value string) (string, error) {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "app":
+ return "APP", nil
+ case "sms":
+ return "SMS", nil
+ case "call":
+ return "PHONE", nil
+ default:
+ return "", apperrors.NewValidation("--type 必须是 app、sms 或 call")
+ }
}
func init() {
- shortcut.Register(
- List,
- ReceiverStatus,
- SendPersonal,
- SendByMessage,
- RecallPersonal,
- )
+ shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal)
}
diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go
new file mode 100644
index 00000000..f5340cf3
--- /dev/null
+++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go
@@ -0,0 +1,434 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package ding
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+type dingCoverageCaller struct {
+ responses map[string][]string
+ failures map[string]error
+ history []string
+ arguments []map[string]any
+}
+
+func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
+ caller.history = append(caller.history, tool)
+ caller.arguments = append(caller.arguments, arguments)
+ if err := caller.failures[tool]; err != nil {
+ return nil, err
+ }
+ queue := caller.responses[tool]
+ if len(queue) == 0 {
+ return nil, errors.New("missing DING fake response for " + tool)
+ }
+ caller.responses[tool] = queue[1:]
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
+}
+
+func (*dingCoverageCaller) Format() string { return "json" }
+func (*dingCoverageCaller) DryRun() bool { return false }
+func (*dingCoverageCaller) Fields() string { return "" }
+func (*dingCoverageCaller) JQ() string { return "" }
+
+func runDingCoverage(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) (*cobra.Command, error) {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs(args)
+ return cmd, cmd.Execute()
+}
+
+func runDingCoverageOutput(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) ([]byte, error) {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs(args)
+ err := cmd.Execute()
+ return stdout.Bytes(), err
+}
+
+func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T) {
+ for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal} {
+ if declaration.Contract.Empty() {
+ t.Errorf("%s lacks Contract", declaration.Command)
+ }
+ if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" {
+ t.Errorf("%s lacks Safety", declaration.Command)
+ }
+ if declaration.OutputRollout != output.RolloutUnifiedActive {
+ t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
+ }
+ }
+ for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus} {
+ if declaration.Contract.Result == nil {
+ t.Errorf("%s lacks Result", declaration.Command)
+ }
+ if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
+ t.Errorf("%s is not interface-available", declaration.Command)
+ }
+ }
+ for _, declaration := range []shortcut.Shortcut{SendPersonal, RecallPersonal} {
+ if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
+ t.Errorf("%s compatibility write safety/interface drift", declaration.Command)
+ }
+ }
+ if SendByMessage.Safety.Confirmation != "user_required" || SendByMessage.Contract.Interface == nil || SendByMessage.Contract.Interface.Availability != "unavailable" {
+ t.Errorf("%s unavailable write safety/interface drift", SendByMessage.Command)
+ }
+ if List.Contract.Pagination == nil {
+ t.Fatal("+list lacks cursor pagination")
+ }
+}
+
+func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) {
+ valid := map[string]any{
+ "success": true,
+ "result": map[string]any{
+ "dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": "fixture"}},
+ "hasMore": true,
+ "nextCursor": float64(2),
+ },
+ }
+ messages, page, err := dingProjectMessages(valid, "im/list_ding_messages")
+ if err != nil || len(messages) != 1 || !page.HasMore || page.Next != "2" || messages[0]["content"] != "fixture" {
+ t.Fatalf("valid list projection messages=%#v page=%+v err=%v", messages, page, err)
+ }
+ empty := map[string]any{"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": nil}}
+ if messages, page, err := dingProjectMessages(empty, "im/list_ding_messages"); err != nil || len(messages) != 0 || page.HasMore {
+ t.Fatalf("explicit terminal empty page messages=%#v page=%+v err=%v", messages, page, err)
+ }
+ fixtures := map[string]map[string]any{
+ "empty": {},
+ "missing success": {"result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
+ "wrong success": {"success": "true", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
+ "false success": {"success": false, "errorMsg": "fixture failure", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}},
+ "missing result": {"success": true},
+ "missing collection": {"success": true, "result": map[string]any{"hasMore": false}},
+ "wrong collection": {"success": true, "result": map[string]any{"dingMessages": map[string]any{}, "hasMore": false}},
+ "bad item": {"success": true, "result": map[string]any{"dingMessages": []any{"bad"}, "hasMore": false}},
+ "missing item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"dingContent": "fixture"}}, "hasMore": false}},
+ "duplicate item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "same"}, map[string]any{"openDingId": "same"}}, "hasMore": false}},
+ "missing pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}}},
+ "wrong pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": "false"}},
+ "empty continuation": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": true, "nextCursor": float64(2)}},
+ "missing next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true}},
+ "wrong next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true, "nextCursor": "2"}},
+ "conflicting terminal": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": float64(2)}},
+ "wrong optional content": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": float64(1)}}, "hasMore": false}},
+ }
+ for name, fixture := range fixtures {
+ if projected, _, projectErr := dingProjectMessages(fixture, "im/list_ding_messages"); projectErr == nil {
+ t.Errorf("%s returned success: %#v", name, projected)
+ }
+ }
+ if _, _, err := dingProjectMessages(map[string]any{"success": false, "errorMsg": "fixture failure"}, "im/list_ding_messages"); err == nil || !strings.Contains(err.Error(), "fixture failure") {
+ t.Fatalf("remote failure message was not preserved: %v", err)
+ }
+}
+
+func TestCrossPlatformCoverageDINGIntegerRepresentations(t *testing.T) {
+ for name, test := range map[string]struct {
+ value any
+ want int64
+ }{
+ "float64": {value: float64(1), want: 1},
+ "int": {value: int(2), want: 2},
+ "int64": {value: int64(3), want: 3},
+ "json number": {value: json.Number("4"), want: 4},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if got, ok := dingInteger(test.value); !ok || got != test.want {
+ t.Fatalf("dingInteger(%T(%v))=(%d,%t), want (%d,true)", test.value, test.value, got, ok, test.want)
+ }
+ })
+ }
+ for name, value := range map[string]any{
+ "nan": math.NaN(),
+ "fraction": 1.5,
+ "overflow": math.MaxFloat64,
+ "bad json number": json.Number("not-an-integer"),
+ "wrong type": "1",
+ } {
+ t.Run(name, func(t *testing.T) {
+ if got, ok := dingInteger(value); ok {
+ t.Fatalf("dingInteger(%T(%v))=(%d,true), want invalid", value, value, got)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) {
+ valid := map[string]any{"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}}
+ items, err := dingProjectReceivers(valid, "im/list_ding_receiver_status", "ding-1")
+ if err != nil || len(items) != 1 || items[0]["confirmedStatus"] != int64(1) {
+ t.Fatalf("valid receiver projection=%#v err=%v", items, err)
+ }
+ for name, fixture := range map[string]map[string]any{
+ "empty": {},
+ "missing collection": {"success": true, "result": map[string]any{}},
+ "empty collection": {"success": true, "result": map[string]any{"receivers": []any{}}},
+ "bad item": {"success": true, "result": map[string]any{"receivers": []any{"bad"}}},
+ "missing item id": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"confirmedStatus": float64(1), "receiverNick": "fixture"}}}},
+ "identity mismatch": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "other", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}},
+ "wrong status": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": "1", "receiverNick": "fixture"}}}},
+ "missing receiver": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1)}}}},
+ } {
+ if projected, projectErr := dingProjectReceivers(fixture, "im/list_ding_receiver_status", "ding-1"); projectErr == nil {
+ t.Errorf("%s returned success: %#v", name, projected)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageDINGListExecutionFailuresPaginationAndLegacy(t *testing.T) {
+ t.Run("negative cursor validates before call", func(t *testing.T) {
+ caller := &dingCoverageCaller{responses: map[string][]string{}}
+ if _, err := runDingCoverage(t, List, caller, "--cursor", "-1"); err == nil || len(caller.history) != 0 {
+ t.Fatalf("negative cursor error=%v calls=%v", err, caller.history)
+ }
+ })
+
+ t.Run("transport failure", func(t *testing.T) {
+ caller := &dingCoverageCaller{
+ responses: map[string][]string{},
+ failures: map[string]error{"list_ding_messages": errors.New("fixture transport failure")},
+ }
+ if _, err := runDingCoverage(t, List, caller); err == nil || strings.Join(caller.history, ",") != "list_ding_messages" {
+ t.Fatalf("transport error=%v calls=%v", err, caller.history)
+ }
+ })
+
+ t.Run("projection failure", func(t *testing.T) {
+ caller := &dingCoverageCaller{responses: map[string][]string{
+ "list_ding_messages": {`{"success":true,"result":{"dingMessages":[],"hasMore":true,"nextCursor":2}}`},
+ }}
+ if _, err := runDingCoverage(t, List, caller); err == nil {
+ t.Fatal("malformed empty continuation returned success")
+ }
+ })
+
+ t.Run("stalled cursor", func(t *testing.T) {
+ caller := &dingCoverageCaller{responses: map[string][]string{
+ "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":2}}`},
+ }}
+ if _, err := runDingCoverage(t, List, caller, "--cursor", "2"); err == nil || !strings.Contains(err.Error(), "nextCursor") {
+ t.Fatalf("stalled cursor error=%v", err)
+ }
+ })
+
+ t.Run("advancing cursor legacy output", func(t *testing.T) {
+ legacy := List
+ legacy.OutputRollout = output.RolloutLegacyOnly
+ caller := &dingCoverageCaller{responses: map[string][]string{
+ "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":3}}`},
+ }}
+ stdout, err := runDingCoverageOutput(t, legacy, caller, "--cursor", "2")
+ if err != nil {
+ t.Fatalf("legacy advancing page: %v", err)
+ }
+ var payload map[string]any
+ if err := json.Unmarshal(stdout, &payload); err != nil || payload["nextCursor"] != "3" || payload["complete"] != false {
+ t.Fatalf("legacy payload=%#v decode=%v", payload, err)
+ }
+ if len(caller.arguments) != 1 || caller.arguments[0]["cursor"] != 2 {
+ t.Fatalf("legacy cursor arguments=%#v", caller.arguments)
+ }
+ })
+
+ t.Run("invalid unified page evidence", func(t *testing.T) {
+ cmd := corecmd.New(shortcut.FromShortcut(List))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ rt := shortcut.RuntimeContextForTest(cmd, List)
+ if err := outputDingPage(rt, nil, dingPageEvidence{HasMore: true}); err == nil {
+ t.Fatal("continuation without next cursor returned success")
+ }
+ })
+}
+
+func TestCrossPlatformCoverageDINGReceiverExecutionFailuresAndLegacy(t *testing.T) {
+ t.Run("transport failure", func(t *testing.T) {
+ caller := &dingCoverageCaller{
+ responses: map[string][]string{},
+ failures: map[string]error{"list_ding_receiver_status": errors.New("fixture transport failure")},
+ }
+ if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil {
+ t.Fatal("receiver transport failure returned success")
+ }
+ })
+
+ t.Run("projection failure", func(t *testing.T) {
+ caller := &dingCoverageCaller{responses: map[string][]string{
+ "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[]}}`},
+ }}
+ if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil {
+ t.Fatal("empty receiver response returned success")
+ }
+ })
+
+ t.Run("legacy output", func(t *testing.T) {
+ legacy := ReceiverStatus
+ legacy.OutputRollout = output.RolloutLegacyOnly
+ caller := &dingCoverageCaller{responses: map[string][]string{
+ "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`},
+ }}
+ stdout, err := runDingCoverageOutput(t, legacy, caller, "--ding-id", "ding-1")
+ if err != nil {
+ t.Fatalf("receiver legacy output: %v", err)
+ }
+ var payload map[string]any
+ if err := json.Unmarshal(stdout, &payload); err != nil || payload["count"] != float64(1) {
+ t.Fatalf("receiver legacy payload=%#v decode=%v", payload, err)
+ }
+ if len(caller.arguments) != 1 || caller.arguments[0]["openDingId"] != "ding-1" {
+ t.Fatalf("receiver arguments=%#v", caller.arguments)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testing.T) {
+ listCaller := &dingCoverageCaller{responses: map[string][]string{
+ "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1","dingContent":"fixture"}],"hasMore":false,"nextCursor":null}}`},
+ }}
+ cmd, err := runDingCoverage(t, List, listCaller, "--type", "ALL")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 {
+ t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr)
+ }
+ var envelope map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatal(err)
+ }
+ data := envelope["data"].(map[string]any)
+ if _, leaked := data["result"]; leaked || data["count"] != float64(1) || data["complete"] != true {
+ t.Fatalf("list unified projection=%#v", data)
+ }
+ if strings.Join(listCaller.history, ",") != "list_ding_messages" {
+ t.Fatalf("list call history=%v", listCaller.history)
+ }
+
+ receiverCaller := &dingCoverageCaller{responses: map[string][]string{
+ "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`},
+ }}
+ if _, err := runDingCoverage(t, ReceiverStatus, receiverCaller, "--ding-id", "ding-1"); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(receiverCaller.history, ",") != "list_ding_receiver_status" {
+ t.Fatalf("receiver call history=%v", receiverCaller.history)
+ }
+}
+
+func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) {
+ args := []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"}
+ unconfirmed := &dingCoverageCaller{responses: map[string][]string{}}
+ err := runDingRoot(t, SendByMessage, unconfirmed, false, args...)
+ if err == nil || len(unconfirmed.history) != 0 {
+ t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history)
+ }
+ confirmed := &dingCoverageCaller{responses: map[string][]string{}}
+ err = runDingRoot(t, SendByMessage, confirmed, true, args...)
+ if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 {
+ t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history)
+ }
+}
+
+func TestCrossPlatformCoverageDINGCompatibilityWritesRequireConfirmationAndExecute(t *testing.T) {
+ for input, want := range map[string]string{"app": "APP", " sms ": "SMS", "call": "PHONE"} {
+ got, err := dingPersonalRemindType(input)
+ if err != nil || got != want {
+ t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want (%q,nil)", input, got, err, want)
+ }
+ }
+
+ sendArgs := []string{"--users", "D-fixture", "--content", "fixture", "--type", "call", "--uuid", "fixture-uuid"}
+ unconfirmedSend := &dingCoverageCaller{responses: map[string][]string{}}
+ if err := runDingRoot(t, SendPersonal, unconfirmedSend, false, sendArgs...); err == nil || len(unconfirmedSend.history) != 0 {
+ t.Fatalf("unconfirmed send error=%v calls=%v", err, unconfirmedSend.history)
+ }
+ confirmedSend := &dingCoverageCaller{responses: map[string][]string{
+ "send_personal_ding": {`{"success":true,"result":{"openDingId":"ding-fixture"}}`},
+ }}
+ if err := runDingRoot(t, SendPersonal, confirmedSend, true, sendArgs...); err != nil {
+ t.Fatalf("confirmed send failed: %v", err)
+ }
+ if strings.Join(confirmedSend.history, ",") != "send_personal_ding" || len(confirmedSend.arguments) != 1 {
+ t.Fatalf("confirmed send calls=%v args=%v", confirmedSend.history, confirmedSend.arguments)
+ }
+ sendParams := confirmedSend.arguments[0]
+ users, _ := sendParams["receiverOpenDingTalkIds"].([]string)
+ if strings.Join(users, ",") != "D-fixture" || sendParams["content"] != "fixture" || sendParams["remindType"] != "PHONE" || sendParams["uuid"] != "fixture-uuid" {
+ t.Fatalf("confirmed send params=%#v", sendParams)
+ }
+
+ invalidType := &dingCoverageCaller{responses: map[string][]string{}}
+ if err := runDingRoot(t, SendPersonal, invalidType, true, "--users", "D-fixture", "--content", "fixture", "--type", "other"); err == nil || len(invalidType.history) != 0 {
+ t.Fatalf("invalid type error=%v calls=%v", err, invalidType.history)
+ }
+
+ recallArgs := []string{"--id", "ding-fixture"}
+ unconfirmedRecall := &dingCoverageCaller{responses: map[string][]string{}}
+ if err := runDingRoot(t, RecallPersonal, unconfirmedRecall, false, recallArgs...); err == nil || len(unconfirmedRecall.history) != 0 {
+ t.Fatalf("unconfirmed recall error=%v calls=%v", err, unconfirmedRecall.history)
+ }
+ confirmedRecall := &dingCoverageCaller{responses: map[string][]string{
+ "recall_personal_ding": {`{"success":true,"result":true}`},
+ }}
+ if err := runDingRoot(t, RecallPersonal, confirmedRecall, true, recallArgs...); err != nil {
+ t.Fatalf("confirmed recall failed: %v", err)
+ }
+ if strings.Join(confirmedRecall.history, ",") != "recall_personal_ding" || confirmedRecall.arguments[0]["openDingId"] != "ding-fixture" {
+ t.Fatalf("confirmed recall calls=%v args=%v", confirmedRecall.history, confirmedRecall.arguments)
+ }
+}
+
+func runDingRoot(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, yes bool, args ...string) error {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ 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", "")
+ ctx, _ := output.WithResultStore(context.Background())
+ root.SetContext(ctx)
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ service := &cobra.Command{Use: "ding"}
+ service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
+ root.AddCommand(service)
+ argv := []string{"ding", declaration.Command}
+ argv = append(argv, args...)
+ if yes {
+ argv = append(argv, "--yes")
+ }
+ root.SetArgs(argv)
+ return root.Execute()
+}
diff --git a/internal/shortcut/doc/doc.go b/internal/shortcut/doc/doc.go
index 494ee29a..0b66fe44 100644
--- a/internal/shortcut/doc/doc.go
+++ b/internal/shortcut/doc/doc.go
@@ -19,6 +19,7 @@ package doc
import (
"strings"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
@@ -564,6 +565,9 @@ var CommentReply = shortcut.Shortcut{
"replyCommentKey": rt.Str("comment-key"),
}
if rt.Bool("emoji") {
+ if err := commentreaction.Validate(rt.Str("content")); err != nil {
+ return err
+ }
params["emoji"] = true
}
if rt.Changed("mention") {
diff --git a/internal/shortcut/doc/doc_shortcuts_test.go b/internal/shortcut/doc/doc_shortcuts_test.go
index 8b3da279..db3e9441 100644
--- a/internal/shortcut/doc/doc_shortcuts_test.go
+++ b/internal/shortcut/doc/doc_shortcuts_test.go
@@ -132,6 +132,31 @@ func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *doc
return root.Execute()
}
+func TestCrossPlatformCoverageCommentReplyRejectsUnsupportedEmojiBeforeRPC(t *testing.T) {
+ for _, content := range []string{"😄", "乱码"} {
+ caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
+ err := runDocCoverage(t, CommentReply, caller,
+ "--node", "node-1", "--comment-key", "comment-1",
+ "--content", content, "--emoji", "--yes")
+ if err == nil {
+ t.Fatalf("unsupported reaction %q accepted", content)
+ }
+ if caller.calls != 0 {
+ t.Fatalf("unsupported reaction %q reached RPC %d time(s)", content, caller.calls)
+ }
+ }
+
+ caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
+ if err := runDocCoverage(t, CommentReply, caller,
+ "--node", "node-1", "--comment-key", "comment-1",
+ "--content", "鼓掌", "--emoji", "--yes"); err != nil {
+ t.Fatal(err)
+ }
+ if caller.calls != 1 || caller.history[0].params["emoji"] != true {
+ t.Fatalf("valid reaction call = %#v", caller.history)
+ }
+}
+
func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) {
revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}}
if got, ok := nestedRevision(revisionPayload); !ok || got != 9 {
diff --git a/internal/shortcut/mail/common.go b/internal/shortcut/mail/common.go
new file mode 100644
index 00000000..6ac9c5f9
--- /dev/null
+++ b/internal/shortcut/mail/common.go
@@ -0,0 +1,527 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package mail
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+const mailCompositeReason = "Reviewed Mail Shortcut composite: the executable CLI owns strict response validation, pagination evidence, stable-identity checks, output projection, and confirmation; no single MCP interface represents the complete command contract."
+
+const mailCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or cleanup proof gap is closed."
+
+func mailCollectionResult(collection, description string) *contract.ResultSpec {
+ identity := mailCollectionIdentitySchema(collection)
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ SensitivePaths: mailCollectionSensitivePaths(collection),
+ DataSchema: json.RawMessage(fmt.Sprintf(
+ `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`,
+ description, collection, description, identity, collection,
+ )),
+ }
+}
+
+func mailCollectionSensitivePaths(collection string) []string {
+ switch collection {
+ case "threads":
+ return []string{"threads.subject", "threads.lastUpdated"}
+ case "folders":
+ return []string{"folders.name"}
+ case "tags":
+ return []string{"tags.name"}
+ case "users":
+ return []string{"users.name", "users.email", "users.employeeNo"}
+ case "templates":
+ return []string{"templates.name", "templates.subject"}
+ case "contacts":
+ return []string{"contacts.contactEmail", "contacts.displayName"}
+ case "messages":
+ return []string{"messages.subject", "messages.body", "messages.markdownBody", "messages.from", "messages.toRecipients", "messages.ccRecipients", "messages.bccRecipients", "messages.attachments", "messages.receivedDateTime", "messages.sentDateTime"}
+ default:
+ panic("unsupported Mail collection sensitive paths: " + collection)
+ }
+}
+
+func mailCollectionIdentitySchema(collection string) string {
+ switch collection {
+ case "threads":
+ return `"properties":{"conversationId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["conversationId"]`
+ case "folders", "tags", "templates", "contacts", "messages":
+ return `"properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"]`
+ case "users":
+ return `"properties":{"userId":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["userId"]},{"required":["email"]}]`
+ default:
+ panic("unsupported Mail collection Result identity: " + collection)
+ }
+}
+
+func mailCursorPagination() *contract.PaginationSpec {
+ return &contract.PaginationSpec{
+ Kind: contract.PaginationKindCursor,
+ CursorParameter: "cursor",
+ MetaPath: contract.PaginationMetaPath,
+ EndpointExhaustedPath: contract.PaginationExhaustedPath,
+ NextTokenPath: contract.PaginationNextTokenPath,
+ }
+}
+
+func mailObjectResult(description string) *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(fmt.Sprintf(
+ `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的邮件业务对象","properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
+ description,
+ )),
+ SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.summary", "value.name", "value.email", "value.contactEmail", "value.displayName", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients", "value.senders", "value.attachments", "value.receivedDateTime", "value.sentDateTime"},
+ }
+}
+
+func mailReadSafety() contract.SafetySpec {
+ return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
+}
+
+func mailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
+ if !required && !rt.Changed(flag) {
+ return nil
+ }
+ value := rt.Int(flag)
+ if value < 1 || value > 100 {
+ return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag))
+ }
+ return nil
+}
+
+func mailStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) (string, error) {
+ if !required && !rt.Changed(flag) {
+ return "", nil
+ }
+ value, err := strconv.Atoi(rt.Str(flag))
+ if err != nil || value < 1 || value > 100 {
+ return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag))
+ }
+ return strconv.Itoa(value), nil
+}
+
+func mailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
+ _, err := mailStringPageSize(rt, flag, required)
+ return err
+}
+
+func mailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error {
+ if strings.TrimSpace(rt.Str(flag)) == "" {
+ return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag))
+ }
+ return nil
+}
+
+func mailValidateThreadList(rt *shortcut.RuntimeContext) error {
+ if err := mailValidatePageSize(rt, "limit", true); err != nil {
+ return err
+ }
+ parsed := make(map[string]time.Time, 2)
+ for _, flag := range []string{"start", "end"} {
+ if !rt.Changed(flag) {
+ continue
+ }
+ value, err := time.Parse(time.RFC3339, strings.TrimSpace(rt.Str(flag)))
+ if err != nil {
+ return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间", flag))
+ }
+ _, offset := value.Zone()
+ if offset != 0 {
+ return apperrors.NewValidation(fmt.Sprintf("--%s 必须使用 UTC 时区", flag))
+ }
+ parsed[flag] = value
+ }
+ start, hasStart := parsed["start"]
+ end, hasEnd := parsed["end"]
+ if hasStart && hasEnd && end.Before(start) {
+ return apperrors.NewValidation("--end 不能早于 --start")
+ }
+ return nil
+}
+
+func mailWriteSafety(idempotency string) contract.SafetySpec {
+ return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency}
+}
+
+func mailReadContract(command, description, intent string, result *contract.ResultSpec, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
+ name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
+ cliPath := "mail " + command
+ return corecmd.ContractDecl{
+ Description: description,
+ Result: result,
+ Identity: contract.ToolIdentitySpec{
+ ProductID: "mail", Name: name, CanonicalPath: "mail." + name,
+ CLIPath: cliPath, PrimaryCLIPath: cliPath,
+ },
+ Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason},
+ Selection: contract.SelectionSpec{
+ AgentSummary: description,
+ UseWhen: []string{intent},
+ AvoidWhen: []string{"只需摘要列表时使用 mail +triage 或 +recent-mail;需要原始响应时改用对应原子命令"},
+ Examples: examples,
+ },
+ Parameters: flags,
+ }
+}
+
+func mailWriteContract(command, description, intent string, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
+ contractDecl := mailReadContract(command, description, intent, mailObjectResult(description), flags, examples...)
+ contractDecl.Selection.AvoidWhen = []string{"用户尚未确认写入内容或目标时不要执行;只需读取时使用对应只读 shortcut"}
+ return contractDecl
+}
+
+func mailResponseError(operation, reason, message string) error {
+ return apperrors.NewAPI(message,
+ apperrors.WithOperation(operation),
+ apperrors.WithOrigin("mcp"),
+ apperrors.WithFailureStage("response_validation"),
+ apperrors.WithRetryable(false),
+ apperrors.WithReason(reason),
+ )
+}
+
+// mailRequireSuccess accepts only the two success encodings observed from the
+// Mail backend: boolean true and the exact string "true". Missing, false, or
+// malformed status values fail closed.
+func mailRequireSuccess(data map[string]any, operation string) error {
+ if len(data) == 0 {
+ return mailResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空")
+ }
+ switch value := data["success"].(type) {
+ case bool:
+ if value {
+ return nil
+ }
+ case string:
+ if value == "true" {
+ return nil
+ }
+ }
+ if _, present := data["success"]; !present {
+ return mailResponseError(operation, "missing_success", "响应缺少 success 业务状态")
+ }
+ message := mailFirstString(data, "errorMessage", "errorMsg", "message", "error")
+ if message == "" {
+ message = "服务未明确返回成功状态"
+ }
+ return mailResponseError(operation, "remote_failure", message)
+}
+
+func mailRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
+ if err := mailRequireSuccess(data, operation); err != nil {
+ return nil, err
+ }
+ value, present := mailLookup(data, path)
+ if !present {
+ return nil, mailResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
+ }
+ raw, ok := value.([]any)
+ if !ok {
+ return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
+ }
+ items := make([]map[string]any, 0, len(raw))
+ for index, item := range raw {
+ object, ok := item.(map[string]any)
+ if !ok || len(object) == 0 {
+ return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
+ }
+ items = append(items, object)
+ }
+ return items, nil
+}
+
+func mailRequireObject(data map[string]any, operation, path string) (map[string]any, error) {
+ if err := mailRequireSuccess(data, operation); err != nil {
+ return nil, err
+ }
+ value, present := mailLookup(data, path)
+ if !present || value == nil {
+ return nil, mailResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path))
+ }
+ object, ok := value.(map[string]any)
+ if !ok || len(object) == 0 {
+ return nil, mailResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value))
+ }
+ return object, nil
+}
+
+func mailRequireIdentity(object map[string]any, operation, expected string, keys ...string) error {
+ actual := mailFirstString(object, keys...)
+ if actual == "" {
+ return mailResponseError(operation, "missing_stable_id", "业务对象缺少稳定 ID")
+ }
+ if expected != "" && actual != expected {
+ return mailResponseError(operation, "identity_mismatch", "业务对象 ID 与请求目标不一致")
+ }
+ return nil
+}
+
+func mailValidateRows(items []map[string]any, operation string, identityKeys ...string) error {
+ allowNumericID := false
+ for _, key := range identityKeys {
+ if key == "email" {
+ allowNumericID = true
+ break
+ }
+ }
+ for index, item := range items {
+ valid := mailFirstString(item, identityKeys...) != ""
+ if !valid && allowNumericID {
+ valid = mailNumericIdentity(item["id"])
+ }
+ if !valid {
+ return mailResponseError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
+ }
+ }
+ return nil
+}
+
+func mailNumericIdentity(value any) bool {
+ switch value.(type) {
+ case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number:
+ return true
+ default:
+ return false
+ }
+}
+
+func mailProjectValue(operation, outputName string, value any) (any, bool, error) {
+ if value == nil {
+ return nil, false, nil
+ }
+ switch outputName {
+ case "email":
+ text, ok := value.(string)
+ if !ok {
+ return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 email 必须是字符串")
+ }
+ text = strings.TrimSpace(text)
+ return text, text != "", nil
+ case "userId":
+ if text, ok := value.(string); ok {
+ text = strings.TrimSpace(text)
+ return text, text != "", nil
+ }
+ if !mailNumericIdentity(value) {
+ return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 userId 必须是字符串或数字")
+ }
+ return value, true, nil
+ default:
+ if text, ok := value.(string); ok {
+ if strings.TrimSpace(text) == "" {
+ return nil, false, nil
+ }
+ return text, true, nil
+ }
+ return value, true, nil
+ }
+}
+
+func mailProjectCollection(data map[string]any, operation, path string, identityKeys []string, fields map[string][]string) ([]map[string]any, error) {
+ items, err := mailRequireCollection(data, operation, path)
+ if err != nil {
+ return nil, err
+ }
+ if err := mailValidateRows(items, operation, identityKeys...); err != nil {
+ return nil, err
+ }
+ projected := make([]map[string]any, 0, len(items))
+ for _, item := range items {
+ row := make(map[string]any, len(fields))
+ for outputName, candidates := range fields {
+ for _, candidate := range candidates {
+ if value, present := item[candidate]; present && value != nil {
+ projectedValue, include, err := mailProjectValue(operation, outputName, value)
+ if err != nil {
+ return nil, err
+ }
+ if !include {
+ continue
+ }
+ row[outputName] = projectedValue
+ break
+ }
+ }
+ }
+ projected = append(projected, row)
+ }
+ return projected, nil
+}
+
+func mailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) {
+ container := data
+ if prefix != "" {
+ value, present := mailLookup(data, prefix)
+ if !present {
+ return false, "", mailResponseError(operation, "missing_pagination", "分页响应缺少已审核的分页对象")
+ }
+ var ok bool
+ container, ok = value.(map[string]any)
+ if !ok {
+ return false, "", mailResponseError(operation, "malformed_pagination", "分页容器不是对象")
+ }
+ }
+ nextValue, nextPresent := container["nextCursor"]
+ next, nextOK := nextValue.(string)
+ if !nextPresent || !nextOK {
+ return false, "", mailResponseError(operation, "missing_pagination", "响应缺少字符串 nextCursor,无法证明结果是否完整")
+ }
+ if raw, present := container["hasMore"]; present {
+ hasMore, ok := mailBool(raw)
+ if !ok {
+ return false, "", mailResponseError(operation, "malformed_pagination", "hasMore 应为布尔值")
+ }
+ terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$"
+ if hasMore && terminal {
+ return false, "", mailResponseError(operation, "missing_next_cursor", "hasMore=true 但 nextCursor 为空")
+ }
+ if !hasMore && !terminal {
+ return false, "", mailResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空")
+ }
+ if !hasMore {
+ return true, "", nil
+ }
+ if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
+ return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
+ }
+ return false, next, nil
+ }
+ if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" {
+ return true, "", nil
+ }
+ if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
+ return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
+ }
+ return false, next, nil
+}
+
+func mailBool(value any) (bool, bool) {
+ switch typed := value.(type) {
+ case bool:
+ return typed, true
+ case string:
+ if typed == "true" {
+ return true, true
+ }
+ if typed == "false" {
+ return false, true
+ }
+ }
+ return false, false
+}
+
+func mailLookup(object map[string]any, path string) (any, bool) {
+ var current any = object
+ for _, segment := range strings.Split(path, ".") {
+ mapped, ok := current.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ current, ok = mapped[segment]
+ if !ok {
+ return nil, false
+ }
+ }
+ return current, true
+}
+
+func mailFirstString(object map[string]any, keys ...string) string {
+ for _, key := range keys {
+ if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+func mailCollectionPayload(collection string, items []map[string]any, complete bool, next string) map[string]any {
+ payload := map[string]any{"count": len(items), collection: items, "complete": complete}
+ if !complete {
+ payload["nextCursor"] = next
+ }
+ return payload
+}
+
+func mailBusinessCollectionPayload(collection string, items []map[string]any) map[string]any {
+ return map[string]any{"count": len(items), collection: items}
+}
+
+// mailOutputPage preserves legacy payload compatibility while keeping cursor
+// controls in the unified envelope's meta.pagination contract.
+func mailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error {
+ payload := mailCollectionPayload(collection, items, complete, next)
+ if !output.UsesUnifiedResult(rt.Command()) {
+ return rt.Output(payload)
+ }
+ pagination, err := output.NewPagination(complete, next)
+ if err != nil {
+ return mailResponseError("mail/pagination", "invalid_pagination", err.Error())
+ }
+ business := map[string]any{"count": len(items), collection: items}
+ meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))}
+ return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
+}
+
+func hardenPublicMailContracts() {
+ collections := []struct {
+ declaration *shortcut.Shortcut
+ collection string
+ description string
+ paginated bool
+ }{
+ {&ThreadList, "threads", "严格校验的邮件会话列表", true},
+ {&FolderList, "folders", "严格校验的邮箱文件夹列表", false},
+ {&TagList, "tags", "严格校验的邮件标签列表", false},
+ {&UserSearch, "users", "严格校验的企业邮箱用户搜索结果", true},
+ {&TemplateList, "templates", "严格校验的邮件模板列表", true},
+ {&ContactList, "contacts", "严格校验的邮件联系人列表", true},
+ }
+ for _, item := range collections {
+ item.declaration.OutputRollout = output.RolloutUnifiedActive
+ item.declaration.Contract.Result = mailCollectionResult(item.collection, item.description)
+ if item.paginated {
+ item.declaration.Contract.Pagination = mailCursorPagination()
+ }
+ item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason}
+ }
+ markMailCompatibilityOnly(&ThreadList)
+ markMailCompatibilityOnly(&TagList)
+ markMailCompatibilityOnly(&TemplateList)
+ markMailCompatibilityOnly(&ContactList)
+}
+
+func markMailCompatibilityOnly(declaration *shortcut.Shortcut) {
+ declaration.OutputRollout = output.RolloutLegacyOnly
+ declaration.Contract.Result = nil
+ declaration.Contract.Pagination = nil
+ declaration.Contract.Interface = &contract.InterfaceSpec{
+ Mode: contract.InterfaceModeComposite,
+ Availability: contract.InterfaceAvailable,
+ Reason: mailCompatibilityInterfaceReason,
+ }
+}
+
+func markMailUnavailable(declaration *shortcut.Shortcut, reason string) {
+ declaration.OutputRollout = output.RolloutLegacyOnly
+ declaration.Contract.Result = nil
+ declaration.Contract.Pagination = nil
+ declaration.Contract.Interface = &contract.InterfaceSpec{
+ Mode: contract.InterfaceModeComposite,
+ Availability: contract.InterfaceUnavailable,
+ Reason: reason,
+ }
+}
diff --git a/internal/shortcut/mail/mail.go b/internal/shortcut/mail/mail.go
index 525505ce..2aeab198 100644
--- a/internal/shortcut/mail/mail.go
+++ b/internal/shortcut/mail/mail.go
@@ -18,8 +18,11 @@
package mail
import (
+ "strings"
+
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -64,6 +67,13 @@ var ThreadList = shortcut.Shortcut{
PrimaryCLIPath: "mail +thread-list",
},
Description: "列出指定邮箱文件夹下的邮件会话(thread)",
+ Parameters: []contract.ParamDecl{
+ {Name: "folder", Property: "folder"},
+ {Name: "limit", Property: "limit"},
+ {Name: "start", Property: "start"},
+ {Name: "end", Property: "end"},
+ {Name: "ascending", Property: "ascending"},
+ },
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -85,6 +95,13 @@ var ThreadList = shortcut.Shortcut{
{Name: "end", Type: shortcut.FlagString, Desc: "结束 UTC 时间,如 2024-12-31T23:59:59Z"},
{Name: "ascending", Type: shortcut.FlagBool, Desc: "是否按时间升序"},
},
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 UTC RFC3339 时间,且 end 不能早于 start"},
+ },
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ return mailValidateThreadList(rt)
+ },
Tips: []string{
`dws mail +thread-list --email user@company.com --folder 104 --limit 20`,
},
@@ -110,76 +127,20 @@ var ThreadList = shortcut.Shortcut{
if err != nil {
return err
}
- threads := threadListProject(data)
- return rt.Output(map[string]any{"count": len(threads), "threads": threads})
+ threads, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{
+ "conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"},
+ })
+ if err != nil {
+ return err
+ }
+ complete, next, err := mailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor"))
+ if err != nil {
+ return err
+ }
+ return mailOutputPage(rt, "threads", threads, complete, next)
},
}
-// threadListProject reshapes the raw list_mailbox_threads response into a clean
-// {conversationId, subject, lastUpdated, isRead} thread list —
-// clean output projection. Both the list container and per-item
-// field names are probed defensively across candidate keys, so an empty/unknown
-// shape yields an empty list rather than a crash or fabricated data.
-func threadListProject(data map[string]any) []map[string]any {
- raw := threadListResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- row := map[string]any{}
- if v, ok := threadListFirst(m, "conversationId", "conversation_id", "id", "threadId"); ok {
- row["conversationId"] = v
- }
- if v, ok := threadListFirst(m, "subject", "title", "topic"); ok {
- row["subject"] = v
- }
- if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "lastModifiedDateTime", "updateTime", "modifiedTime", "sentDateTime"); ok {
- row["lastUpdated"] = v
- }
- if v, ok := threadListFirst(m, "isRead", "is_read", "read", "unread"); ok {
- row["isRead"] = v
- }
- if len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// threadListResolveList locates the list payload inside the response, tolerating
-// a bare top-level array container or nesting one level deeper.
-func threadListResolveList(data map[string]any) []any {
- for _, key := range []string{"result", "data", "list", "items", "threads", "conversations"} {
- v, ok := data[key]
- if !ok {
- continue
- }
- if arr, ok := v.([]any); ok {
- return arr
- }
- if inner, ok := v.(map[string]any); ok {
- for _, ik := range []string{"list", "items", "threads", "conversations", "result", "data"} {
- if arr, ok := inner[ik].([]any); ok {
- return arr
- }
- }
- }
- }
- return []any{}
-}
-
-// threadListFirst returns the first present candidate key's value.
-func threadListFirst(m map[string]any, keys ...string) (any, bool) {
- for _, k := range keys {
- if v, ok := m[k]; ok {
- return v, true
- }
- }
- return nil, false
-}
-
// Thread 根据会话 ID 获取会话详情。
// ThreadUpdate 修改单个邮件会话的状态或标签。
// ThreadBatchUpdate 批量修改邮件会话的状态或标签。
@@ -208,6 +169,7 @@ var FolderList = shortcut.Shortcut{
PrimaryCLIPath: "mail +folder-list",
},
Description: "列出顶层文件夹或指定父文件夹下的子文件夹",
+ Parameters: []contract.ParamDecl{{Name: "folder", Property: "folder"}},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -221,9 +183,13 @@ var FolderList = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
- {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址", Required: true},
+ {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址,不能为空", Required: true},
{Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID,不传则返回顶层文件夹"},
},
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"email"}, Description: "不能为空"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ return mailValidateRequiredText(rt, "email")
+ },
Tips: []string{
`dws mail +folder-list --email user@company.com`,
},
@@ -238,73 +204,16 @@ var FolderList = shortcut.Shortcut{
if err != nil {
return err
}
- folders := folderListProject(data)
- return rt.Output(map[string]any{"count": len(folders), "folders": folders})
+ folders, err := mailProjectCollection(data, "mail/list_folders", "folders", []string{"id"}, map[string][]string{
+ "id": {"id"}, "name": {"displayName"}, "parentId": {"parentFolderId"},
+ })
+ if err != nil {
+ return err
+ }
+ return rt.Output(mailBusinessCollectionPayload("folders", folders))
},
}
-// folderListProject reshapes the raw list_folders response into a clean
-// {id, name, parentId} folder list — clean output projection. Both
-// the list container and per-item field names are probed defensively across
-// candidate keys, so an empty/unknown shape yields an empty list rather than a
-// crash or fabricated data.
-func folderListProject(data map[string]any) []map[string]any {
- raw := folderListResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- row := map[string]any{}
- if v, ok := folderListFirst(m, "id", "folderId", "folder_id"); ok {
- row["id"] = v
- }
- if v, ok := folderListFirst(m, "name", "folderName", "folder_name", "displayName"); ok {
- row["name"] = v
- }
- if v, ok := folderListFirst(m, "parentId", "parent_id", "parentFolderId"); ok {
- row["parentId"] = v
- }
- if len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// folderListResolveList locates the list payload inside the response, tolerating
-// a bare top-level array container or nesting one level deeper.
-func folderListResolveList(data map[string]any) []any {
- for _, key := range []string{"result", "data", "list", "items", "folders"} {
- v, ok := data[key]
- if !ok {
- continue
- }
- if arr, ok := v.([]any); ok {
- return arr
- }
- if inner, ok := v.(map[string]any); ok {
- for _, ik := range []string{"list", "items", "folders", "result", "data"} {
- if arr, ok := inner[ik].([]any); ok {
- return arr
- }
- }
- }
- }
- return []any{}
-}
-
-// folderListFirst returns the first present candidate key's value.
-func folderListFirst(m map[string]any, keys ...string) (any, bool) {
- for _, k := range keys {
- if v, ok := m[k]; ok {
- return v, true
- }
- }
- return nil, false
-}
-
// FolderCreate 创建邮件文件夹。
// FolderUpdate 更新邮件文件夹名称。
// FolderDelete 删除邮件文件夹。
@@ -356,73 +265,16 @@ var TagList = shortcut.Shortcut{
if err != nil {
return err
}
- tags := tagListProject(data)
- return rt.Output(map[string]any{"count": len(tags), "tags": tags})
+ tags, err := mailProjectCollection(data, "mail/list_tags", "tags", []string{"id"}, map[string][]string{
+ "id": {"id"}, "name": {"name"}, "parentId": {"parentId"},
+ })
+ if err != nil {
+ return err
+ }
+ return rt.Output(mailBusinessCollectionPayload("tags", tags))
},
}
-// tagListProject reshapes the raw list_tags response into a clean
-// {id, name, parentId} tag list — clean output projection. Both the
-// list container and per-item field names are probed defensively across
-// candidate keys, so an empty/unknown shape yields an empty list rather than a
-// crash or fabricated data.
-func tagListProject(data map[string]any) []map[string]any {
- raw := tagListResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- row := map[string]any{}
- if v, ok := tagListFirst(m, "id", "tagId", "tag_id"); ok {
- row["id"] = v
- }
- if v, ok := tagListFirst(m, "name", "tagName", "tag_name", "displayName"); ok {
- row["name"] = v
- }
- if v, ok := tagListFirst(m, "parentId", "parent_id"); ok {
- row["parentId"] = v
- }
- if len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// tagListResolveList locates the list payload inside the response, tolerating a
-// bare top-level array container or nesting one level deeper.
-func tagListResolveList(data map[string]any) []any {
- for _, key := range []string{"result", "data", "list", "items", "tags"} {
- v, ok := data[key]
- if !ok {
- continue
- }
- if arr, ok := v.([]any); ok {
- return arr
- }
- if inner, ok := v.(map[string]any); ok {
- for _, ik := range []string{"list", "items", "tags", "result", "data"} {
- if arr, ok := inner[ik].([]any); ok {
- return arr
- }
- }
- }
- }
- return []any{}
-}
-
-// tagListFirst returns the first present candidate key's value.
-func tagListFirst(m map[string]any, keys ...string) (any, bool) {
- for _, k := range keys {
- if v, ok := m[k]; ok {
- return v, true
- }
- }
- return nil, false
-}
-
// TagCreate 创建邮件标签。
// TagUpdate 更新邮件标签名称。
// TagDelete 删除邮件标签。
@@ -449,6 +301,10 @@ var UserSearch = shortcut.Shortcut{
PrimaryCLIPath: "mail +user-search",
},
Description: "按关键词或工号搜索邮箱用户(仅企业邮箱)",
+ Parameters: []contract.ParamDecl{
+ {Name: "employee-no", Property: "employeeNo"},
+ {Name: "limit", Property: "limit"},
+ },
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -465,20 +321,40 @@ var UserSearch = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
- {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词(未提供 --employee-no 时为必填)"},
- {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索"},
+ {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词;显式提供时不能为空(未提供 --employee-no 时为必填)"},
+ {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索;显式提供时不能为空"},
{Name: "email", Type: shortcut.FlagString, Desc: "搜索目标邮箱地址"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"},
- {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量"},
+ {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "employee-no"}},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"keyword", "employee-no"}, Description: "不能为空"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"},
+ },
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if err := mailValidateStringPageSize(rt, "limit", false); err != nil {
+ return err
+ }
+ for _, name := range []string{"keyword", "employee-no"} {
+ if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" {
+ return apperrors.NewValidation("--" + name + " 显式提供时不能为空")
+ }
+ }
+ if strings.TrimSpace(rt.Str("keyword")) == "" && strings.TrimSpace(rt.Str("employee-no")) == "" {
+ return apperrors.NewValidation("--keyword 和 --employee-no 至少需要一个非空值")
+ }
+ return nil
},
Tips: []string{
`dws mail +user-search --keyword "张三"`,
`dws mail +user-search --email user@company.com --employee-no "E123456"`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
+ size, err := mailStringPageSize(rt, "limit", false)
+ if err != nil {
+ return err
+ }
params := map[string]any{}
if rt.Str("keyword") != "" {
params["keyword"] = rt.Str("keyword")
@@ -493,82 +369,26 @@ var UserSearch = shortcut.Shortcut{
params["cursor"] = rt.Str("cursor")
}
if rt.Changed("limit") {
- params["size"] = rt.Str("limit")
+ params["size"] = size
}
data, err := rt.CallMCPData("mail", "search_mail_users", params)
if err != nil {
return err
}
- users := userSearchProject(data)
- return rt.Output(map[string]any{"count": len(users), "users": users})
+ users, err := mailProjectCollection(data, "mail/search_mail_users", "users", []string{"id", "email"}, map[string][]string{
+ "name": {"name"}, "email": {"email"}, "employeeNo": {"employeeNo"}, "userId": {"id"},
+ })
+ if err != nil {
+ return err
+ }
+ complete, next, err := mailPage(data, "mail/search_mail_users", "", rt.Str("cursor"))
+ if err != nil {
+ return err
+ }
+ return mailOutputPage(rt, "users", users, complete, next)
},
}
-// userSearchProject reshapes the raw search_mail_users response into a clean
-// {name, email, employeeNo, userId} user list — clean output projection.
-// Both the list container and per-item field names are probed defensively
-// across candidate keys, so an empty/unknown shape yields an empty list rather
-// than a crash or fabricated data.
-func userSearchProject(data map[string]any) []map[string]any {
- raw := userSearchResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- row := map[string]any{}
- if v, ok := userSearchFirst(m, "name", "userName", "displayName", "nickName"); ok {
- row["name"] = v
- }
- if v, ok := userSearchFirst(m, "email", "mail", "emailAddress"); ok {
- row["email"] = v
- }
- if v, ok := userSearchFirst(m, "employeeNo", "employee_no", "employeeNumber", "jobNumber"); ok {
- row["employeeNo"] = v
- }
- if v, ok := userSearchFirst(m, "userId", "user_id", "id"); ok {
- row["userId"] = v
- }
- if len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// userSearchResolveList locates the list payload inside the response, tolerating
-// a bare top-level array container or nesting one level deeper.
-func userSearchResolveList(data map[string]any) []any {
- for _, key := range []string{"result", "data", "list", "items", "users"} {
- v, ok := data[key]
- if !ok {
- continue
- }
- if arr, ok := v.([]any); ok {
- return arr
- }
- if inner, ok := v.(map[string]any); ok {
- for _, ik := range []string{"list", "items", "users", "result", "data"} {
- if arr, ok := inner[ik].([]any); ok {
- return arr
- }
- }
- }
- }
- return []any{}
-}
-
-// userSearchFirst returns the first present candidate key's value.
-func userSearchFirst(m map[string]any, keys ...string) (any, bool) {
- for _, k := range keys {
- if v, ok := m[k]; ok {
- return v, true
- }
- }
- return nil, false
-}
-
// ── attachment ─────────────────────────────────────────────
// AttachmentList 列举邮件附件。
@@ -596,6 +416,7 @@ var TemplateList = shortcut.Shortcut{
PrimaryCLIPath: "mail +template-list",
},
Description: "列出指定邮箱的所有邮件模板",
+ Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -610,16 +431,24 @@ var TemplateList = shortcut.Shortcut{
},
Flags: []shortcut.Flag{
{Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true},
- {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true},
+ {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"},
},
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ return mailValidateStringPageSize(rt, "limit", true)
+ },
Tips: []string{
`dws mail +template-list --email user@company.com --limit 20`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
+ size, err := mailStringPageSize(rt, "limit", true)
+ if err != nil {
+ return err
+ }
params := map[string]any{
"email": rt.Str("email"),
- "size": rt.Str("limit"),
+ "size": size,
}
if rt.Changed("cursor") {
params["cursor"] = rt.Str("cursor")
@@ -628,73 +457,20 @@ var TemplateList = shortcut.Shortcut{
if err != nil {
return err
}
- templates := templateListProject(data)
- return rt.Output(map[string]any{"count": len(templates), "templates": templates})
+ templates, err := mailProjectCollection(data, "mail/list_user_message_templates", "templates", []string{"id"}, map[string][]string{
+ "id": {"id"}, "name": {"name"}, "subject": {"subject"},
+ })
+ if err != nil {
+ return err
+ }
+ complete, next, err := mailPage(data, "mail/list_user_message_templates", "", rt.Str("cursor"))
+ if err != nil {
+ return err
+ }
+ return mailOutputPage(rt, "templates", templates, complete, next)
},
}
-// templateListProject reshapes the raw list_user_message_templates response into
-// a clean {id, name, subject} template list — clean output projection.
-// Both the list container and per-item field names are probed defensively
-// across candidate keys, so an empty/unknown shape yields an empty list rather
-// than a crash or fabricated data.
-func templateListProject(data map[string]any) []map[string]any {
- raw := templateListResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- row := map[string]any{}
- if v, ok := templateListFirst(m, "id", "templateId", "template_id"); ok {
- row["id"] = v
- }
- if v, ok := templateListFirst(m, "name", "templateName", "template_name", "displayName"); ok {
- row["name"] = v
- }
- if v, ok := templateListFirst(m, "subject", "title"); ok {
- row["subject"] = v
- }
- if len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// templateListResolveList locates the list payload inside the response,
-// tolerating a bare top-level array container or nesting one level deeper.
-func templateListResolveList(data map[string]any) []any {
- for _, key := range []string{"result", "data", "list", "items", "templates"} {
- v, ok := data[key]
- if !ok {
- continue
- }
- if arr, ok := v.([]any); ok {
- return arr
- }
- if inner, ok := v.(map[string]any); ok {
- for _, ik := range []string{"list", "items", "templates", "result", "data"} {
- if arr, ok := inner[ik].([]any); ok {
- return arr
- }
- }
- }
- }
- return []any{}
-}
-
-// templateListFirst returns the first present candidate key's value.
-func templateListFirst(m map[string]any, keys ...string) (any, bool) {
- for _, k := range keys {
- if v, ok := m[k]; ok {
- return v, true
- }
- }
- return nil, false
-}
-
// TemplateGet 获取邮件模板详情。
// TemplateUpdate 更新邮件模板。
// TemplateDelete 删除邮件模板。
@@ -722,6 +498,7 @@ var ContactList = shortcut.Shortcut{
PrimaryCLIPath: "mail +contact-list",
},
Description: "列出指定邮箱的所有邮件联系人",
+ Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -736,16 +513,24 @@ var ContactList = shortcut.Shortcut{
},
Flags: []shortcut.Flag{
{Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true},
- {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true},
+ {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"},
},
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ return mailValidateStringPageSize(rt, "limit", true)
+ },
Tips: []string{
`dws mail +contact-list --email user@company.com --limit 20`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
+ size, err := mailStringPageSize(rt, "limit", true)
+ if err != nil {
+ return err
+ }
params := map[string]any{
"email": rt.Str("email"),
- "size": rt.Str("limit"),
+ "size": size,
}
if rt.Changed("cursor") {
params["cursor"] = rt.Str("cursor")
@@ -754,73 +539,20 @@ var ContactList = shortcut.Shortcut{
if err != nil {
return err
}
- contacts := contactListProject(data)
- return rt.Output(map[string]any{"count": len(contacts), "contacts": contacts})
+ contacts, err := mailProjectCollection(data, "mail/list_user_mail_contacts", "contacts", []string{"id"}, map[string][]string{
+ "id": {"id"}, "contactEmail": {"contactEmail", "email"}, "displayName": {"displayName", "name"},
+ })
+ if err != nil {
+ return err
+ }
+ complete, next, err := mailPage(data, "mail/list_user_mail_contacts", "", rt.Str("cursor"))
+ if err != nil {
+ return err
+ }
+ return mailOutputPage(rt, "contacts", contacts, complete, next)
},
}
-// contactListProject reshapes the raw list_user_mail_contacts response into a
-// clean {id, contactEmail, displayName} contact list — output-projection
-// clean output projection. Both the list container and per-item field names are probed
-// defensively across candidate keys, so an empty/unknown shape yields an empty
-// list rather than a crash or fabricated data.
-func contactListProject(data map[string]any) []map[string]any {
- raw := contactListResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- row := map[string]any{}
- if v, ok := contactListFirst(m, "id", "contactId", "contact_id"); ok {
- row["id"] = v
- }
- if v, ok := contactListFirst(m, "contactEmail", "contact_email", "email"); ok {
- row["contactEmail"] = v
- }
- if v, ok := contactListFirst(m, "displayName", "display_name", "name"); ok {
- row["displayName"] = v
- }
- if len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// contactListResolveList locates the list payload inside the response,
-// tolerating a bare top-level array container or nesting one level deeper.
-func contactListResolveList(data map[string]any) []any {
- for _, key := range []string{"result", "data", "list", "items", "contacts"} {
- v, ok := data[key]
- if !ok {
- continue
- }
- if arr, ok := v.([]any); ok {
- return arr
- }
- if inner, ok := v.(map[string]any); ok {
- for _, ik := range []string{"list", "items", "contacts", "result", "data"} {
- if arr, ok := inner[ik].([]any); ok {
- return arr
- }
- }
- }
- }
- return []any{}
-}
-
-// contactListFirst returns the first present candidate key's value.
-func contactListFirst(m map[string]any, keys ...string) (any, bool) {
- for _, k := range keys {
- if v, ok := m[k]; ok {
- return v, true
- }
- }
- return nil, false
-}
-
// ContactUpdate 更新邮件联系人。
// ContactBatchDelete 批量删除邮件联系人。
// ── auto-reply ─────────────────────────────────────────────
@@ -834,6 +566,7 @@ func contactListFirst(m map[string]any, keys ...string) (any, bool) {
// RuleDelete 删除个人收信规则。
// RuleAdjust 调整收信规则排序。
func init() {
+ hardenPublicMailContracts()
shortcut.Register(
ThreadList,
FolderList,
diff --git a/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go
new file mode 100644
index 00000000..248ecd6c
--- /dev/null
+++ b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go
@@ -0,0 +1,422 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package mail
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func runMailDeclarationForCoverage(t *testing.T, declaration shortcut.Shortcut, caller *mailWriteContractCaller, args ...string) error {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs(args)
+ return cmd.Execute()
+}
+
+func mailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext {
+ t.Helper()
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ for name, value := range values {
+ if err := cmd.Flags().Set(name, value); err != nil {
+ t.Fatalf("set --%s: %v", name, err)
+ }
+ }
+ return shortcut.RuntimeContextForTest(cmd, declaration)
+}
+
+func TestCrossPlatformCoverageMailReadLeavesExerciseExactIdentityAndMailboxBranches(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ args []string
+ responses map[string][]string
+ errors map[string]error
+ wantErr bool
+ wantCalls int
+ }{
+ {name: "message explicit mailbox", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 1},
+ {name: "message auto mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2},
+ {name: "message auto nested string mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2},
+ {name: "mailbox transport", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1},
+ {name: "mailbox missing collection", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true}`}}, wantErr: true, wantCalls: 1},
+ {name: "mailbox empty", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[]}`}}, wantErr: true, wantCalls: 1},
+ {name: "mailbox missing identity", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"name":"mailbox"}]}`}}, wantErr: true, wantCalls: 1},
+ {name: "message transport", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("message transport")}, wantErr: true, wantCalls: 1},
+ {name: "message malformed", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":[]}`}}, wantErr: true, wantCalls: 1},
+ {name: "message wrong identity", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 1},
+ {name: "messages ordered", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"message-2"}}`}}, wantCalls: 2},
+ {name: "messages auto data mailbox", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"data":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2},
+ {name: "messages second fails", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 2},
+ {name: "messages mailbox resolution fails", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1},
+ {name: "thread exact", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 1},
+ {name: "thread auto result mailbox", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 2},
+ {name: "thread transport", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"get_thread": fmt.Errorf("thread transport")}, wantErr: true, wantCalls: 1},
+ {name: "thread mailbox resolution fails", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1},
+ {name: "thread missing result", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true}`}}, wantErr: true, wantCalls: 1},
+ {name: "thread wrong identity", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"other"}}`}}, wantErr: true, wantCalls: 1},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: tc.responses, errors: tc.errors}
+ if caller.responses == nil {
+ caller.responses = map[string][]string{}
+ }
+ if caller.errors == nil {
+ caller.errors = map[string]error{}
+ }
+ err := runConfirmedMailWriteContract(t, caller, tc.args...)
+ if tc.wantErr && err == nil {
+ t.Fatal("invalid read unexpectedly succeeded")
+ }
+ if !tc.wantErr && err != nil {
+ t.Fatalf("valid read failed: %v", err)
+ }
+ if len(caller.calls) != tc.wantCalls {
+ t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
+ }
+ })
+ }
+
+ if err := mailValidateMessageIDs(nil); err == nil {
+ t.Fatal("empty ids unexpectedly valid")
+ }
+ caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
+ helpers.InitDepsForTest(t, caller)
+ rt := mailRuntimeForCoverage(t, Messages, map[string]string{"email": "mail@example.invalid", "ids": " "})
+ if err := Messages.Execute(rt); err == nil {
+ t.Fatal("defensive empty-id guard unexpectedly succeeded")
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("defensive empty-id guard made %d calls", len(caller.calls))
+ }
+}
+
+func TestCrossPlatformCoverageMailMailboxResolverSupportsReviewedShapesAndFailsClosed(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ data map[string]any
+ }{
+ {
+ name: "top string",
+ data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}},
+ },
+ {
+ name: "top object",
+ data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}},
+ },
+ {
+ name: "result string",
+ data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}},
+ },
+ {
+ name: "result object",
+ data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}},
+ },
+ {
+ name: "data string",
+ data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}},
+ },
+ {
+ name: "data object",
+ data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}},
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := mailMailboxAddresses(tc.data)
+ if err != nil || len(got) != 1 || got[0] != "mail@example.invalid" {
+ t.Fatalf("addresses=%#v err=%v", got, err)
+ }
+ })
+ }
+
+ for name, data := range map[string]map[string]any{
+ "remote failure": {"success": false},
+ "missing collection": {"success": true},
+ "malformed collection": {"success": true, "emailAccounts": map[string]any{}},
+ "blank string": {"success": true, "emailAccounts": []any{" "}},
+ "missing object email": {"success": true, "emailAccounts": []any{map[string]any{"name": "mailbox"}}},
+ "wrong object email": {"success": true, "emailAccounts": []any{map[string]any{"email": 7}}},
+ "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}},
+ "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "result": map[string]any{"emailAccounts": []any{"two@example.invalid"}}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := mailMailboxAddresses(data); err == nil {
+ t.Fatal("invalid mailbox response unexpectedly succeeded")
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageMailListLeavesFailAtOwningBoundary(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ args []string
+ tool string
+ response string
+ remoteErr bool
+ wantErr bool
+ }{
+ {name: "folder happy", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid", "--folder", "parent-1"}, tool: "list_folders", response: `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`},
+ {name: "folder remote", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", remoteErr: true, wantErr: true},
+ {name: "folder malformed", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", response: `{"success":true,"folders":{}}`, wantErr: true},
+ {name: "tag happy", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":[{"id":"tag-1"}]}`},
+ {name: "tag remote", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", remoteErr: true, wantErr: true},
+ {name: "tag malformed", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":{}}`, wantErr: true},
+ {name: "thread malformed rows", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":{},"nextCursor":""}}`, wantErr: true},
+ {name: "thread missing page", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}]}}`, wantErr: true},
+ {name: "user malformed rows", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":{}}`, wantErr: true},
+ {name: "user missing page", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1"}]}`, wantErr: true},
+ {name: "template malformed rows", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":{}}`, wantErr: true},
+ {name: "template missing page", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}]}`, wantErr: true},
+ {name: "contact malformed rows", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":{}}`, wantErr: true},
+ {name: "contact missing page", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}]}`, wantErr: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}}
+ if tc.remoteErr {
+ caller.errors[tc.tool] = fmt.Errorf("remote failure")
+ }
+ err := runConfirmedMailWriteContract(t, caller, tc.args...)
+ if tc.wantErr && err == nil {
+ t.Fatal("invalid list response unexpectedly succeeded")
+ }
+ if !tc.wantErr && err != nil {
+ t.Fatalf("valid list response failed: %v", err)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageMailStrictHelpersCoverMalformedWireAndPagination(t *testing.T) {
+ assertPanic := func(name string, fn func()) {
+ t.Helper()
+ t.Run(name, func(t *testing.T) {
+ deferred := false
+ defer func() {
+ if recover() == nil {
+ t.Fatal("unsupported declaration did not panic")
+ }
+ deferred = true
+ }()
+ fn()
+ _ = deferred
+ })
+ }
+ assertPanic("sensitive collection", func() { mailCollectionSensitivePaths("unknown") })
+ assertPanic("identity collection", func() { mailCollectionIdentitySchema("unknown") })
+
+ if _, err := mailRequireObject(map[string]any{}, "mail/get", "message"); err == nil {
+ t.Fatal("object accepted missing success")
+ }
+ if object, err := mailRequireObject(map[string]any{"success": true, "message": map[string]any{"id": "message-1"}}, "mail/get", "message"); err != nil || object["id"] != "message-1" {
+ t.Fatalf("valid object rejected: %#v %v", object, err)
+ }
+ if _, _, err := mailProjectValue("mail/users", "email", 7); err == nil {
+ t.Fatal("numeric email unexpectedly projected")
+ }
+ if _, include, err := mailProjectValue("mail/users", "userId", " "); err != nil || include {
+ t.Fatalf("blank user id include=%v err=%v", include, err)
+ }
+ if _, include, err := mailProjectValue("mail/test", "name", nil); err != nil || include {
+ t.Fatalf("nil projection include=%v err=%v", include, err)
+ }
+ if _, include, err := mailProjectValue("mail/test", "name", " "); err != nil || include {
+ t.Fatalf("blank projection include=%v err=%v", include, err)
+ }
+ if value, include, err := mailProjectValue("mail/test", "rank", 7); err != nil || !include || value != 7 {
+ t.Fatalf("numeric projection value=%v include=%v err=%v", value, include, err)
+ }
+
+ for name, data := range map[string]map[string]any{
+ "missing prefix": {},
+ "malformed prefix": {"result": []any{}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, _, err := mailPage(data, "mail/page", "result", ""); err == nil {
+ t.Fatal("invalid prefixed page unexpectedly succeeded")
+ }
+ })
+ }
+ for name, data := range map[string]map[string]any{
+ "missing cursor": {},
+ "malformed cursor": {"nextCursor": 7},
+ "malformed hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}},
+ "conflicting terminal": {"nextCursor": "cursor-2", "hasMore": false},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, _, err := mailPage(data, "mail/page", "", ""); err == nil {
+ t.Fatal("invalid page unexpectedly succeeded")
+ }
+ })
+ }
+ if complete, next, err := mailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" {
+ t.Fatalf("continuing cursor rejected: %v %q %v", complete, next, err)
+ }
+ if _, _, err := mailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil {
+ t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded")
+ }
+ if value, ok := mailBool("true"); !ok || !value {
+ t.Fatal("string true not normalized")
+ }
+ if _, ok := mailBool(7); ok {
+ t.Fatal("invalid boolean unexpectedly normalized")
+ }
+ if _, ok := mailLookup(map[string]any{"result": "bad"}, "result.items"); ok {
+ t.Fatal("non-object intermediate lookup unexpectedly succeeded")
+ }
+ declaration := UserSearch
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ cmd.SetOut(io.Discard)
+ rt := shortcut.RuntimeContextForTest(cmd, declaration)
+ if err := mailOutputPage(rt, "users", nil, true, "unexpected-next"); err == nil {
+ t.Fatal("invalid terminal pagination unexpectedly stored")
+ }
+}
+
+func TestCrossPlatformCoverageMailWriteVerificationHelpersAndDryRun(t *testing.T) {
+ for name, message := range map[string]map[string]any{
+ "missing addresses": {},
+ "wrong address collection": {"toRecipients": "bad"},
+ "wrong address item": {"toRecipients": []any{7}},
+ "blank address item": {"toRecipients": []any{map[string]any{"email": " "}}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := mailReadAddresses(message, "mail/verify", "toRecipients"); err == nil {
+ t.Fatal("malformed recipients unexpectedly succeeded")
+ }
+ })
+ }
+ addresses, err := mailReadAddresses(map[string]any{"toRecipients": []any{" A@example.invalid ", map[string]any{"address": "b@example.invalid"}}}, "mail/verify", "toRecipients")
+ if err != nil || len(addresses) != 2 {
+ t.Fatalf("valid recipients rejected: %v %v", addresses, err)
+ }
+ if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"a@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid", "b@example.invalid"}); err == nil {
+ t.Fatal("recipient length mismatch accepted")
+ }
+ if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"b@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil {
+ t.Fatal("recipient value mismatch accepted")
+ }
+ if err := mailVerifyAddresses(map[string]any{}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil {
+ t.Fatal("recipient parser error was not propagated")
+ }
+ if _, err := mailDraftMessage(map[string]any{"success": false}, "mail/create"); err == nil {
+ t.Fatal("failed draft receipt unexpectedly succeeded")
+ }
+ for name, message := range map[string]map[string]any{
+ "missing sender": {},
+ "wrong sender": {"from": []any{}},
+ "blank sender": {"from": " "},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if err := mailVerifySender(message, "mail/verify", "sender@example.invalid"); err == nil {
+ t.Fatal("malformed sender unexpectedly accepted")
+ }
+ })
+ }
+ if err := mailVerifySender(map[string]any{"from": "sender@example.invalid"}, "mail/verify", "SENDER@example.invalid"); err != nil {
+ t.Fatalf("case-insensitive sender rejected: %v", err)
+ }
+
+ for _, args := range [][]string{
+ {"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "subject", "--dry-run"},
+ {"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject", "--dry-run"},
+ {"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--dry-run"},
+ {"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject", "--dry-run"},
+ } {
+ caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
+ if err := runConfirmedMailWriteContract(t, caller, args...); err != nil {
+ t.Fatalf("dry-run failed: %v", err)
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("dry-run made %d remote calls", len(caller.calls))
+ }
+ }
+
+ for _, declaration := range []shortcut.Shortcut{DraftEdit, TemplateUpdate} {
+ caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
+ helpers.InitDepsForTest(t, caller)
+ values := map[string]string{"email": "sender@example.invalid", "id": "template-1"}
+ if declaration.Command == "+draft-edit" {
+ values = map[string]string{"from": "sender@example.invalid", "id": "draft-1"}
+ }
+ rt := mailRuntimeForCoverage(t, declaration, values)
+ if err := declaration.Execute(rt); err == nil {
+ t.Fatal("execute-level no-change guard unexpectedly succeeded")
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("no-change guard made %d calls", len(caller.calls))
+ }
+ }
+
+ for _, tc := range []struct {
+ name string
+ readback string
+ }{
+ {name: "body mismatch", readback: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")},
+ {name: "sender mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid")},
+ {name: "cc mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid")},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: map[string][]string{
+ "create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`},
+ "get_email_by_message_id": {tc.readback},
+ }, errors: map[string]error{}}
+ if err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"); err == nil {
+ t.Fatal("draft verification mismatch unexpectedly succeeded")
+ }
+ })
+ }
+
+ caller := &mailWriteContractCaller{responses: map[string][]string{
+ "create_user_message_template": {`{"success":true,"id":"template-1"}`},
+ "get_user_message_template": {`{"success":"false"}`},
+ }, errors: map[string]error{}}
+ if err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"); err == nil {
+ t.Fatal("failed template readback unexpectedly succeeded")
+ }
+
+ for _, tc := range []struct {
+ declaration shortcut.Shortcut
+ values map[string]string
+ }{
+ {declaration: Message, values: map[string]string{"id": " "}},
+ {declaration: FolderList, values: map[string]string{"email": " "}},
+ } {
+ rt := mailRuntimeForCoverage(t, tc.declaration, tc.values)
+ if err := tc.declaration.Validate(rt); err == nil {
+ t.Fatal("direct required-text validator accepted whitespace")
+ }
+ }
+ rt := mailRuntimeForCoverage(t, UserSearch, map[string]string{})
+ if err := UserSearch.Validate(rt); err == nil {
+ t.Fatal("direct user-search validator accepted missing identities")
+ }
+}
+
+func TestCrossPlatformCoverageMailTemplateReadbackRejectsBusinessFailure(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: map[string][]string{
+ "get_user_message_template": {`{"id":"template-1"}`},
+ }, errors: map[string]error{}}
+ helpers.InitDepsForTest(t, caller)
+ rt := mailRuntimeForCoverage(t, TemplateCreate, map[string]string{})
+ if _, err := mailReadTemplate(rt, "mail@example.invalid", "template-1"); err == nil {
+ t.Fatal("failed template detail response unexpectedly succeeded")
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("calls=%d want=1", len(caller.calls))
+ }
+}
diff --git a/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go
new file mode 100644
index 00000000..99b7ea65
--- /dev/null
+++ b/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go
@@ -0,0 +1,62 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package mail
+
+import (
+ "context"
+ "io"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+type mailConfirmationCaller struct{ calls int }
+
+func (caller *mailConfirmationCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
+ caller.calls++
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true}`}}}, nil
+}
+
+func (*mailConfirmationCaller) Format() string { return "json" }
+func (*mailConfirmationCaller) DryRun() bool { return false }
+func (*mailConfirmationCaller) Fields() string { return "" }
+func (*mailConfirmationCaller) JQ() string { return "" }
+
+func runUnconfirmedMailWrite(t *testing.T, caller *mailConfirmationCaller, args ...string) error {
+ t.Helper()
+ helpers.InitDeps(caller)
+ 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", "")
+ ctx, _ := output.WithResultStore(context.Background())
+ root.SetContext(ctx)
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ root.AddCommand(shortcut.Commands()...)
+ root.SetArgs(args)
+ return root.Execute()
+}
+
+func TestCrossPlatformCoverageMailConfirmationPrecedesRemoteCall(t *testing.T) {
+ cases := [][]string{
+ {"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "fixture"},
+ {"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-id", "--subject", "fixture"},
+ {"mail", "+template-create", "--email", "sender@example.invalid", "--name", "fixture", "--subject", "fixture", "--body", "fixture"},
+ {"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-id", "--subject", "fixture"},
+ }
+ for _, args := range cases {
+ caller := &mailConfirmationCaller{}
+ if err := runUnconfirmedMailWrite(t, caller, args...); err == nil {
+ t.Fatalf("unconfirmed %v unexpectedly succeeded", args)
+ }
+ if caller.calls != 0 {
+ t.Fatalf("unconfirmed %v reached MCP %d times", args, caller.calls)
+ }
+ }
+}
diff --git a/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go
new file mode 100644
index 00000000..4134fc99
--- /dev/null
+++ b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go
@@ -0,0 +1,310 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package mail
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) {
+ declarations := []*struct {
+ rollout output.RolloutState
+ result bool
+ }{
+ {FolderList.OutputRollout, FolderList.Contract.Result != nil},
+ {UserSearch.OutputRollout, UserSearch.Contract.Result != nil},
+ {Message.OutputRollout, Message.Contract.Result != nil},
+ {Messages.OutputRollout, Messages.Contract.Result != nil},
+ {Thread.OutputRollout, Thread.Contract.Result != nil},
+ }
+ for index, declaration := range declarations {
+ if declaration.rollout != output.RolloutUnifiedActive || !declaration.result {
+ t.Fatalf("mail declaration %d is not unified with Result", index)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageMailUnavailableWritesMatchRuntimeBoundary(t *testing.T) {
+ for _, declaration := range []*shortcut.Shortcut{&DraftCreate, &DraftEdit, &TemplateCreate, &TemplateUpdate} {
+ if declaration.OutputRollout != output.RolloutLegacyOnly {
+ t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout)
+ }
+ if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
+ t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command)
+ }
+ if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" {
+ t.Errorf("%s missing precise unavailable interface", declaration.Command)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) {
+ for _, declaration := range []*shortcut.Shortcut{&ThreadList, &TagList, &TemplateList, &ContactList} {
+ if declaration.OutputRollout != output.RolloutLegacyOnly {
+ t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout)
+ }
+ if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
+ t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command)
+ }
+ if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != mailCompatibilityInterfaceReason {
+ t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageMailMutationConstraintsAreDeclared(t *testing.T) {
+ want := map[string]bool{"to": false, "cc": false, "subject": false, "body": false}
+ for _, constraint := range DraftEdit.Constraints {
+ if constraint.Kind != shortcut.ConstraintAtLeastOne {
+ continue
+ }
+ for _, flag := range constraint.Flags {
+ if _, ok := want[flag]; ok {
+ want[flag] = true
+ }
+ }
+ }
+ for flag, found := range want {
+ if !found {
+ t.Fatalf("draft edit missing at-least-one declaration for %s", flag)
+ }
+ }
+ if len(Messages.Constraints) != 1 || Messages.Constraints[0].Kind != shortcut.ConstraintCustom || Messages.Validate == nil {
+ t.Fatal("messages must declare and execute the 1-100 nonempty ID contract")
+ }
+ userSearchNonempty := false
+ for _, constraint := range UserSearch.Constraints {
+ if constraint.Kind == shortcut.ConstraintCustom && len(constraint.Flags) == 2 && constraint.Flags[0] == "keyword" && constraint.Flags[1] == "employee-no" && constraint.Description != "" {
+ userSearchNonempty = true
+ }
+ }
+ if !userSearchNonempty {
+ t.Fatal("user search must declare its explicit nonempty string contract")
+ }
+}
+
+func TestCrossPlatformCoverageMailPaginationDeclarationsMatchLeaves(t *testing.T) {
+ for _, declaration := range []*shortcut.Shortcut{&UserSearch} {
+ if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" {
+ t.Fatalf("%s missing cursor pagination declaration", declaration.Command)
+ }
+ }
+ for _, declaration := range []*shortcut.Shortcut{&ThreadList, &FolderList, &TagList, &TemplateList, &ContactList, &Messages} {
+ if declaration.Contract.Pagination != nil {
+ t.Fatalf("%s must not declare pagination", declaration.Command)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageMailUnifiedPaginationLivesOnlyInMeta(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ complete bool
+ next string
+ exhausted bool
+ }{
+ {name: "continuing", complete: false, next: "cursor-2", exhausted: false},
+ {name: "terminal", complete: true, next: "", exhausted: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ declaration := UserSearch
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ rt := shortcut.RuntimeContextForTest(cmd, declaration)
+ if err := mailOutputPage(rt, "users", []map[string]any{{"email": "fixture@example.invalid"}}, tc.complete, tc.next); err != nil {
+ t.Fatalf("store pagination: %v", err)
+ }
+ if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 {
+ t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err)
+ }
+ var envelope struct {
+ Data map[string]any `json:"data"`
+ Meta struct {
+ Pagination *struct {
+ EndpointExhausted bool `json:"endpoint_exhausted"`
+ NextToken string `json:"next_token"`
+ } `json:"pagination"`
+ } `json:"meta"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode output: %v", err)
+ }
+ for _, field := range []string{"complete", "hasMore", "nextCursor"} {
+ if _, exists := envelope.Data[field]; exists {
+ t.Fatalf("pagination field %s leaked into data", field)
+ }
+ }
+ if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted != tc.exhausted || envelope.Meta.Pagination.NextToken != tc.next {
+ t.Fatalf("pagination=%+v", envelope.Meta.Pagination)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageMailResultSchemasRequireStableIdentity(t *testing.T) {
+ collections := []struct {
+ declaration *shortcut.Shortcut
+ collection string
+ identity string
+ }{
+ {&FolderList, "folders", "id"},
+ {&UserSearch, "users", "email"},
+ {&Messages, "messages", "id"},
+ }
+ for _, tc := range collections {
+ if len(tc.declaration.Contract.Result.SensitivePaths) == 0 {
+ t.Fatalf("%s missing sensitive_paths", tc.declaration.Command)
+ }
+ var schema map[string]any
+ if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil {
+ t.Fatalf("%s schema: %v", tc.declaration.Command, err)
+ }
+ properties := schema["properties"].(map[string]any)
+ count := properties["count"].(map[string]any)
+ if count["minimum"] != float64(0) {
+ t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, count["minimum"])
+ }
+ items := properties[tc.collection].(map[string]any)["items"].(map[string]any)
+ identity, ok := items["properties"].(map[string]any)[tc.identity].(map[string]any)
+ if !ok || identity["description"] == "" {
+ t.Fatalf("%s missing identity %s", tc.declaration.Command, tc.identity)
+ }
+ }
+ for _, declaration := range []*shortcut.Shortcut{&Message, &Thread} {
+ if len(declaration.Contract.Result.SensitivePaths) == 0 {
+ t.Fatalf("%s missing sensitive_paths", declaration.Command)
+ }
+ var schema map[string]any
+ if err := json.Unmarshal(declaration.Contract.Result.DataSchema, &schema); err != nil {
+ t.Fatalf("%s schema: %v", declaration.Command, err)
+ }
+ value := schema["properties"].(map[string]any)["value"].(map[string]any)
+ required := value["required"].([]any)
+ if len(required) != 1 || required[0] != "id" {
+ t.Fatalf("%s value does not require id", declaration.Command)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageMailWritesRequireConfirmation(t *testing.T) {
+ for _, declaration := range []struct {
+ name string
+ confirmation string
+ }{
+ {DraftCreate.Command, DraftCreate.Safety.Confirmation},
+ {DraftEdit.Command, DraftEdit.Safety.Confirmation},
+ {TemplateCreate.Command, TemplateCreate.Safety.Confirmation},
+ {TemplateUpdate.Command, TemplateUpdate.Safety.Confirmation},
+ } {
+ if declaration.confirmation != "user_required" {
+ t.Errorf("%s confirmation=%q, want user_required", declaration.name, declaration.confirmation)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageMailStrictResponseMatrix(t *testing.T) {
+ validEmpty := map[string]any{"success": "true", "folders": []any{}}
+ items, err := mailRequireCollection(validEmpty, "mail/list_folders", "folders")
+ if err != nil || len(items) != 0 {
+ t.Fatalf("explicit empty collection must succeed: items=%v err=%v", items, err)
+ }
+ for name, fixture := range map[string]map[string]any{
+ "missing success": {"folders": []any{}},
+ "missing collection": {"success": "true"},
+ "wrong collection": {"success": true, "folders": map[string]any{}},
+ "bad item": {"success": "true", "folders": []any{"bad"}},
+ } {
+ if _, err := mailRequireCollection(fixture, "mail/list_folders", "folders"); err == nil {
+ t.Fatalf("%s must fail closed", name)
+ }
+ }
+ if err := mailRequireSuccess(map[string]any{"success": "false"}, "mail/test"); err == nil {
+ t.Fatal("string false must not be success")
+ }
+ if err := mailRequireSuccess(map[string]any{"success": true}, "mail/test"); err != nil {
+ t.Fatalf("boolean true must be accepted: %v", err)
+ }
+}
+
+func TestCrossPlatformCoverageMailPaginationAndIdentity(t *testing.T) {
+ complete, next, err := mailPage(map[string]any{"nextCursor": "$"}, "mail/search", "", "")
+ if err != nil || !complete || next != "" {
+ t.Fatalf("terminal dollar cursor mismatch: complete=%v next=%q err=%v", complete, next, err)
+ }
+ complete, next, err = mailPage(map[string]any{"hasMore": "false", "nextCursor": ""}, "mail/list", "", "")
+ if err != nil || !complete || next != "" {
+ t.Fatalf("string false pagination mismatch: complete=%v next=%q err=%v", complete, next, err)
+ }
+ if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": ""}, "mail/list", "", ""); err == nil {
+ t.Fatal("hasMore without a progressing cursor must fail")
+ }
+ if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": "cursor-1"}, "mail/list", "", "cursor-1"); err == nil {
+ t.Fatal("repeated cursor must fail closed")
+ }
+ if err := mailRequireIdentity(map[string]any{"id": "actual"}, "mail/get", "expected", "id"); err == nil {
+ t.Fatal("identity mismatch must fail")
+ }
+}
+
+func TestCrossPlatformCoverageMailUserProjectionOmitsEmptyOptionalIdentity(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ user map[string]any
+ wantKey string
+ absentKey string
+ }{
+ {name: "user id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "userId", absentKey: "email"},
+ {name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "userId"},
+ {name: "numeric user id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "userId", absentKey: "email"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rows, err := mailProjectCollection(
+ map[string]any{"success": true, "users": []any{tc.user}},
+ "mail/search_mail_users", "users", []string{"id", "email"},
+ map[string][]string{"userId": {"id"}, "email": {"email"}},
+ )
+ if err != nil || len(rows) != 1 {
+ t.Fatalf("rows=%v err=%v", rows, err)
+ }
+ if _, ok := rows[0][tc.wantKey]; !ok {
+ t.Fatalf("projection missing %s: %#v", tc.wantKey, rows[0])
+ }
+ if _, ok := rows[0][tc.absentKey]; ok {
+ t.Fatalf("projection retained empty %s: %#v", tc.absentKey, rows[0])
+ }
+ })
+ }
+ if _, err := mailProjectCollection(
+ map[string]any{"success": true, "users": []any{map[string]any{"id": "", "email": ""}}},
+ "mail/search_mail_users", "users", []string{"id", "email"},
+ map[string][]string{"userId": {"id"}, "email": {"email"}},
+ ); err == nil {
+ t.Fatal("user without a stable identity unexpectedly projected")
+ }
+ for name, user := range map[string]map[string]any{
+ "malformed email with valid id": {"id": "user-1", "email": 7},
+ "malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := mailProjectCollection(
+ map[string]any{"success": true, "users": []any{user}},
+ "mail/search_mail_users", "users", []string{"id", "email"},
+ map[string][]string{"userId": {"id"}, "email": {"email"}},
+ ); err == nil {
+ t.Fatal("malformed optional identity unexpectedly passed projection")
+ }
+ })
+ }
+}
diff --git a/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go
new file mode 100644
index 00000000..722ee756
--- /dev/null
+++ b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go
@@ -0,0 +1,504 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package mail
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "maps"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+type mailWriteCall struct {
+ tool string
+ args map[string]any
+}
+
+type mailWriteContractCaller struct {
+ responses map[string][]string
+ errors map[string]error
+ calls []mailWriteCall
+}
+
+func (caller *mailWriteContractCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
+ caller.calls = append(caller.calls, mailWriteCall{tool: tool, args: maps.Clone(args)})
+ if err := caller.errors[tool]; err != nil {
+ return nil, err
+ }
+ queue := caller.responses[tool]
+ if len(queue) == 0 {
+ return nil, fmt.Errorf("unexpected Mail tool call: %s", tool)
+ }
+ text := queue[0]
+ caller.responses[tool] = queue[1:]
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
+}
+
+func (*mailWriteContractCaller) Format() string { return "json" }
+func (*mailWriteContractCaller) DryRun() bool { return false }
+func (*mailWriteContractCaller) Fields() string { return "" }
+func (*mailWriteContractCaller) JQ() string { return "" }
+
+func runConfirmedMailWriteContract(t *testing.T, caller *mailWriteContractCaller, args ...string) error {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ 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", "")
+ ctx, _ := output.WithResultStore(context.Background())
+ root.SetContext(ctx)
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ root.AddCommand(shortcut.Commands()...)
+ root.SetArgs(append(args, "--yes", "--format", "json"))
+ return root.Execute()
+}
+
+func mailDraftReadback(id, subject, body, sender, to, cc string) string {
+ return fmt.Sprintf(`{"success":true,"message":{"id":%q,"subject":%q,"markdownBody":%q,"from":{"email":%q},"toRecipients":[{"email":%q}],"ccRecipients":[{"email":%q}]}}`, id, subject, body, sender, to, cc)
+}
+
+func TestCrossPlatformCoverageMailWriteHappyPathsUseReceiptThenExactReadback(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ responses map[string][]string
+ wantTools []string
+ }{
+ {
+ name: "draft create",
+ args: []string{"mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"},
+ responses: map[string][]string{
+ "create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`},
+ "get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")},
+ },
+ wantTools: []string{"create_draft", "get_email_by_message_id"},
+ },
+ {
+ name: "draft edit",
+ args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"},
+ responses: map[string][]string{
+ "update_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`},
+ "get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")},
+ },
+ wantTools: []string{"update_draft", "get_email_by_message_id"},
+ },
+ {
+ name: "template create core fields",
+ args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"},
+ responses: map[string][]string{
+ "create_user_message_template": {`{"success":true,"id":"template-1"}`},
+ "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`},
+ },
+ wantTools: []string{"create_user_message_template", "get_user_message_template"},
+ },
+ {
+ name: "template update success plus requested id readback",
+ args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body"},
+ responses: map[string][]string{
+ "update_user_message_template": {`{"success":true}`},
+ "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`},
+ },
+ wantTools: []string{"update_user_message_template", "get_user_message_template"},
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}}
+ if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil {
+ t.Fatalf("write execution failed: %v", err)
+ }
+ if len(caller.calls) != len(tc.wantTools) {
+ t.Fatalf("calls=%d want=%d", len(caller.calls), len(tc.wantTools))
+ }
+ for index, tool := range tc.wantTools {
+ if caller.calls[index].tool != tool {
+ t.Fatalf("call[%d]=%s want=%s", index, caller.calls[index].tool, tool)
+ }
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageMailDraftWritesFailClosedOnReceiptAndReadback(t *testing.T) {
+ tests := []struct {
+ name string
+ writeBody string
+ readBody string
+ writeErr error
+ wantCalls int
+ }{
+ {name: "empty write body", writeBody: "", wantCalls: 1},
+ {name: "missing receipt", writeBody: `{"success":true}`, wantCalls: 1},
+ {name: "malformed receipt", writeBody: `{"success":true,"result":{"message":"bad"}}`, wantCalls: 1},
+ {name: "missing receipt id", writeBody: `{"success":true,"result":{"message":{"subject":"x"}}}`, wantCalls: 1},
+ {name: "missing readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true}`, wantCalls: 2},
+ {name: "malformed readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true,"message":"bad"}`, wantCalls: 2},
+ {name: "wrong readback id", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
+ {name: "request field mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
+ {name: "recipient mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2},
+ {name: "write failure", writeErr: fmt.Errorf("write failed"), wantCalls: 1},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{
+ responses: map[string][]string{"create_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}},
+ errors: map[string]error{"create_draft": tc.writeErr},
+ }
+ err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body")
+ if err == nil {
+ t.Fatal("invalid receipt/readback unexpectedly succeeded")
+ }
+ if len(caller.calls) != tc.wantCalls {
+ t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageMailEditAndTemplateNegativeCallHistory(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ responses map[string][]string
+ wantCalls int
+ }{
+ {name: "draft edit no change", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1"}, responses: map[string][]string{}, wantCalls: 0},
+ {name: "draft edit wrong receipt id", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject"}, responses: map[string][]string{"update_draft": {`{"success":true,"result":{"message":{"id":"other"}}}`}}, wantCalls: 1},
+ {name: "template update no change", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1"}, responses: map[string][]string{}, wantCalls: 0},
+ {name: "template update missing success", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{}`}}, wantCalls: 1},
+ {name: "template update wrong readback id", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2},
+ {name: "template update field mismatch", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`}}, wantCalls: 2},
+ {name: "template create missing id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true}`}}, wantCalls: 1},
+ {name: "template create wrong readback id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true,"id":"template-1"}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2},
+ {name: "template create unverifiable from", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--from", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{}, wantCalls: 0},
+ {name: "template create unverifiable draft", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--draft"}, responses: map[string][]string{}, wantCalls: 0},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}}
+ if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil {
+ t.Fatal("invalid write unexpectedly succeeded")
+ }
+ if len(caller.calls) != tc.wantCalls {
+ t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageMailCursorPassthroughAndStall(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ tool string
+ response string
+ }{
+ {name: "thread list", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}],"hasMore":true,"nextCursor":"cursor-2"}}`},
+ {name: "user search", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
+ {name: "template list", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}],"hasMore":true,"nextCursor":"cursor-2"}`},
+ {name: "contact list", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}],"hasMore":true,"nextCursor":"cursor-2"}`},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}}
+ if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil {
+ t.Fatalf("execution failed: %v", err)
+ }
+ if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" {
+ t.Fatalf("cursor was not passed through: %#v", caller.calls)
+ }
+ })
+ }
+ caller := &mailWriteContractCaller{
+ responses: map[string][]string{"search_mail_users": {`{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}},
+ errors: map[string]error{},
+ }
+ if err := runConfirmedMailWriteContract(t, caller, "mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"); err == nil {
+ t.Fatal("repeated cursor unexpectedly succeeded")
+ }
+}
+
+func TestCrossPlatformCoverageMailValidationRejectsBeforeRemoteCall(t *testing.T) {
+ tooManyIDs := make([]string, 101)
+ for index := range tooManyIDs {
+ tooManyIDs[index] = fmt.Sprintf("message-%d", index)
+ }
+ for _, tc := range []struct {
+ name string
+ args []string
+ }{
+ {name: "thread zero limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "0"}},
+ {name: "thread high limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "101"}},
+ {name: "thread bad start", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "bad"}},
+ {name: "thread non utc", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00+08:00"}},
+ {name: "thread reversed", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00Z", "--end", "2026-08-18T09:00:00Z"}},
+ {name: "user zero limit", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "0"}},
+ {name: "template nonnumeric limit", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "bad"}},
+ {name: "contact high limit", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "101"}},
+ {name: "messages empty item", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1", "--ids", " "}},
+ {name: "messages too many", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", strings.Join(tooManyIDs, ",")}},
+ {name: "user empty keyword", args: []string{"mail", "+user-search", "--keyword", " "}},
+ {name: "user empty employee number", args: []string{"mail", "+user-search", "--keyword", "fixture", "--employee-no", " "}},
+ {name: "message empty id", args: []string{"mail", "+message", "--id", " "}},
+ {name: "thread empty id", args: []string{"mail", "+thread", "--id", " "}},
+ {name: "folder empty email", args: []string{"mail", "+folder-list", "--email", " "}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
+ if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil {
+ t.Fatal("invalid input unexpectedly succeeded")
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("invalid input reached remote calls: %d", len(caller.calls))
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageMailLegacyStringLimitsStayCompatibleAndStrict(t *testing.T) {
+ if err := mailValidatePageSize(mailRuntimeForCoverage(t, ThreadList, nil), "limit", false); err != nil {
+ t.Fatalf("omitted optional integer limit should remain valid: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ declaration *shortcut.Shortcut
+ args []string
+ tool string
+ response string
+ wantSize string
+ wantPresent bool
+ }{
+ {name: "user limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "1"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`, wantSize: "1", wantPresent: true},
+ {name: "user omitted limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`},
+ {name: "template limit", declaration: &TemplateList, args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "100"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[],"hasMore":false,"nextCursor":""}`, wantSize: "100", wantPresent: true},
+ {name: "contact limit", declaration: &ContactList, args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[],"hasMore":false,"nextCursor":""}`, wantSize: "20", wantPresent: true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ found := false
+ for _, flag := range tc.declaration.Flags {
+ if flag.Name != "limit" {
+ continue
+ }
+ found = true
+ if flag.Type != shortcut.FlagString || flag.Default != "" {
+ t.Fatalf("declaration --limit type/default = %q/%q, want string/empty", flag.Type, flag.Default)
+ }
+ }
+ if !found {
+ t.Fatal("declaration missing --limit")
+ }
+ cmd := corecmd.New(shortcut.FromShortcut(*tc.declaration))
+ if flag := cmd.Flags().Lookup("limit"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" {
+ t.Fatalf("Cobra --limit = %#v, want historical string with empty default", flag)
+ }
+
+ caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}}
+ if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil {
+ t.Fatalf("compatible execution failed: %v", err)
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("calls=%d, want 1", len(caller.calls))
+ }
+ gotSize, present := caller.calls[0].args["size"]
+ if present != tc.wantPresent || (present && gotSize != tc.wantSize) {
+ t.Fatalf("size=%#v present=%v, want %q present=%v", gotSize, present, tc.wantSize, tc.wantPresent)
+ }
+ })
+ }
+
+ for _, tc := range []struct {
+ name string
+ declaration shortcut.Shortcut
+ values map[string]string
+ }{
+ {name: "user execute guard", declaration: UserSearch, values: map[string]string{"keyword": "fixture", "limit": "bad"}},
+ {name: "template execute guard", declaration: TemplateList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}},
+ {name: "contact execute guard", declaration: ContactList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
+ helpers.InitDepsForTest(t, caller)
+ rt := mailRuntimeForCoverage(t, tc.declaration, tc.values)
+ if err := tc.declaration.Execute(rt); err == nil {
+ t.Fatal("direct Execute accepted malformed string limit")
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("direct Execute reached %d remote calls", len(caller.calls))
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageMailLegacyStringLimitsRejectEveryInvalidClassWithZeroCalls(t *testing.T) {
+ for _, leaf := range []struct {
+ name string
+ args []string
+ }{
+ {name: "user", args: []string{"mail", "+user-search", "--keyword", "fixture"}},
+ {name: "template", args: []string{"mail", "+template-list", "--email", "mail@example.invalid"}},
+ {name: "contact", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid"}},
+ } {
+ for _, invalid := range []struct {
+ name string
+ value string
+ }{
+ {name: "blank", value: " "},
+ {name: "nonnumeric", value: "bad"},
+ {name: "zero", value: "0"},
+ {name: "negative", value: "-1"},
+ {name: "too large", value: "101"},
+ } {
+ t.Run(leaf.name+"/"+invalid.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}}
+ args := append(append([]string{}, leaf.args...), "--limit", invalid.value)
+ if err := runConfirmedMailWriteContract(t, caller, args...); err == nil {
+ t.Fatal("invalid legacy string limit unexpectedly succeeded")
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("invalid legacy string limit reached %d remote calls", len(caller.calls))
+ }
+ })
+ }
+ }
+}
+
+func TestCrossPlatformCoverageMailDraftEditOwnNegativeBranches(t *testing.T) {
+ goodReceipt := `{"success":true,"result":{"message":{"id":"draft-1"}}}`
+ goodReadback := mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")
+ for _, tc := range []struct {
+ name string
+ writeBody string
+ readBody string
+ errors map[string]error
+ wantCalls int
+ }{
+ {name: "receipt remote failure", writeBody: `{"success":false}`, wantCalls: 1},
+ {name: "receipt missing", writeBody: `{"success":true}`, wantCalls: 1},
+ {name: "receipt malformed", writeBody: `{"success":true,"result":{"message":[]}}`, wantCalls: 1},
+ {name: "receipt wrong id", writeBody: `{"success":true,"result":{"message":{"id":"other"}}}`, wantCalls: 1},
+ {name: "readback transport", writeBody: goodReceipt, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("read failed")}, wantCalls: 2},
+ {name: "readback missing", writeBody: goodReceipt, readBody: `{"success":true}`, wantCalls: 2},
+ {name: "readback malformed", writeBody: goodReceipt, readBody: `{"success":true,"message":[]}`, wantCalls: 2},
+ {name: "readback wrong id", writeBody: goodReceipt, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
+ {name: "subject mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
+ {name: "body mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
+ {name: "from mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2},
+ {name: "to mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2},
+ {name: "cc mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid"), wantCalls: 2},
+ {name: "happy control", writeBody: goodReceipt, readBody: goodReadback, wantCalls: 2},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{
+ responses: map[string][]string{"update_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}},
+ errors: tc.errors,
+ }
+ if caller.errors == nil {
+ caller.errors = map[string]error{}
+ }
+ err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body")
+ if tc.name == "happy control" {
+ if err != nil {
+ t.Fatalf("happy control: %v", err)
+ }
+ } else if err == nil {
+ t.Fatal("negative branch unexpectedly succeeded")
+ }
+ if len(caller.calls) != tc.wantCalls {
+ t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageMailTemplateCreateOwnNegativeBranches(t *testing.T) {
+ goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`
+ for _, tc := range []struct {
+ name string
+ createBody string
+ readBody string
+ errors map[string]error
+ wantCalls int
+ }{
+ {name: "missing success", createBody: `{"id":"template-1"}`, wantCalls: 1},
+ {name: "remote failure", createBody: `{"success":false,"id":"template-1"}`, wantCalls: 1},
+ {name: "missing id", createBody: `{"success":true}`, wantCalls: 1},
+ {name: "readback transport", createBody: `{"success":true,"id":"template-1"}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2},
+ {name: "readback missing shape", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true}`, wantCalls: 2},
+ {name: "readback wrong id", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
+ {name: "name mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
+ {name: "subject mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2},
+ {name: "body mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2},
+ {name: "happy control", createBody: `{"success":true,"id":"template-1"}`, readBody: goodReadback, wantCalls: 2},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: map[string][]string{"create_user_message_template": {tc.createBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors}
+ if caller.errors == nil {
+ caller.errors = map[string]error{}
+ }
+ err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body")
+ if tc.name == "happy control" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else if err == nil {
+ t.Fatal("negative branch unexpectedly succeeded")
+ }
+ if len(caller.calls) != tc.wantCalls {
+ t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageMailTemplateUpdateOwnNegativeBranches(t *testing.T) {
+ goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`
+ for _, tc := range []struct {
+ name string
+ updateBody string
+ readBody string
+ errors map[string]error
+ wantCalls int
+ }{
+ {name: "missing success", updateBody: `{}`, wantCalls: 1},
+ {name: "remote failure", updateBody: `{"success":false}`, wantCalls: 1},
+ {name: "readback transport", updateBody: `{"success":true}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2},
+ {name: "readback missing shape", updateBody: `{"success":true}`, readBody: `{"success":true}`, wantCalls: 2},
+ {name: "readback wrong id", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
+ {name: "name mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2},
+ {name: "subject mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2},
+ {name: "body mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2},
+ {name: "happy control", updateBody: `{"success":true}`, readBody: goodReadback, wantCalls: 2},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &mailWriteContractCaller{responses: map[string][]string{"update_user_message_template": {tc.updateBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors}
+ if caller.errors == nil {
+ caller.errors = map[string]error{}
+ }
+ err := runConfirmedMailWriteContract(t, caller, "mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body")
+ if tc.name == "happy control" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else if err == nil {
+ t.Fatal("negative branch unexpectedly succeeded")
+ }
+ if len(caller.calls) != tc.wantCalls {
+ t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
+ }
+ })
+ }
+}
diff --git a/internal/shortcut/mail/reads.go b/internal/shortcut/mail/reads.go
new file mode 100644
index 00000000..21feb47c
--- /dev/null
+++ b/internal/shortcut/mail/reads.go
@@ -0,0 +1,222 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package mail
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func mailResolveMailbox(rt *shortcut.RuntimeContext) (string, error) {
+ if email := strings.TrimSpace(rt.Str("email")); email != "" {
+ return email, nil
+ }
+ data, err := rt.CallMCPData("mail", "list_user_mailboxes", nil)
+ if err != nil {
+ return "", err
+ }
+ mailboxes, err := mailMailboxAddresses(data)
+ if err != nil {
+ return "", err
+ }
+ if len(mailboxes) == 0 {
+ return "", apperrors.NewValidation("当前身份没有可用邮箱,请用 --email 指定")
+ }
+ return mailboxes[0], nil
+}
+
+// mailMailboxAddresses accepts the reviewed mailbox collection paths and item
+// shapes used by the Mail gateway. The complete selected array is validated
+// before the first address is returned so a malformed later row cannot be
+// hidden behind an otherwise usable first mailbox.
+func mailMailboxAddresses(data map[string]any) ([]string, error) {
+ const operation = "mail/list_user_mailboxes"
+ if err := mailRequireSuccess(data, operation); err != nil {
+ return nil, err
+ }
+
+ paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"}
+ var value any
+ selectedPath := ""
+ for _, path := range paths {
+ candidate, present := mailLookup(data, path)
+ if !present {
+ continue
+ }
+ if selectedPath != "" {
+ return nil, mailResponseError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path))
+ }
+ value = candidate
+ selectedPath = path
+ }
+ if selectedPath == "" {
+ return nil, mailResponseError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果")
+ }
+
+ raw, ok := value.([]any)
+ if !ok {
+ return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value))
+ }
+ addresses := make([]string, 0, len(raw))
+ for index, item := range raw {
+ var address string
+ switch typed := item.(type) {
+ case string:
+ address = strings.TrimSpace(typed)
+ case map[string]any:
+ rawEmail, present := typed["email"]
+ if present && rawEmail != nil {
+ var valid bool
+ address, valid = rawEmail.(string)
+ if !valid {
+ return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d].email 必须是字符串", selectedPath, index))
+ }
+ address = strings.TrimSpace(address)
+ }
+ default:
+ return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index))
+ }
+ if address == "" {
+ return nil, mailResponseError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index))
+ }
+ addresses = append(addresses, address)
+ }
+ return addresses, nil
+}
+
+func mailReadMessage(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) {
+ data, err := rt.CallMCPData("mail", "get_email_by_message_id", map[string]any{"email": email, "messageId": id})
+ if err != nil {
+ return nil, err
+ }
+ message, err := mailRequireObject(data, "mail/get_email_by_message_id", "message")
+ if err != nil {
+ return nil, err
+ }
+ if err := mailRequireIdentity(message, "mail/get_email_by_message_id", id, "id"); err != nil {
+ return nil, err
+ }
+ return message, nil
+}
+
+var Message = shortcut.Shortcut{
+ OutputRollout: output.RolloutUnifiedActive,
+ Service: "mail", Command: "+message", Product: "mail",
+ Description: "读取一封邮件的完整正文与附件元数据", Intent: "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。",
+ Risk: shortcut.RiskRead, Safety: mailReadSafety(),
+ Contract: mailReadContract("+message", "读取一封邮件的完整正文与附件元数据", "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", mailObjectResult("身份匹配的单封邮件详情"), []contract.ParamDecl{{Name: "id", Property: "messageId"}}, `dws mail +message --id --format json`),
+ Flags: []shortcut.Flag{
+ {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件 messageId,不能为空"},
+ {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ return mailValidateRequiredText(rt, "id")
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ email, err := mailResolveMailbox(rt)
+ if err != nil {
+ return err
+ }
+ message, err := mailReadMessage(rt, email, rt.Str("id"))
+ if err != nil {
+ return err
+ }
+ return rt.Output(map[string]any{"value": message})
+ },
+}
+
+var Messages = shortcut.Shortcut{
+ OutputRollout: output.RolloutUnifiedActive,
+ Service: "mail", Command: "+messages", Product: "mail",
+ Description: "按请求顺序读取多封邮件并逐封验证身份", Intent: "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。",
+ Risk: shortcut.RiskRead, Safety: mailReadSafety(),
+ Contract: mailReadContract("+messages", "按请求顺序读取多封邮件并逐封验证身份", "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", mailCollectionResult("messages", "身份匹配且保持请求顺序的邮件详情"), []contract.ParamDecl{{Name: "ids", Property: "messageIds"}}, `dws mail +messages --ids , --format json`),
+ Flags: []shortcut.Flag{
+ {Name: "ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "邮件 messageId 列表,1-100 个且每项不能为空"},
+ {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
+ },
+ Constraints: []shortcut.Constraint{{
+ Kind: shortcut.ConstraintCustom, Flags: []string{"ids"},
+ Description: "1-100 个且不能为空",
+ }},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ return mailValidateMessageIDs(rt.StrSlice("ids"))
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ ids := rt.StrSlice("ids")
+ email, err := mailResolveMailbox(rt)
+ if err != nil {
+ return err
+ }
+ messages := make([]map[string]any, 0, len(ids))
+ for index, id := range ids {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index))
+ }
+ message, err := mailReadMessage(rt, email, id)
+ if err != nil {
+ return err
+ }
+ messages = append(messages, message)
+ }
+ return rt.Output(mailBusinessCollectionPayload("messages", messages))
+ },
+}
+
+func mailValidateMessageIDs(ids []string) error {
+ if len(ids) == 0 || len(ids) > 100 {
+ return apperrors.NewValidation("--ids 需要 1 到 100 个邮件 ID")
+ }
+ for index, id := range ids {
+ if strings.TrimSpace(id) == "" {
+ return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index))
+ }
+ }
+ return nil
+}
+
+var Thread = shortcut.Shortcut{
+ OutputRollout: output.RolloutUnifiedActive,
+ Service: "mail", Command: "+thread", Product: "mail",
+ Description: "读取完整邮件会话并精确验证 conversationId", Intent: "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。",
+ Risk: shortcut.RiskRead, Safety: mailReadSafety(),
+ Contract: mailReadContract("+thread", "读取完整邮件会话并精确验证 conversationId", "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", mailObjectResult("身份匹配的邮件会话详情"), []contract.ParamDecl{{Name: "id", Property: "conversationId"}}, `dws mail +thread --id --format json`),
+ Flags: []shortcut.Flag{
+ {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件会话 conversationId,不能为空"},
+ {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ return mailValidateRequiredText(rt, "id")
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ email, err := mailResolveMailbox(rt)
+ if err != nil {
+ return err
+ }
+ data, err := rt.CallMCPData("mail", "get_thread", map[string]any{"email": email, "conversationId": rt.Str("id")})
+ if err != nil {
+ return err
+ }
+ conversation, err := mailRequireObject(data, "mail/get_thread", "conversation")
+ if err != nil {
+ return err
+ }
+ if err := mailRequireIdentity(conversation, "mail/get_thread", rt.Str("id"), "id"); err != nil {
+ return err
+ }
+ return rt.Output(map[string]any{"value": conversation})
+ },
+}
+
+func init() {
+ shortcut.Register(Message, Messages, Thread)
+}
diff --git a/internal/shortcut/mail/thread_list_projection_test.go b/internal/shortcut/mail/thread_list_projection_test.go
index c2f178d5..d34f639e 100644
--- a/internal/shortcut/mail/thread_list_projection_test.go
+++ b/internal/shortcut/mail/thread_list_projection_test.go
@@ -21,18 +21,23 @@ import (
// TestThreadListProjectConversationsShape guards the exact
// list_mailbox_threads response contract, including lastModifiedDateTime.
func TestThreadListProjectConversationsShape(t *testing.T) {
- const raw = `{"result":{"conversations":[{
+ const raw = `{"success":true,"result":{"conversations":[{
"id":"thread-1",
"subject":"Projection contract",
"lastModifiedDateTime":"2026-07-26T10:00:00Z",
"isRead":false
- }]}}`
+ }],"hasMore":false,"nextCursor":""}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
- got := threadListProject(data)
+ got, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{
+ "conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"},
+ })
+ if err != nil {
+ t.Fatalf("strict projection: %v", err)
+ }
if len(got) != 1 {
t.Fatalf("lower/upper mismatch: result.conversations has 1 entry, projection returned %d (%v)", len(got), got)
}
diff --git a/internal/shortcut/mail/writes.go b/internal/shortcut/mail/writes.go
new file mode 100644
index 00000000..63989e89
--- /dev/null
+++ b/internal/shortcut/mail/writes.go
@@ -0,0 +1,397 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package mail
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func mailRecipients(values []string) []string {
+ out := make([]string, 0, len(values))
+ for _, value := range values {
+ for _, part := range strings.Split(value, ",") {
+ if trimmed := strings.TrimSpace(part); trimmed != "" {
+ out = append(out, trimmed)
+ }
+ }
+ }
+ return out
+}
+
+func mailNormalizedAddresses(values []string) []string {
+ seen := make(map[string]struct{}, len(values))
+ for _, value := range values {
+ normalized := strings.ToLower(strings.TrimSpace(value))
+ if normalized != "" {
+ seen[normalized] = struct{}{}
+ }
+ }
+ out := make([]string, 0, len(seen))
+ for value := range seen {
+ out = append(out, value)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func mailReadAddresses(message map[string]any, operation, field string) ([]string, error) {
+ raw, present := message[field]
+ if !present {
+ return nil, mailResponseError(operation, "missing_verification_field", fmt.Sprintf("读回邮件缺少 %s", field))
+ }
+ items, ok := raw.([]any)
+ if !ok {
+ return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s 应为数组", field))
+ }
+ addresses := make([]string, 0, len(items))
+ for index, item := range items {
+ var address string
+ switch typed := item.(type) {
+ case string:
+ address = typed
+ case map[string]any:
+ address = mailFirstString(typed, "email", "address")
+ default:
+ return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 类型无效", field, index))
+ }
+ if strings.TrimSpace(address) == "" {
+ return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 缺少邮箱地址", field, index))
+ }
+ addresses = append(addresses, address)
+ }
+ return mailNormalizedAddresses(addresses), nil
+}
+
+func mailVerifyAddresses(message map[string]any, operation, field string, requested []string) error {
+ actual, err := mailReadAddresses(message, operation, field)
+ if err != nil {
+ return err
+ }
+ expected := mailNormalizedAddresses(requested)
+ if len(actual) != len(expected) {
+ return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field))
+ }
+ for index := range expected {
+ if actual[index] != expected[index] {
+ return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field))
+ }
+ }
+ return nil
+}
+
+func mailVerifySender(message map[string]any, operation, requested string) error {
+ raw, present := message["from"]
+ if !present {
+ return mailResponseError(operation, "missing_verification_field", "读回邮件缺少 from")
+ }
+ var actual string
+ switch typed := raw.(type) {
+ case string:
+ actual = typed
+ case map[string]any:
+ actual = mailFirstString(typed, "email", "address")
+ default:
+ return mailResponseError(operation, "malformed_verification_field", "读回邮件 from 类型无效")
+ }
+ if !strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(requested)) {
+ return mailResponseError(operation, "verification_mismatch", "读回邮件发件地址与请求不一致")
+ }
+ return nil
+}
+
+func mailDraftMessage(data map[string]any, operation string) (map[string]any, error) {
+ if err := mailRequireSuccess(data, operation); err != nil {
+ return nil, err
+ }
+ value, present := mailLookup(data, "result.message")
+ if !present {
+ return nil, mailResponseError(operation, "missing_write_receipt", "写响应缺少 result.message")
+ }
+ message, ok := value.(map[string]any)
+ if !ok || len(message) == 0 {
+ return nil, mailResponseError(operation, "malformed_write_receipt", fmt.Sprintf("result.message 应为非空对象,实际为 %T", value))
+ }
+ if err := mailRequireIdentity(message, operation, "", "id"); err != nil {
+ return nil, err
+ }
+ return message, nil
+}
+
+func mailReadTemplate(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) {
+ data, err := rt.CallMCPData("mail", "get_user_message_template", map[string]any{"email": email, "id": id})
+ if err != nil {
+ return nil, err
+ }
+ if err := mailRequireSuccess(data, "mail/get_user_message_template"); err != nil {
+ return nil, err
+ }
+ template := make(map[string]any, len(data))
+ for key, value := range data {
+ switch key {
+ case "success", "errorCode", "errorMsg", "arguments":
+ continue
+ default:
+ template[key] = value
+ }
+ }
+ if len(template) == 0 {
+ return nil, mailResponseError("mail/get_user_message_template", "missing_result", "模板详情为空")
+ }
+ if err := mailRequireIdentity(template, "mail/get_user_message_template", id, "id"); err != nil {
+ return nil, err
+ }
+ return template, nil
+}
+
+func mailTemplateSubject(template map[string]any) string {
+ message, _ := template["message"].(map[string]any)
+ return mailFirstString(message, "subject")
+}
+
+func mailTemplateBody(template map[string]any) string {
+ message, _ := template["message"].(map[string]any)
+ return mailFirstString(message, "markdownBody", "body")
+}
+
+var DraftCreate = shortcut.Shortcut{
+ OutputRollout: output.RolloutUnifiedActive,
+ Service: "mail", Command: "+draft-create", Product: "mail",
+ Description: "创建邮件草稿并按 messageId 读回验证", Intent: "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。",
+ Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"),
+ Contract: mailWriteContract("+draft-create", "创建邮件草稿并按 messageId 读回验证", "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", []contract.ParamDecl{{Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-create --from user@company.com --subject "草稿" --body "正文" --format json`),
+ Flags: []shortcut.Flag{
+ {Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"},
+ {Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件邮箱,可多次指定或逗号分隔"},
+ {Name: "cc", Type: shortcut.FlagStringSlice, Desc: "抄送邮箱,可多次指定或逗号分隔"},
+ {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "草稿主题"},
+ {Name: "body", Type: shortcut.FlagString, Desc: "草稿正文"},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}},
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ params := map[string]any{"from": rt.Str("from"), "subject": rt.Str("subject")}
+ if recipients := mailRecipients(rt.StrSlice("to")); len(recipients) > 0 {
+ params["toRecipients"] = recipients
+ }
+ if recipients := mailRecipients(rt.StrSlice("cc")); len(recipients) > 0 {
+ params["ccRecipients"] = recipients
+ }
+ if rt.Changed("body") {
+ params["body"] = rt.Str("body")
+ }
+ if rt.DryRun() {
+ return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_draft"}})
+ }
+ written, err := rt.CallMCPWriteDataStrict("mail", "create_draft", params)
+ if err != nil {
+ return err
+ }
+ receipt, err := mailDraftMessage(written, "mail/create_draft")
+ if err != nil {
+ return err
+ }
+ id := mailFirstString(receipt, "id")
+ verified, err := mailReadMessage(rt, rt.Str("from"), id)
+ if err != nil {
+ return err
+ }
+ if mailFirstString(verified, "subject") != rt.Str("subject") {
+ return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回主题与请求不一致")
+ }
+ if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") {
+ return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回正文与请求不一致")
+ }
+ if err := mailVerifySender(verified, "mail/create_draft", rt.Str("from")); err != nil {
+ return err
+ }
+ if rt.Changed("to") {
+ if err := mailVerifyAddresses(verified, "mail/create_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil {
+ return err
+ }
+ }
+ if rt.Changed("cc") {
+ if err := mailVerifyAddresses(verified, "mail/create_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil {
+ return err
+ }
+ }
+ return rt.Output(map[string]any{"value": verified})
+ },
+}
+
+var DraftEdit = shortcut.Shortcut{
+ OutputRollout: output.RolloutUnifiedActive,
+ Service: "mail", Command: "+draft-edit", Product: "mail",
+ Description: "更新已有草稿并精确读回验证", Intent: "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。",
+ Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"),
+ Contract: mailWriteContract("+draft-edit", "更新已有草稿并精确读回验证", "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", []contract.ParamDecl{{Name: "id", Property: "id"}, {Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-edit --from user@company.com --id --subject "新主题" --format json`),
+ Flags: []shortcut.Flag{
+ {Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"},
+ {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "草稿 messageId"},
+ {Name: "to", Type: shortcut.FlagStringSlice, Desc: "新收件邮箱"},
+ {Name: "cc", Type: shortcut.FlagStringSlice, Desc: "新抄送邮箱"},
+ {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"},
+ {Name: "body", Type: shortcut.FlagString, Desc: "新正文"},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}},
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ if !rt.Changed("to") && !rt.Changed("cc") && !rt.Changed("subject") && !rt.Changed("body") {
+ return apperrors.NewValidation("至少指定 --to、--cc、--subject、--body 之一")
+ }
+ params := map[string]any{"from": rt.Str("from"), "id": rt.Str("id")}
+ if rt.Changed("to") {
+ params["toRecipients"] = mailRecipients(rt.StrSlice("to"))
+ }
+ if rt.Changed("cc") {
+ params["ccRecipients"] = mailRecipients(rt.StrSlice("cc"))
+ }
+ if rt.Changed("subject") {
+ params["subject"] = rt.Str("subject")
+ }
+ if rt.Changed("body") {
+ params["body"] = rt.Str("body")
+ }
+ if rt.DryRun() {
+ return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_draft"}})
+ }
+ written, err := rt.CallMCPWriteDataStrict("mail", "update_draft", params)
+ if err != nil {
+ return err
+ }
+ receipt, err := mailDraftMessage(written, "mail/update_draft")
+ if err != nil {
+ return err
+ }
+ if mailFirstString(receipt, "id") != rt.Str("id") {
+ return mailResponseError("mail/update_draft", "identity_mismatch", "更新回执的 messageId 与请求不一致")
+ }
+ verified, err := mailReadMessage(rt, rt.Str("from"), rt.Str("id"))
+ if err != nil {
+ return err
+ }
+ if rt.Changed("subject") && mailFirstString(verified, "subject") != rt.Str("subject") {
+ return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回主题与请求不一致")
+ }
+ if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") {
+ return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回正文与请求不一致")
+ }
+ if err := mailVerifySender(verified, "mail/update_draft", rt.Str("from")); err != nil {
+ return err
+ }
+ if rt.Changed("to") {
+ if err := mailVerifyAddresses(verified, "mail/update_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil {
+ return err
+ }
+ }
+ if rt.Changed("cc") {
+ if err := mailVerifyAddresses(verified, "mail/update_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil {
+ return err
+ }
+ }
+ return rt.Output(map[string]any{"value": verified})
+ },
+}
+
+var TemplateCreate = shortcut.Shortcut{
+ OutputRollout: output.RolloutUnifiedActive,
+ Service: "mail", Command: "+template-create", Product: "mail",
+ Description: "创建个人邮件模板并按模板 ID 读回", Intent: "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。",
+ Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"),
+ Contract: mailWriteContract("+template-create", "创建个人邮件模板并按模板 ID 读回", "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "body", Property: "body"}}, `dws mail +template-create --email user@company.com --name "模板" --subject "主题" --body "正文" --format json`),
+ Flags: []shortcut.Flag{
+ {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"},
+ {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "模板名称"},
+ {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "模板主题"},
+ {Name: "body", Type: shortcut.FlagString, Required: true, Desc: "模板正文"},
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ params := map[string]any{"email": rt.Str("email"), "name": rt.Str("name"), "subject": rt.Str("subject"), "body": rt.Str("body")}
+ if rt.DryRun() {
+ return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_user_message_template"}})
+ }
+ written, err := rt.CallMCPWriteDataStrict("mail", "create_user_message_template", params)
+ if err != nil {
+ return err
+ }
+ if err := mailRequireSuccess(written, "mail/create_user_message_template"); err != nil {
+ return err
+ }
+ id := mailFirstString(written, "id")
+ if id == "" {
+ return mailResponseError("mail/create_user_message_template", "missing_stable_id", "创建模板响应缺少稳定 ID")
+ }
+ verified, err := mailReadTemplate(rt, rt.Str("email"), id)
+ if err != nil {
+ return err
+ }
+ if mailFirstString(verified, "name") != rt.Str("name") || mailTemplateSubject(verified) != rt.Str("subject") || mailTemplateBody(verified) != rt.Str("body") {
+ return mailResponseError("mail/create_user_message_template", "verification_mismatch", "模板读回内容与请求不一致")
+ }
+ return rt.Output(map[string]any{"value": verified})
+ },
+}
+
+var TemplateUpdate = shortcut.Shortcut{
+ OutputRollout: output.RolloutUnifiedActive,
+ Service: "mail", Command: "+template-update", Product: "mail",
+ Description: "更新草稿模板并按原 ID 读回验证", Intent: "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。",
+ Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"),
+ Contract: mailWriteContract("+template-update", "更新草稿模板并按原 ID 读回验证", "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "id", Property: "id"}, {Name: "body", Property: "body"}}, `dws mail +template-update --email user@company.com --id --subject "新主题" --format json`),
+ Flags: []shortcut.Flag{
+ {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"},
+ {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "模板 ID;仅草稿模板可更新"},
+ {Name: "name", Type: shortcut.FlagString, Desc: "新模板名称"},
+ {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"},
+ {Name: "body", Type: shortcut.FlagString, Desc: "新正文"},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"name", "subject", "body"}}},
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ if !rt.Changed("name") && !rt.Changed("subject") && !rt.Changed("body") {
+ return apperrors.NewValidation("至少指定 --name、--subject、--body 之一")
+ }
+ params := map[string]any{"email": rt.Str("email"), "id": rt.Str("id")}
+ for _, flag := range []string{"name", "subject", "body"} {
+ if rt.Changed(flag) {
+ params[flag] = rt.Str(flag)
+ }
+ }
+ if rt.DryRun() {
+ return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_user_message_template"}})
+ }
+ written, err := rt.CallMCPWriteDataStrict("mail", "update_user_message_template", params)
+ if err != nil {
+ return err
+ }
+ if err := mailRequireSuccess(written, "mail/update_user_message_template"); err != nil {
+ return err
+ }
+ verified, err := mailReadTemplate(rt, rt.Str("email"), rt.Str("id"))
+ if err != nil {
+ return err
+ }
+ if rt.Changed("name") && mailFirstString(verified, "name") != rt.Str("name") {
+ return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回名称与请求不一致")
+ }
+ if rt.Changed("subject") && mailTemplateSubject(verified) != rt.Str("subject") {
+ return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回主题与请求不一致")
+ }
+ if rt.Changed("body") && mailTemplateBody(verified) != rt.Str("body") {
+ return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回正文与请求不一致")
+ }
+ return rt.Output(map[string]any{"value": verified})
+ },
+}
+
+func init() {
+ markMailUnavailable(&DraftCreate, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。")
+ markMailUnavailable(&DraftEdit, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。")
+ markMailUnavailable(&TemplateCreate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。")
+ markMailUnavailable(&TemplateUpdate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。")
+ shortcut.Register(DraftCreate, DraftEdit, TemplateCreate, TemplateUpdate)
+}
diff --git a/internal/shortcut/minutesdata/workflow.go b/internal/shortcut/minutesdata/workflow.go
index 4ffc9a83..236f77b7 100644
--- a/internal/shortcut/minutesdata/workflow.go
+++ b/internal/shortcut/minutesdata/workflow.go
@@ -33,7 +33,8 @@ func RequireWriteAcknowledgement(operation string, data map[string]any) error {
// RequirePermissionMutationAcknowledgement validates the target-level payload
// returned by add/remove_member_permission. The backend's top-level
// success=true is not sufficient: historically it also accompanied a
-// resultMap that merely echoed nonexistent task UUIDs.
+// resultMap that merely echoed nonexistent task UUIDs, and it does not prove
+// that every requested task/member pair was changed.
func RequirePermissionMutationAcknowledgement(operation string, taskUUIDs, memberUIDs []string, data map[string]any) error {
if err := validateEnvelope(data); err != nil {
return err
diff --git a/internal/shortcut/minutesdata/workflow_test.go b/internal/shortcut/minutesdata/workflow_test.go
index b0bccb58..e566b645 100644
--- a/internal/shortcut/minutesdata/workflow_test.go
+++ b/internal/shortcut/minutesdata/workflow_test.go
@@ -26,6 +26,26 @@ func TestCrossPlatformCoverageMinutesWorkflowShapesRejectUnknownE2E(t *testing.T
}
}
+func TestCrossPlatformCoverageMinutesPermissionMutationRequiresExactResultMap(t *testing.T) {
+ for _, data := range []map[string]any{
+ {"success": true, "result": map[string]any{}},
+ {"success": true, "result": map[string]any{"resultMap": map[string]any{}}},
+ {"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{}}}},
+ {"success": true, "result": map[string]any{"resultMap": map[string]any{"other": []any{"m1"}}}},
+ } {
+ if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, data); err == nil {
+ t.Fatalf("incomplete permission acknowledgement accepted: %#v", data)
+ }
+ }
+ valid := map[string]any{
+ "success": true,
+ "result": map[string]any{"resultMap": map[string]any{"u1": []any{"m1"}}},
+ }
+ if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, valid); err != nil {
+ t.Fatalf("valid permission acknowledgement rejected: %v", err)
+ }
+}
+
func TestCrossPlatformCoverageMinutesWorkflowObservedShapesE2E(t *testing.T) {
record, err := RecordResult("pause", "u1", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "u1"}})
if err != nil || record["cmd"] != "pause" {
diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go
new file mode 100644
index 00000000..6e09854c
--- /dev/null
+++ b/internal/shortcut/oa/common.go
@@ -0,0 +1,415 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package oa
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+const oaCompositeReason = "Reviewed OA Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable identity checks, truthful pagination, output projection, and confirmation; no single MCP interface represents the complete command contract."
+
+type oaPageEvidence struct {
+ Known bool
+ HasMore bool
+ Next string
+}
+
+func oaCollectionResult(collection, description string) *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(fmt.Sprintf(
+ `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效审批记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格验证后的 OA 审批业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果完整"}},"required":["count",%q,"complete"],"additionalProperties":true}`,
+ description, collection, description, collection,
+ )),
+ SensitivePaths: []string{collection + ".title", collection + ".originatorName", collection + ".formValueVOS", collection + ".userId"},
+ }
+}
+
+func oaObjectResult(description string) *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(fmt.Sprintf(
+ `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格验证且身份匹配的 OA 审批对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
+ description,
+ )),
+ SensitivePaths: []string{"value.title", "value.processInstanceTitle", "value.formValueVOS", "value.originatorUserid", "value.operationRecords", "value.userId"},
+ }
+}
+
+func oaWriteResult(description string) *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(fmt.Sprintf(
+ `{"type":"object","description":%q,"properties":{"processInstanceId":{"type":"string","description":"被处理审批实例的稳定 ID"},"taskId":{"type":"string","description":"被处理审批任务的稳定 ID"},"verified":{"type":"boolean","description":"写后精确任务读回是否证明目标任务不再待处理"}},"required":["processInstanceId","taskId","verified"],"additionalProperties":false}`,
+ description,
+ )),
+ SensitivePaths: []string{"processInstanceId", "taskId"},
+ }
+}
+
+func oaReadSafety() contract.SafetySpec {
+ return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
+}
+
+func oaWriteSafety() contract.SafetySpec {
+ return contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
+}
+
+func oaContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
+ name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
+ cliPath := "oa " + command
+ availability := contract.InterfaceUnavailable
+ interfaceReason := strings.TrimSpace(intent)
+ if available {
+ availability = contract.InterfaceAvailable
+ interfaceReason = oaCompositeReason
+ }
+ return corecmd.ContractDecl{
+ Description: description,
+ Result: result,
+ Pagination: pagination,
+ Parameters: params,
+ Identity: contract.ToolIdentitySpec{
+ ProductID: "oa", Name: name, CanonicalPath: "oa." + name,
+ CLIPath: cliPath, PrimaryCLIPath: cliPath,
+ },
+ Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: interfaceReason},
+ Selection: contract.SelectionSpec{
+ AgentSummary: description,
+ UseWhen: []string{intent},
+ AvoidWhen: []string{"只需原始 OA MCP 响应时使用 oa approval 原子命令;缺少稳定实例、任务或表单身份时不要猜测"},
+ Examples: examples,
+ },
+ }
+}
+
+func oaPagePagination(parameter string) *contract.PaginationSpec {
+ return &contract.PaginationSpec{
+ Kind: contract.PaginationKindCursor,
+ CursorParameter: parameter,
+ MetaPath: contract.PaginationMetaPath,
+ EndpointExhaustedPath: contract.PaginationExhaustedPath,
+ NextTokenPath: contract.PaginationNextTokenPath,
+ }
+}
+
+func oaResponseError(operation, reason, message string) error {
+ return apperrors.NewAPI(message,
+ apperrors.WithOperation(operation),
+ apperrors.WithOrigin("mcp"),
+ apperrors.WithFailureStage("response_validation"),
+ apperrors.WithRetryable(false),
+ apperrors.WithReason(reason),
+ )
+}
+
+func oaPostWriteError(operation, reason, message string) error {
+ return apperrors.NewAPI(message,
+ apperrors.WithOperation(operation),
+ apperrors.WithOrigin("mcp"),
+ apperrors.WithFailureStage("write_verification"),
+ apperrors.WithExecutionStarted(true),
+ apperrors.WithRetryable(false),
+ apperrors.WithReason(reason),
+ )
+}
+
+// oaRequireSuccess accepts only the two success encodings observed from the
+// OA backend. Missing, false, null, and all other encodings fail closed.
+func oaRequireSuccess(data map[string]any, operation string) error {
+ if len(data) == 0 {
+ return oaResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 OA 调用成功或结果确实为空")
+ }
+ switch value := data["success"].(type) {
+ case bool:
+ if value {
+ return nil
+ }
+ case string:
+ if value == "true" {
+ return nil
+ }
+ }
+ if _, present := data["success"]; !present {
+ return oaResponseError(operation, "missing_success", "OA 响应缺少 success 业务状态")
+ }
+ message := oaFirstString(data, "errorMessage", "errorMsg", "message", "error")
+ if message == "" {
+ message = "OA 服务未明确返回成功状态"
+ }
+ return oaResponseError(operation, "remote_failure", message)
+}
+
+func oaLookup(object map[string]any, path string) (any, bool) {
+ var current any = object
+ for _, segment := range strings.Split(path, ".") {
+ mapped, ok := current.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ current, ok = mapped[segment]
+ if !ok {
+ return nil, false
+ }
+ }
+ return current, true
+}
+
+func oaRequireObject(data map[string]any, operation, path string) (map[string]any, error) {
+ if err := oaRequireSuccess(data, operation); err != nil {
+ return nil, err
+ }
+ value, present := oaLookup(data, path)
+ if !present || value == nil {
+ return nil, oaResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path))
+ }
+ object, ok := value.(map[string]any)
+ if !ok || len(object) == 0 {
+ return nil, oaResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value))
+ }
+ return object, nil
+}
+
+func oaRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
+ if err := oaRequireSuccess(data, operation); err != nil {
+ return nil, err
+ }
+ value, present := oaLookup(data, path)
+ if !present {
+ return nil, oaResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
+ }
+ raw, ok := value.([]any)
+ if !ok {
+ return nil, oaResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
+ }
+ items := make([]map[string]any, 0, len(raw))
+ for index, item := range raw {
+ object, ok := item.(map[string]any)
+ if !ok || len(object) == 0 {
+ return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
+ }
+ items = append(items, object)
+ }
+ return items, nil
+}
+
+func oaFirstString(object map[string]any, keys ...string) string {
+ for _, key := range keys {
+ if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+func oaScalarString(value any) string {
+ switch typed := value.(type) {
+ case string:
+ return strings.TrimSpace(typed)
+ case json.Number:
+ return typed.String()
+ case float64:
+ return strconv.FormatFloat(typed, 'f', -1, 64)
+ case int:
+ return strconv.Itoa(typed)
+ case int64:
+ return strconv.FormatInt(typed, 10)
+ default:
+ return ""
+ }
+}
+
+func oaIdentity(object map[string]any, keys ...string) string {
+ for _, key := range keys {
+ if value := oaScalarString(object[key]); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func oaRequireIdentity(object map[string]any, operation, expected string, keys ...string) error {
+ actual := oaIdentity(object, keys...)
+ if actual == "" {
+ return oaResponseError(operation, "missing_stable_id", "OA 业务对象缺少稳定 ID")
+ }
+ if expected != "" && actual != expected {
+ return oaResponseError(operation, "identity_mismatch", "OA 业务对象 ID 与请求目标不一致")
+ }
+ return nil
+}
+
+func oaProjectForms(data map[string]any, operation, path string) ([]map[string]any, error) {
+ items, err := oaRequireCollection(data, operation, path)
+ if err != nil {
+ return nil, err
+ }
+ forms := make([]map[string]any, 0, len(items))
+ for index, item := range items {
+ code := oaIdentity(item, "processCode")
+ name := oaFirstString(item, "processName", "name")
+ if code == "" || name == "" {
+ return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("表单结果第 %d 项缺少 processCode 或名称", index))
+ }
+ row := map[string]any{"processCode": code, "name": name}
+ if icon := oaFirstString(item, "processIconUrl", "iconUrl"); icon != "" {
+ row["iconUrl"] = icon
+ }
+ forms = append(forms, row)
+ }
+ return forms, nil
+}
+
+func oaProjectInstances(data map[string]any, operation, path string) ([]map[string]any, error) {
+ items, err := oaRequireCollection(data, operation, path)
+ if err != nil {
+ return nil, err
+ }
+ instances := make([]map[string]any, 0, len(items))
+ for index, item := range items {
+ id := oaIdentity(item, "processInstanceId")
+ if id == "" {
+ return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批结果第 %d 项缺少 processInstanceId", index))
+ }
+ row := map[string]any{"processInstanceId": id}
+ if title := oaFirstString(item, "title", "processInstanceTitle"); title != "" {
+ row["title"] = title
+ }
+ if status := oaScalarString(item["status"]); status != "" {
+ row["status"] = status
+ }
+ if businessID := oaIdentity(item, "businessId"); businessID != "" {
+ row["businessId"] = businessID
+ }
+ if originator := oaFirstString(item, "originatorName", "originatorUserName"); originator != "" {
+ row["originatorName"] = originator
+ }
+ for _, key := range []string{"processCreateTime", "createTime"} {
+ if value, present := item[key]; present && value != nil {
+ row["createTime"] = value
+ break
+ }
+ }
+ instances = append(instances, row)
+ }
+ return instances, nil
+}
+
+func oaProjectTasks(data map[string]any, operation string) ([]map[string]any, error) {
+ items, err := oaRequireCollection(data, operation, "result.taskIdList")
+ if err != nil {
+ return nil, err
+ }
+ tasks := make([]map[string]any, 0, len(items))
+ seen := make(map[string]struct{}, len(items))
+ for index, item := range items {
+ id := oaIdentity(item, "taskId")
+ if id == "" {
+ return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批任务第 %d 项缺少 taskId", index))
+ }
+ if _, duplicate := seen[id]; duplicate {
+ return nil, oaResponseError(operation, "duplicate_item_identity", "审批任务响应包含重复 taskId")
+ }
+ seen[id] = struct{}{}
+ tasks = append(tasks, map[string]any{"taskId": id})
+ }
+ return tasks, nil
+}
+
+func oaCursorPage(result map[string]any, operation string, current int) (oaPageEvidence, error) {
+ raw, present := result["hasMore"]
+ if !present {
+ return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "游标响应缺少 hasMore;不能把当前页当作完整结果")
+ }
+ hasMore, ok := raw.(bool)
+ if !ok {
+ return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值")
+ }
+ page := oaPageEvidence{Known: true, HasMore: hasMore}
+ next := oaScalarString(result["nextCursor"])
+ if !hasMore {
+ if next != "" {
+ return oaPageEvidence{}, oaResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空")
+ }
+ return page, nil
+ }
+ if next == "" {
+ return oaPageEvidence{}, oaResponseError(operation, "missing_next_cursor", "hasMore=true 但缺少 nextCursor")
+ }
+ nextValue, err := strconv.Atoi(next)
+ if err != nil {
+ return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "nextCursor 应为整数")
+ }
+ if nextValue <= current {
+ return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 没有严格前进")
+ }
+ page.Next = next
+ return page, nil
+}
+
+func oaHasMorePage(result map[string]any, operation string, currentPage int) (oaPageEvidence, error) {
+ raw, present := result["hasMore"]
+ if !present {
+ return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "审批列表缺少 hasMore,无法证明结果完整或提供续页凭据")
+ }
+ hasMore, ok := raw.(bool)
+ if !ok {
+ return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值")
+ }
+ page := oaPageEvidence{Known: true, HasMore: hasMore}
+ if hasMore {
+ if currentPage <= 0 {
+ return oaPageEvidence{}, oaResponseError(operation, "invalid_page", "当前页码无效,无法生成下一页凭据")
+ }
+ page.Next = strconv.Itoa(currentPage + 1)
+ }
+ return page, nil
+}
+
+func outputOAPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, page oaPageEvidence) error {
+ if !page.Known {
+ return oaResponseError("oa/pagination", "missing_pagination", "响应缺少分页终态证据")
+ }
+ payload := map[string]any{"count": len(items), collection: items, "complete": !page.HasMore}
+ if !output.UsesUnifiedResult(rt.Command()) {
+ if page.HasMore {
+ payload["nextPage"] = page.Next
+ }
+ return rt.Output(payload)
+ }
+ pagination, err := output.NewPagination(!page.HasMore, page.Next)
+ if err != nil {
+ return oaResponseError("oa/pagination", "invalid_pagination", err.Error())
+ }
+ meta := &output.Meta{Count: output.NewCount(len(items)), Pagination: pagination}
+ return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
+}
+
+func outputOACompleteCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any) error {
+ payload := map[string]any{"count": len(items), collection: items, "complete": true}
+ if !output.UsesUnifiedResult(rt.Command()) {
+ return rt.Output(payload)
+ }
+ meta := &output.Meta{Count: output.NewCount(len(items))}
+ return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
+}
+
+func validateOAPage(page, limit int) error {
+ if page <= 0 {
+ return apperrors.NewValidation("--page 必须大于 0")
+ }
+ if limit <= 0 || limit > 100 {
+ return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
+ }
+ return nil
+}
diff --git a/internal/shortcut/oa/list_forms_projection_test.go b/internal/shortcut/oa/list_forms_projection_test.go
deleted file mode 100644
index 21ce7df8..00000000
--- a/internal/shortcut/oa/list_forms_projection_test.go
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright 2026 Alibaba Group
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package oa
-
-import (
- "encoding/json"
- "testing"
-)
-
-// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss
-// class: list_user_visible_process nests the forms under result.processCodeList.
-// The resolver MUST probe that exact key — otherwise the whole list silently
-// projects to empty (exit 0, no error envelope) while the backend has data.
-func TestListFormsProjectProcessCodeListShape(t *testing.T) {
- // Faithful list_user_visible_process shape (as returned by the backend).
- const raw = `{"result":{"processCodeList":[
- {"processCode":"PROC-1","processName":"leave","dirName":"attendance"},
- {"processCode":"PROC-2","processName":"check-in fix","dirName":"attendance"},
- {"processCode":"PROC-3","processName":"overtime","dirName":"attendance"}
- ],"totalCount":-1},"success":true}`
- var data map[string]any
- if err := json.Unmarshal([]byte(raw), &data); err != nil {
- t.Fatalf("unmarshal fixture: %v", err)
- }
-
- forms := listFormsProject(data)
- if len(forms) != 3 {
- t.Fatalf("lower/upper mismatch: 3 forms in backend, projection returned %d (forms=%v)", len(forms), forms)
- }
- for _, f := range forms {
- if f["processCode"] == nil || f["name"] == nil {
- t.Fatalf("projected form missing processCode/name: %v", f)
- }
- }
-}
-
-// TestListFormsProjectBareArrayShape ensures a bare top-level array still works.
-func TestListFormsProjectBareArrayShape(t *testing.T) {
- const raw = `{"result":[
- {"processCode":"PROC-9","processName":"generic approval"}
- ]}`
- var data map[string]any
- if err := json.Unmarshal([]byte(raw), &data); err != nil {
- t.Fatalf("unmarshal fixture: %v", err)
- }
- if forms := listFormsProject(data); len(forms) != 1 {
- t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms)
- }
-}
-
-// TestOAInstanceResolveListValuesShape guards the shared resolver behind
-// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval
-// instance tools nest the list under result.values; the resolver must probe
-// "values" or all four commands silently return empty despite backend records.
-func TestOAInstanceResolveListValuesShape(t *testing.T) {
- const raw = `{"result":{"hasMore":false,"values":[
- {"processInstanceId":"i-1","title":"user leave"},
- {"processInstanceId":"i-2","title":"user reimbursement"}
- ]}}`
- var data map[string]any
- if err := json.Unmarshal([]byte(raw), &data); err != nil {
- t.Fatalf("unmarshal fixture: %v", err)
- }
- if got := oaInstanceResolveList(data); len(got) != 2 {
- t.Fatalf("lower/upper mismatch: result.values has 2 entries, resolver returned %d", len(got))
- }
- // End-to-end through a real projection that uses the shared resolver.
- if instances := listSubmittedProject(data); len(instances) != 2 {
- t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances)
- }
-}
diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go
index 80d1590d..b5ab751c 100644
--- a/internal/shortcut/oa/oa.go
+++ b/internal/shortcut/oa/oa.go
@@ -1,59 +1,73 @@
// Copyright 2026 Alibaba Group
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
+// Licensed under the Apache License, Version 2.0
-// Package oa registers declarative shortcuts for the DingTalk OA approval
-// service, wrapping the raw MCP tools exposed by the dws oa helper.
+// Package oa registers strict declarative shortcuts for DingTalk OA approval.
package oa
import (
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "fmt"
+ "strconv"
+ "strings"
+
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
-// ListPending — 查询待我处理的审批 (list_pending_approvals)
+func parseOAStringPage(rt *shortcut.RuntimeContext, name string, fallback int) (int, error) {
+ raw := rt.Str(name)
+ if raw == "" {
+ return fallback, nil
+ }
+ value, err := strconv.Atoi(raw)
+ if err != nil {
+ return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是整数", name))
+ }
+ return value, nil
+}
+
+func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string]any, page int) error {
+ operation := "oa/" + tool
+ data, err := rt.CallMCPData("oa", tool, params)
+ if err != nil {
+ return err
+ }
+ instances, err := oaProjectInstances(data, operation, "result.values")
+ if err != nil {
+ return err
+ }
+ result, _ := data["result"].(map[string]any)
+ evidence, err := oaHasMorePage(result, operation, page)
+ if err != nil {
+ return err
+ }
+ return outputOAPage(rt, "instances", instances, evidence)
+}
+
var ListPending = shortcut.Shortcut{
- Service: "oa",
- Command: "+list-pending",
- Product: "oa",
- Description: "查询待我处理的审批(时间范围为 epoch 毫秒)",
- Intent: "当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。",
- Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "oa",
- Name: "shortcut_list_pending",
- CanonicalPath: "oa.shortcut_list_pending",
- CLIPath: "oa +list-pending",
- PrimaryCLIPath: "oa +list-pending",
+ Service: "oa", Command: "+list-pending", Product: "oa",
+ Description: "查询待我处理的审批(时间范围为 epoch 毫秒)",
+ Intent: "按 epoch 毫秒时间范围查询当前用户待处理审批;只有显式成功、严格实例数组与可续页证据齐全时才返回结果。",
+ Risk: shortcut.RiskRead,
+ Safety: oaReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: oaContract(
+ "+list-pending",
+ "查询待我处理的审批(时间范围为 epoch 毫秒)",
+ "需要按时间范围读取待我审批的实例,并取得稳定 processInstanceId 时使用;没有安全非空待办 fixture 前不会进入公开发现。",
+ true,
+ oaCollectionResult("instances", "严格验证的待处理审批实例页"),
+ oaPagePagination("page"),
+ []contract.ParamDecl{
+ {Name: "start", Property: "start"},
+ {Name: "end", Property: "end"},
+ {Name: "page", Property: "page"},
+ {Name: "limit", Property: "limit"},
+ {Name: "query", Property: "query"},
},
- Description: "查询待我处理的审批(时间范围为 epoch 毫秒)",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "查询待我处理的审批(时间范围为 epoch 毫秒)",
- UseWhen: []string{"当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销"},
- },
- },
+ "dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20",
+ ),
Flags: []shortcut.Flag{
{Name: "start", Type: shortcut.FlagInt, Desc: "开始时间(epoch 毫秒)", Required: true},
{Name: "end", Type: shortcut.FlagInt, Desc: "结束时间(epoch 毫秒)", Required: true},
@@ -61,12 +75,25 @@ var ListPending = shortcut.Shortcut{
{Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"},
{Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
- Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销`},
- Execute: func(rt *shortcut.RuntimeContext) error {
- params := map[string]any{
- "starTime": rt.Int("start"),
- "endTime": rt.Int("end"),
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end", "page", "limit"}, Description: "--start/--end 必须是递增的正整数 epoch 毫秒;--page 必须大于 0;--limit 必须在 1-100"}},
+ Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if rt.Int("start") <= 0 || rt.Int("end") <= rt.Int("start") {
+ return apperrors.NewValidation("--start/--end 必须是递增的正整数 epoch 毫秒范围")
}
+ page, err := parseOAStringPage(rt, "page", 1)
+ if err != nil {
+ return err
+ }
+ limit, err := parseOAStringPage(rt, "limit", 20)
+ if err != nil {
+ return err
+ }
+ return validateOAPage(page, limit)
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ page, _ := parseOAStringPage(rt, "page", 1)
+ params := map[string]any{"starTime": rt.Int("start"), "endTime": rt.Int("end")}
if rt.Changed("page") {
params["pageNum"] = rt.Str("page")
}
@@ -76,530 +103,154 @@ var ListPending = shortcut.Shortcut{
if rt.Changed("query") {
params["query"] = rt.Str("query")
}
- data, err := rt.CallMCPData("oa", "list_pending_approvals", params)
- if err != nil {
- return err
- }
- instances := listPendingProject(data)
- return rt.Output(map[string]any{"count": len(instances), "instances": instances})
+ return oaInstancePage(rt, "list_pending_approvals", params, page)
},
}
-// listPendingProject reshapes the raw list_pending_approvals response into the
-// same clean {processInstanceId, title, status, createTime} approval list as
-// +list-executed/+list-submitted, so all approval-instance listings project
-// identically. It reuses the shared oaInstance* defensive probes, tolerating
-// response-shape and key-spelling drift.
-func listPendingProject(data map[string]any) []map[string]any {
- raw := oaInstanceResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- if row := oaInstanceProjectItem(m); len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// Detail — 获取审批实例详情 (get_processInstance_detail)
-// Approve — 同意审批 (approve_processInstance)
-// Reject — 拒绝审批 (reject_processInstance)
-// Revoke — 撤销已发起的审批 (revoke_processInstance)
-// Records — 获取审批操作记录 (get_processInstance_records)
-// ListInitiated — 查询审批模板下已发起的审批记录 (list_initiated_instances)
-// ListTasks — 查询待我审批的任务 ID (list_pending_tasks)
-// ListForms — 获取当前用户可见的审批表单列表 (list_user_visible_process)
var ListForms = shortcut.Shortcut{
- Service: "oa",
- Command: "+list-forms",
- Product: "oa",
- Description: "获取当前用户可见的审批表单列表",
- Intent: "当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。",
- Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "oa",
- Name: "shortcut_list_forms",
- CanonicalPath: "oa.shortcut_list_forms",
- CLIPath: "oa +list-forms",
- PrimaryCLIPath: "oa +list-forms",
- },
- Description: "获取当前用户可见的审批表单列表",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "获取当前用户可见的审批表单列表",
- UseWhen: []string{"当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws oa +list-forms --cursor 0 --limit 100"},
- },
- },
+ Service: "oa", Command: "+list-forms", Product: "oa",
+ Description: "获取当前用户可见的审批表单列表",
+ Intent: "按服务端游标读取当前用户可发起的审批表单;缺少 hasMore/nextCursor 或游标不前进时失败,不把重复首页宣称为完整列表。",
+ Risk: shortcut.RiskRead,
+ Safety: oaReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: oaContract(
+ "+list-forms", "获取当前用户可见的审批表单列表",
+ "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故不进入 Agent 公开发现。",
+ true,
+ oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"),
+ []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}},
+ "dws oa +list-forms --cursor 0 --limit 100",
+ ),
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 100"},
},
- Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`},
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor", "limit"}, Description: "--cursor 不能小于 0;--limit 必须在 1-100"}},
+ Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if rt.Int("cursor") < 0 {
+ return apperrors.NewValidation("--cursor 不能小于 0")
+ }
+ if rt.Int("limit") <= 0 || rt.Int("limit") > 100 {
+ return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
+ }
+ return nil
+ },
Execute: func(rt *shortcut.RuntimeContext) error {
- data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{
- "cursor": rt.Int("cursor"),
- "pageSize": rt.Int("limit"),
- })
+ const operation = "oa/list_user_visible_process"
+ data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{"cursor": rt.Int("cursor"), "pageSize": rt.Int("limit")})
if err != nil {
return err
}
- forms := listFormsProject(data)
- return rt.Output(map[string]any{"count": len(forms), "forms": forms})
+ forms, err := oaProjectForms(data, operation, "result.processCodeList")
+ if err != nil {
+ return err
+ }
+ result, _ := data["result"].(map[string]any)
+ page, err := oaCursorPage(result, operation, rt.Int("cursor"))
+ if err != nil {
+ return err
+ }
+ return outputOAPage(rt, "forms", forms, page)
},
}
-// listFormsProject reshapes the raw list_user_visible_process response into a
-// clean approval-form list ({processCode, name, iconUrl}) — the output-projection
-// fidelity the framework applies to every list command. The list container and
-// per-item field names are probed defensively across candidate keys so the
-// projection tolerates response-shape drift rather than crashing or fabricating.
-func listFormsProject(data map[string]any) []map[string]any {
- raw := oaFormResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- if row := oaFormProjectItem(m); len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// oaFormResolveList locates the form list inside a response, tolerating a bare
-// top-level array or nesting one level under a common envelope key.
-func oaFormResolveList(data map[string]any) []any {
- if data == nil {
- return []any{}
- }
- // list_user_visible_process nests the forms under result.processCodeList;
- // probing must include that exact key (both at the top level in case the
- // envelope is already unwrapped, and one level deeper under result/data) or
- // the whole list silently projects to empty.
- for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} {
- v, ok := data[key]
- if !ok {
- continue
- }
- if arr, ok := v.([]any); ok {
- return arr
- }
- if inner, ok := v.(map[string]any); ok {
- for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} {
- if arr, ok := inner[ik].([]any); ok {
- return arr
- }
- }
- }
- }
- return []any{}
-}
-
-// oaFormProjectItem picks the stable identity/label fields of a single approval
-// form, probing candidate key spellings so it survives snake/camel drift.
-func oaFormProjectItem(m map[string]any) map[string]any {
- row := map[string]any{}
- if v, ok := oaFormFirst(m, "processCode", "process_code", "code"); ok {
- row["processCode"] = v
- }
- if v, ok := oaFormFirst(m, "name", "processName", "process_name", "flowTitle"); ok {
- row["name"] = v
- }
- if v, ok := oaFormFirst(m, "iconUrl", "icon_url", "iconName"); ok {
- row["iconUrl"] = v
- }
- return row
-}
-
-// oaFormFirst returns the first present candidate key's value.
-func oaFormFirst(m map[string]any, keys ...string) (any, bool) {
- for _, k := range keys {
- if v, ok := m[k]; ok {
- return v, true
- }
- }
- return nil, false
-}
-
-// SearchForms — 按关键字模糊搜索可见审批表单 (search_form)
var SearchForms = shortcut.Shortcut{
- Service: "oa",
- Command: "+search-forms",
- Product: "oa",
- Description: "按关键字模糊搜索当前用户可见的审批表单",
- Intent: "当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。",
- Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
+ Service: "oa", Command: "+search-forms", Product: "oa",
+ Description: "按关键字模糊搜索当前用户可见的审批表单",
+ Intent: "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。",
+ Risk: shortcut.RiskRead,
+ Safety: oaReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: oaContract(
+ "+search-forms", "按关键字模糊搜索当前用户可见的审批表单",
+ "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。",
+ true,
+ oaCollectionResult("forms", "严格验证的审批表单搜索结果"), nil,
+ []contract.ParamDecl{{Name: "query", Property: "query"}},
+ "dws oa +search-forms --query 报销",
+ ),
+ Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称);去除空白后不能为空", Required: true}},
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}},
+ Tips: []string{`dws oa +search-forms --query 报销`},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if strings.TrimSpace(rt.Str("query")) == "" {
+ return apperrors.NewValidation("--query 不能为空")
+ }
+ return nil
},
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "oa",
- Name: "shortcut_search_forms",
- CanonicalPath: "oa.shortcut_search_forms",
- CLIPath: "oa +search-forms",
- PrimaryCLIPath: "oa +search-forms",
- },
- Description: "按关键字模糊搜索当前用户可见的审批表单",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "按关键字模糊搜索当前用户可见的审批表单",
- UseWhen: []string{"当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws oa +search-forms --query 报销"},
- },
- },
- Flags: []shortcut.Flag{
- {Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true},
- },
- Tips: []string{`dws oa +search-forms --query 报销`},
Execute: func(rt *shortcut.RuntimeContext) error {
- data, err := rt.CallMCPData("oa", "search_form", map[string]any{
- "query": rt.Str("query"),
- })
+ const operation = "oa/search_form"
+ data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": strings.TrimSpace(rt.Str("query"))})
if err != nil {
return err
}
- forms := searchFormsProject(data)
- return rt.Output(map[string]any{"count": len(forms), "forms": forms})
- },
-}
-
-// searchFormsProject reshapes the raw search_form response into the same clean
-// {processCode, name, iconUrl} list as +list-forms, so both approval-form
-// listings project identically. It reuses the shared oaForm* defensive probes,
-// tolerating response-shape and key-spelling drift.
-func searchFormsProject(data map[string]any) []map[string]any {
- raw := oaFormResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- if row := oaFormProjectItem(m); len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// DingInfo — 获取审批任务的被催办人 userId (oa_ding_user)
-// ListExecuted — 获取当前用户已处理过的审批单列表 (get_done_tasks)
-var ListExecuted = shortcut.Shortcut{
- Service: "oa",
- Command: "+list-executed",
- Product: "oa",
- Description: "获取当前用户已经处理过的审批单列表",
- Intent: "当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。",
- Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "oa",
- Name: "shortcut_list_executed",
- CanonicalPath: "oa.shortcut_list_executed",
- CLIPath: "oa +list-executed",
- PrimaryCLIPath: "oa +list-executed",
- },
- Description: "获取当前用户已经处理过的审批单列表",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "获取当前用户已经处理过的审批单列表",
- UseWhen: []string{"当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws oa +list-executed --limit 20 --page 1 --query 报销"},
- },
- },
- Flags: []shortcut.Flag{
- {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"},
- {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"},
- {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
- },
- Tips: []string{`dws oa +list-executed --limit 20 --page 1 --query 报销`},
- Execute: func(rt *shortcut.RuntimeContext) error {
- params := map[string]any{
- "pageNumber": rt.Str("page"),
- "pageSize": rt.Str("limit"),
- }
- if rt.Changed("query") {
- params["query"] = rt.Str("query")
- }
- data, err := rt.CallMCPData("oa", "get_done_tasks", params)
+ forms, err := oaProjectForms(data, operation, "result")
if err != nil {
return err
}
- instances := listExecutedProject(data)
- return rt.Output(map[string]any{"count": len(instances), "instances": instances})
+ return outputOACompleteCollection(rt, "forms", forms)
},
}
-// listExecutedProject reshapes the raw get_done_tasks response into a clean
-// approval-instance list ({processInstanceId, title, status, createTime}) — the
-// the clean output projection applied to every list command. The
-// list container and per-item field names are probed defensively across
-// candidate keys so the projection tolerates response-shape drift rather than
-// crashing or fabricating data.
-func listExecutedProject(data map[string]any) []map[string]any {
- raw := oaInstanceResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- if row := oaInstanceProjectItem(m); len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// oaInstanceResolveList locates the approval-instance list inside a response,
-// tolerating a bare top-level array or nesting one level under a common
-// envelope key.
-func oaInstanceResolveList(data map[string]any) []any {
- if data == nil {
- return []any{}
- }
- // The approval instance tools (list_pending_approvals, get_done_tasks,
- // get_submitted_instances, get_noticed_instances) all nest the instance list
- // under result.values; "values" MUST be in the probe set or every one of
- // +list-pending / +list-executed / +list-submitted / +list-cc silently
- // projects to empty despite the backend returning records.
- for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} {
- v, ok := data[key]
- if !ok {
- continue
- }
- if arr, ok := v.([]any); ok {
- return arr
- }
- if inner, ok := v.(map[string]any); ok {
- for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} {
- if arr, ok := inner[ik].([]any); ok {
- return arr
- }
- }
- }
- }
- return []any{}
-}
-
-// oaInstanceProjectItem picks the stable identity/label fields of a single
-// approval instance, probing candidate key spellings so it survives
-// snake/camel drift.
-func oaInstanceProjectItem(m map[string]any) map[string]any {
- row := map[string]any{}
- if v, ok := oaFormFirst(m, "processInstanceId", "process_instance_id", "instanceId", "id"); ok {
- row["processInstanceId"] = v
- }
- if v, ok := oaFormFirst(m, "title", "processTitle", "process_title", "name"); ok {
- row["title"] = v
- }
- if v, ok := oaFormFirst(m, "status", "result", "processResult"); ok {
- row["status"] = v
- }
- if v, ok := oaFormFirst(m, "createTime", "create_time", "gmtCreate", "createdTime"); ok {
- row["createTime"] = v
- }
- return row
-}
-
-// ListSubmitted — 获取当前用户已发起的审批单列表 (get_submitted_instances)
-var ListSubmitted = shortcut.Shortcut{
- Service: "oa",
- Command: "+list-submitted",
- Product: "oa",
- Description: "获取当前用户已发起的审批单列表",
- Intent: "当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。",
- Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "oa",
- Name: "shortcut_list_submitted",
- CanonicalPath: "oa.shortcut_list_submitted",
- CLIPath: "oa +list-submitted",
- PrimaryCLIPath: "oa +list-submitted",
+func oaNumberedInstanceShortcut(command, tool, description, intent string) shortcut.Shortcut {
+ declaration := shortcut.Shortcut{
+ Service: "oa", Command: command, Product: "oa",
+ Description: description, Intent: intent, Risk: shortcut.RiskRead,
+ Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive,
+ Contract: oaContract(command, description, intent, true,
+ oaCollectionResult("instances", description), oaPagePagination("page"),
+ []contract.ParamDecl{{Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}},
+ "dws oa "+command+" --page 1 --limit 20"),
+ Flags: []shortcut.Flag{
+ {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码;--page 必须大于 0"},
+ {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小;--limit 必须在 1-100"},
+ {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
},
- Description: "获取当前用户已发起的审批单列表",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
},
- Selection: contract.SelectionSpec{
- AgentSummary: "获取当前用户已发起的审批单列表",
- UseWhen: []string{"当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws oa +list-submitted --limit 20 --page 1 --query 报销"},
- },
- },
- Flags: []shortcut.Flag{
- {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"},
- {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"},
- {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
- },
- Tips: []string{`dws oa +list-submitted --limit 20 --page 1 --query 报销`},
- Execute: func(rt *shortcut.RuntimeContext) error {
- params := map[string]any{
- "pageNumber": rt.Str("page"),
- "pageSize": rt.Str("limit"),
- }
- if rt.Changed("query") {
- params["query"] = rt.Str("query")
- }
- data, err := rt.CallMCPData("oa", "get_submitted_instances", params)
+ Tips: []string{"dws oa " + command + " --page 1 --limit 20"},
+ }
+ declaration.Validate = func(rt *shortcut.RuntimeContext) error {
+ page, err := parseOAStringPage(rt, "page", 1)
if err != nil {
return err
}
- instances := listSubmittedProject(data)
- return rt.Output(map[string]any{"count": len(instances), "instances": instances})
- },
-}
-
-// listSubmittedProject reshapes the raw get_submitted_instances response into
-// the same clean {processInstanceId, title, status, createTime} approval list
-// as +list-executed, so both instance listings project identically. It reuses
-// the shared oaInstance* defensive probes, tolerating response-shape and
-// key-spelling drift.
-func listSubmittedProject(data map[string]any) []map[string]any {
- raw := oaInstanceResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- if row := oaInstanceProjectItem(m); len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// ListCc — 获取抄送当前用户的审批单列表 (get_noticed_instances)
-var ListCc = shortcut.Shortcut{
- Service: "oa",
- Command: "+list-cc",
- Product: "oa",
- Description: "获取抄送当前用户的审批单列表",
- Intent: "当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。",
- Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "oa",
- Name: "shortcut_list_cc",
- CanonicalPath: "oa.shortcut_list_cc",
- CLIPath: "oa +list-cc",
- PrimaryCLIPath: "oa +list-cc",
- },
- Description: "获取抄送当前用户的审批单列表",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "获取抄送当前用户的审批单列表",
- UseWhen: []string{"当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws oa +list-cc --limit 20 --page 1 --query 报销"},
- },
- },
- Flags: []shortcut.Flag{
- {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"},
- {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"},
- {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"},
- },
- Tips: []string{`dws oa +list-cc --limit 20 --page 1 --query 报销`},
- Execute: func(rt *shortcut.RuntimeContext) error {
- params := map[string]any{
- "pageNumber": rt.Str("page"),
- "pageSize": rt.Str("limit"),
- }
- if rt.Changed("query") {
- params["query"] = rt.Str("query")
- }
- data, err := rt.CallMCPData("oa", "get_noticed_instances", params)
+ limit, err := parseOAStringPage(rt, "limit", 20)
if err != nil {
return err
}
- instances := listCcProject(data)
- return rt.Output(map[string]any{"count": len(instances), "instances": instances})
- },
-}
-
-// listCcProject reshapes the raw get_noticed_instances response into the same
-// clean {processInstanceId, title, status, createTime} approval list as the
-// other instance listings, so all approval-instance listings project
-// identically. It reuses the shared oaInstance* defensive probes, tolerating
-// response-shape and key-spelling drift.
-func listCcProject(data map[string]any) []map[string]any {
- raw := oaInstanceResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- if row := oaInstanceProjectItem(m); len(row) > 0 {
- out = append(out, row)
- }
+ return validateOAPage(page, limit)
}
- return out
+ declaration.Execute = func(rt *shortcut.RuntimeContext) error {
+ page, _ := parseOAStringPage(rt, "page", 1)
+ params := map[string]any{"pageNumber": rt.Str("page"), "pageSize": rt.Str("limit")}
+ if query := strings.TrimSpace(rt.Str("query")); query != "" {
+ params["query"] = query
+ }
+ return oaInstancePage(rt, tool, params, page)
+ }
+ return declaration
}
-// RedirectTask — 转交审批任务给其他人 (redirect_task)
-// Comment — 对审批实例添加评论 (dingflow_comments)
-// CcNotice — 对审批实例进行抄送 (oa_cc_noticer)
-// AppendTask — 对审批任务进行加签 (append_task)
-// RevertActivities — 获取审批任务可回退的节点信息 (get_inst_revert_activities)
-// RevertTask — 退回审批任务到指定节点 (revert_task)
+var ListExecuted = oaNumberedInstanceShortcut(
+ "+list-executed", "get_done_tasks", "获取当前用户已经处理过的审批单列表",
+ "需要回顾当前用户已同意或拒绝过的审批实例时使用;与待办、已发起和抄送列表分开。",
+)
+
+var ListSubmitted = oaNumberedInstanceShortcut(
+ "+list-submitted", "get_submitted_instances", "获取当前用户已发起的审批单列表",
+ "需要查看当前用户发起的审批实例和当前状态时使用;返回稳定 processInstanceId 与可续页证据。",
+)
+
+var ListCc = oaNumberedInstanceShortcut(
+ "+list-cc", "get_noticed_instances", "获取抄送当前用户的审批单列表",
+ "需要查看抄送给当前用户的审批实例时使用;没有安全非空抄送 fixture 前不会进入公开发现。",
+)
+
func init() {
- shortcut.Register(
- ListPending,
- ListForms,
- SearchForms,
- ListExecuted,
- ListSubmitted,
- ListCc,
- )
+ shortcut.Register(ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, ListCc)
}
diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go
new file mode 100644
index 00000000..7df9f056
--- /dev/null
+++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go
@@ -0,0 +1,728 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package oa
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+type oaCoverageCaller struct {
+ responses map[string][]string
+ history []string
+ arguments []map[string]any
+}
+
+func (caller *oaCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
+ caller.history = append(caller.history, tool)
+ caller.arguments = append(caller.arguments, arguments)
+ queue := caller.responses[tool]
+ if len(queue) == 0 {
+ return nil, errors.New("missing OA fake response for " + tool)
+ }
+ caller.responses[tool] = queue[1:]
+ if queue[0] == "__ERROR__" {
+ return nil, errors.New("injected OA failure")
+ }
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
+}
+
+func (*oaCoverageCaller) Format() string { return "json" }
+func (*oaCoverageCaller) DryRun() bool { return false }
+func (*oaCoverageCaller) Fields() string { return "" }
+func (*oaCoverageCaller) JQ() string { return "" }
+
+func runOACoverage(t *testing.T, declaration shortcut.Shortcut, caller *oaCoverageCaller, args ...string) (*cobra.Command, error) {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs(args)
+ return cmd, cmd.Execute()
+}
+
+func runOAConfirmedCoverage(t *testing.T, caller *oaCoverageCaller, args ...string) error {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ 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", "")
+ ctx, _ := output.WithResultStore(context.Background())
+ root.SetContext(ctx)
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ service := &cobra.Command{Use: "oa"}
+ service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve)))
+ root.AddCommand(service)
+ root.SetArgs(append([]string{"oa", "+approve-by"}, args...))
+ return root.Execute()
+}
+
+func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) {
+ for _, declaration := range []shortcut.Shortcut{
+ ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted,
+ ListCc, PendingApprovals, DoneApprovals, Approve, MyInitiated,
+ } {
+ if declaration.Contract.Empty() || declaration.Contract.Result == nil {
+ t.Errorf("%s lacks Contract.Result", declaration.Command)
+ }
+ if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" {
+ t.Errorf("%s lacks Safety", declaration.Command)
+ }
+ if declaration.OutputRollout != output.RolloutUnifiedActive {
+ t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
+ }
+ }
+ if Approve.Safety.Confirmation != "user_required" || Approve.Risk != shortcut.RiskHighWrite {
+ t.Fatal("+approve-by must require explicit high-risk confirmation")
+ }
+ unavailable := oaContract("+fixture-unavailable", "fixture unavailable", "reviewed fixture is unavailable", false, oaCollectionResult("items", "fixture items"), nil, nil)
+ if unavailable.Interface == nil || unavailable.Interface.Availability != "unavailable" || unavailable.Interface.Reason != "reviewed fixture is unavailable" {
+ t.Fatalf("unavailable OA contract interface=%+v", unavailable.Interface)
+ }
+ for _, declaration := range []shortcut.Shortcut{ListPending, ListForms, ListExecuted, ListSubmitted, ListCc, MyInitiated} {
+ if declaration.Contract.Pagination == nil {
+ t.Errorf("%s lacks Pagination", declaration.Command)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageOAStrictResponseMatrix(t *testing.T) {
+ validEmpty := map[string]any{"success": "true", "result": map[string]any{"values": []any{}}}
+ items, err := oaProjectInstances(validEmpty, "oa/test", "result.values")
+ if err != nil || len(items) != 0 {
+ t.Fatalf("explicit empty must succeed: items=%v err=%v", items, err)
+ }
+ for name, fixture := range map[string]map[string]any{
+ "empty": {},
+ "missing success": {"result": map[string]any{"values": []any{}}},
+ "false success": {"success": false, "result": map[string]any{"values": []any{}}},
+ "missing collection": {"success": true, "result": map[string]any{}},
+ "wrong collection": {"success": true, "result": map[string]any{"values": map[string]any{}}},
+ "bad item": {"success": true, "result": map[string]any{"values": []any{"bad"}}},
+ "missing identity": {"success": true, "result": map[string]any{"values": []any{map[string]any{"title": "fixture"}}}},
+ } {
+ if projected, err := oaProjectInstances(fixture, "oa/test", "result.values"); err == nil {
+ t.Errorf("%s returned success: %#v", name, projected)
+ }
+ }
+ forms := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processCode": "p", "processName": "fixture"}}}}
+ if projected, err := oaProjectForms(forms, "oa/forms", "result.processCodeList"); err != nil || len(projected) != 1 {
+ t.Fatalf("strict forms projection=%#v err=%v", projected, err)
+ }
+ badForm := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processName": "fixture"}}}}
+ if projected, err := oaProjectForms(badForm, "oa/forms", "result.processCodeList"); err == nil {
+ t.Fatalf("form without processCode returned success: %#v", projected)
+ }
+ search := map[string]any{"success": true, "result": []any{map[string]any{"processCode": "p", "processName": "fixture"}}}
+ if projected, err := oaProjectForms(search, "oa/search", "result"); err != nil || len(projected) != 1 {
+ t.Fatalf("strict search projection=%#v err=%v", projected, err)
+ }
+}
+
+func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) {
+ if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1); err == nil {
+ t.Fatal("numbered page without hasMore was accepted")
+ }
+ if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 1); err == nil {
+ t.Fatal("wrong hasMore type was accepted")
+ }
+ page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 2)
+ if err != nil || !page.HasMore || page.Next != "3" {
+ t.Fatalf("numbered continuation=%+v err=%v", page, err)
+ }
+ if _, err := oaCursorPage(map[string]any{}, "oa/cursor", 0); err == nil {
+ t.Fatal("cursor response without pagination was accepted")
+ }
+ if _, err := oaCursorPage(map[string]any{"hasMore": true}, "oa/cursor", 0); err == nil {
+ t.Fatal("hasMore without nextCursor was accepted")
+ }
+ if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 1); err == nil {
+ t.Fatal("stalled cursor was accepted")
+ }
+ if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 2); err == nil {
+ t.Fatal("backward cursor was accepted")
+ }
+ if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": "not-a-number"}, "oa/cursor", 1); err == nil {
+ t.Fatal("non-integer cursor was accepted")
+ }
+ page, err = oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(2)}, "oa/cursor", 1)
+ if err != nil || page.Next != "2" {
+ t.Fatalf("cursor continuation=%+v err=%v", page, err)
+ }
+}
+
+func TestCrossPlatformCoverageMyInitiatedNeverFallsBackToRawResponse(t *testing.T) {
+ caller := &oaCoverageCaller{responses: map[string][]string{
+ "get_submitted_instances": {`{"success":"true","result":{"values":[],"hasMore":false}}`},
+ }}
+ cmd, err := runOACoverage(t, MyInitiated, caller, "--page", "1", "--limit", "20")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ code, emitted, err := output.EmitStoredResult(cmd)
+ if err != nil || !emitted || code != 0 {
+ t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err)
+ }
+ var envelope struct {
+ Data map[string]any `json:"data"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatal(err)
+ }
+ if _, raw := envelope.Data["result"]; raw {
+ t.Fatalf("raw result leaked: %s", stdout.String())
+ }
+ initiated, ok := envelope.Data["initiated"].([]any)
+ if !ok || len(initiated) != 0 || envelope.Data["complete"] != true {
+ t.Fatalf("strict empty initiated payload=%#v", envelope.Data)
+ }
+}
+
+func TestCrossPlatformCoverageOAApproveConfirmationAndReadback(t *testing.T) {
+ unconfirmed := &oaCoverageCaller{responses: map[string][]string{}}
+ helpers.InitDepsForTest(t, unconfirmed)
+ 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", "")
+ ctx, _ := output.WithResultStore(context.Background())
+ root.SetContext(ctx)
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ service := &cobra.Command{Use: "oa"}
+ service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve)))
+ root.AddCommand(service)
+ root.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture"})
+ if err := root.Execute(); err == nil {
+ t.Fatal("unconfirmed approval unexpectedly succeeded")
+ }
+ if len(unconfirmed.history) != 0 {
+ t.Fatalf("unconfirmed approval made calls: %v", unconfirmed.history)
+ }
+
+ confirmed := &oaCoverageCaller{responses: map[string][]string{
+ "list_pending_approvals": {`{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"instance-1","title":"fixture"}]}}`},
+ "list_pending_tasks": {
+ `{"success":true,"result":{"taskIdList":[{"taskId":1}]}}`,
+ `{"success":true,"result":{"taskIdList":[]}}`,
+ },
+ "approve_processInstance": {`{"success":true,"result":{"accepted":true}}`},
+ }}
+ helpers.InitDepsForTest(t, confirmed)
+ confirmedRoot := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
+ confirmedRoot.PersistentFlags().Bool("yes", false, "")
+ confirmedRoot.PersistentFlags().Bool("dry-run", false, "")
+ confirmedRoot.PersistentFlags().String("format", "json", "")
+ confirmedCtx, _ := output.WithResultStore(context.Background())
+ confirmedRoot.SetContext(confirmedCtx)
+ confirmedRoot.SetOut(io.Discard)
+ confirmedRoot.SetErr(io.Discard)
+ confirmedService := &cobra.Command{Use: "oa"}
+ confirmedService.AddCommand(corecmd.New(shortcut.FromShortcut(Approve)))
+ confirmedRoot.AddCommand(confirmedService)
+ confirmedRoot.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture", "--yes"})
+ if err := confirmedRoot.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ if got := strings.Join(confirmed.history, ","); got != "list_pending_approvals,list_pending_tasks,approve_processInstance,list_pending_tasks" {
+ t.Fatalf("call history=%s", got)
+ }
+ if len(confirmed.arguments) != 4 || confirmed.arguments[2]["processInstanceId"] != "instance-1" || confirmed.arguments[3]["processInstanceId"] != "instance-1" {
+ t.Fatalf("identity was not preserved: %#v", confirmed.arguments)
+ }
+}
+
+func TestCrossPlatformCoverageOACommonHelperBranches(t *testing.T) {
+ if oaObjectResult("fixture") == nil || oaPostWriteError("oa/test", "fixture", "fixture") == nil {
+ t.Fatal("typed object result and post-write error must be constructed")
+ }
+
+ for name, fixture := range map[string]map[string]any{
+ "false with backend message": {"success": false, "errorMessage": "fixture"},
+ "false without message": {"success": false},
+ "null success": {"success": nil},
+ "wrong success string": {"success": "TRUE"},
+ } {
+ if err := oaRequireSuccess(fixture, "oa/test"); err == nil {
+ t.Errorf("%s was accepted", name)
+ }
+ }
+
+ if _, ok := oaLookup(map[string]any{"result": "bad"}, "result.values"); ok {
+ t.Fatal("lookup traversed a non-object")
+ }
+ if _, ok := oaLookup(map[string]any{"result": map[string]any{}}, "result.values"); ok {
+ t.Fatal("lookup accepted a missing segment")
+ }
+ for name, fixture := range map[string]map[string]any{
+ "nil object": {"success": true, "result": nil},
+ "wrong object": {"success": true, "result": "bad"},
+ "empty object": {"success": true, "result": map[string]any{}},
+ } {
+ if value, err := oaRequireObject(fixture, "oa/test", "result"); err == nil {
+ t.Errorf("%s returned object %#v", name, value)
+ }
+ }
+ if value, err := oaRequireObject(map[string]any{"success": false}, "oa/test", "result"); err == nil {
+ t.Fatalf("failed envelope returned object %#v", value)
+ }
+ object, err := oaRequireObject(map[string]any{"success": true, "result": map[string]any{"id": "fixture"}}, "oa/test", "result")
+ if err != nil || object["id"] != "fixture" {
+ t.Fatalf("valid object=%#v err=%v", object, err)
+ }
+
+ for name, value := range map[string]any{
+ "string": " fixture ",
+ "json number": json.Number("42"),
+ "float": float64(42),
+ "int": int(42),
+ "int64": int64(42),
+ "unsupported": true,
+ } {
+ got := oaScalarString(value)
+ if name == "unsupported" && got != "" {
+ t.Errorf("unsupported scalar=%q", got)
+ }
+ if name != "unsupported" && got == "" {
+ t.Errorf("%s scalar was empty", name)
+ }
+ }
+
+ identityFixture := map[string]any{"id": int64(42)}
+ if err := oaRequireIdentity(identityFixture, "oa/test", "42", "id"); err != nil {
+ t.Fatal(err)
+ }
+ if err := oaRequireIdentity(map[string]any{}, "oa/test", "", "id"); err == nil {
+ t.Fatal("missing identity was accepted")
+ }
+ if err := oaRequireIdentity(identityFixture, "oa/test", "43", "id"); err == nil {
+ t.Fatal("mismatched identity was accepted")
+ }
+
+ formsFixture := map[string]any{"success": true, "result": []any{map[string]any{
+ "processCode": "p", "name": "fixture", "iconUrl": "icon",
+ }}}
+ forms, err := oaProjectForms(formsFixture, "oa/forms", "result")
+ if err != nil || forms[0]["iconUrl"] != "icon" {
+ t.Fatalf("fallback form fields=%#v err=%v", forms, err)
+ }
+
+ instanceFixture := map[string]any{"success": true, "result": map[string]any{"values": []any{map[string]any{
+ "processInstanceId": "i", "processInstanceTitle": "title", "status": json.Number("2"),
+ "businessId": 7, "originatorUserName": "owner", "createTime": int64(9),
+ }}}}
+ instances, err := oaProjectInstances(instanceFixture, "oa/instances", "result.values")
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantInstance := map[string]any{"processInstanceId": "i", "title": "title", "status": "2", "businessId": "7", "originatorName": "owner", "createTime": int64(9)}
+ if !reflect.DeepEqual(instances[0], wantInstance) {
+ t.Fatalf("projected instance=%#v want=%#v", instances[0], wantInstance)
+ }
+
+ for name, fixture := range map[string]map[string]any{
+ "task projection error": {"success": true, "result": map[string]any{}},
+ "missing task id": {"success": true, "result": map[string]any{"taskIdList": []any{map[string]any{"x": 1}}}},
+ "duplicate task id": {"success": true, "result": map[string]any{"taskIdList": []any{
+ map[string]any{"taskId": 1}, map[string]any{"taskId": 1},
+ }}},
+ } {
+ if tasks, err := oaProjectTasks(fixture, "oa/tasks"); err == nil {
+ t.Errorf("%s returned %#v", name, tasks)
+ }
+ }
+
+ for name, fixture := range map[string]map[string]any{
+ "wrong cursor hasMore": {"hasMore": "true"},
+ "terminal conflict": {"hasMore": false, "nextCursor": 1},
+ } {
+ if page, err := oaCursorPage(fixture, "oa/cursor", 0); err == nil {
+ t.Errorf("%s returned %+v", name, page)
+ }
+ }
+ terminal, err := oaCursorPage(map[string]any{"hasMore": false}, "oa/cursor", 0)
+ if err != nil || !terminal.Known || terminal.HasMore || terminal.Next != "" {
+ t.Fatalf("terminal cursor=%+v err=%v", terminal, err)
+ }
+ if _, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 0); err == nil {
+ t.Fatal("continuation from invalid current page was accepted")
+ }
+ terminal, err = oaHasMorePage(map[string]any{"hasMore": false}, "oa/page", 1)
+ if err != nil || !terminal.Known || terminal.HasMore {
+ t.Fatalf("terminal numbered page=%+v err=%v", terminal, err)
+ }
+
+ if err := validateOAPage(0, 1); err == nil {
+ t.Fatal("zero page was accepted")
+ }
+ if err := validateOAPage(1, 0); err == nil {
+ t.Fatal("zero limit was accepted")
+ }
+ if err := validateOAPage(1, 101); err == nil {
+ t.Fatal("oversized limit was accepted")
+ }
+}
+
+func TestCrossPlatformCoverageOAOutputBranches(t *testing.T) {
+ legacyPage := shortcut.Shortcut{
+ Service: "oa", Command: "+coverage-legacy-page", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead,
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ return outputOAPage(rt, "items", []map[string]any{{"id": "1"}}, oaPageEvidence{Known: true, HasMore: true, Next: "2"})
+ },
+ }
+ _, err := runOACoverage(t, legacyPage, &oaCoverageCaller{responses: map[string][]string{}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ unknownPage := legacyPage
+ unknownPage.Command = "+coverage-unknown-page"
+ unknownPage.Execute = func(rt *shortcut.RuntimeContext) error {
+ return outputOAPage(rt, "items", nil, oaPageEvidence{})
+ }
+ if _, err := runOACoverage(t, unknownPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil {
+ t.Fatal("unknown pagination was accepted")
+ }
+
+ legacyComplete := shortcut.Shortcut{
+ Service: "oa", Command: "+coverage-legacy-complete", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead,
+ Execute: func(rt *shortcut.RuntimeContext) error { return outputOACompleteCollection(rt, "items", nil) },
+ }
+ if _, err := runOACoverage(t, legacyComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil {
+ t.Fatal(err)
+ }
+
+ unifiedComplete := legacyComplete
+ unifiedComplete.Command = "+coverage-unified-complete"
+ unifiedComplete.OutputRollout = output.RolloutUnifiedActive
+ if _, err := runOACoverage(t, unifiedComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil {
+ t.Fatal(err)
+ }
+
+ badUnifiedPage := legacyPage
+ badUnifiedPage.Command = "+coverage-unified-bad-page"
+ badUnifiedPage.OutputRollout = output.RolloutUnifiedActive
+ badUnifiedPage.Execute = func(rt *shortcut.RuntimeContext) error {
+ return outputOAPage(rt, "items", nil, oaPageEvidence{Known: true, HasMore: true})
+ }
+ if _, err := runOACoverage(t, badUnifiedPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil {
+ t.Fatal("unified continuation without token was accepted")
+ }
+}
+
+func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) {
+ expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
+ t.Helper()
+ caller := &oaCoverageCaller{responses: responses}
+ if _, err := runOACoverage(t, declaration, caller, args...); err == nil {
+ t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args)
+ }
+ return caller
+ }
+ expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
+ t.Helper()
+ caller := &oaCoverageCaller{responses: responses}
+ if _, err := runOACoverage(t, declaration, caller, args...); err != nil {
+ t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err)
+ }
+ return caller
+ }
+
+ validPage := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}`
+ for name, args := range map[string][]string{
+ "bad interval": {"--start", "2", "--end", "1"},
+ "bad page": {"--start", "1", "--end", "2", "--page", "bad"},
+ "bad limit": {"--start", "1", "--end", "2", "--limit", "bad"},
+ "zero page": {"--start", "1", "--end", "2", "--page", "0"},
+ } {
+ t.Run("list-pending validate "+name, func(t *testing.T) {
+ caller := expectError(t, ListPending, map[string][]string{}, args...)
+ if len(caller.history) != 0 {
+ t.Fatalf("validation made calls: %v", caller.history)
+ }
+ })
+ }
+ t.Run("list-pending call failure", func(t *testing.T) {
+ expectError(t, ListPending, map[string][]string{"list_pending_approvals": {"__ERROR__"}}, "--start", "1", "--end", "2")
+ })
+ t.Run("list-pending projection failure", func(t *testing.T) {
+ expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}, "--start", "1", "--end", "2")
+ })
+ t.Run("list-pending null result fails closed", func(t *testing.T) {
+ expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}, "--start", "1", "--end", "2")
+ })
+ t.Run("list-pending pagination failure", func(t *testing.T) {
+ expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}, "--start", "1", "--end", "2")
+ })
+ t.Run("list-pending full params", func(t *testing.T) {
+ caller := expectSuccess(t, ListPending, map[string][]string{"list_pending_approvals": {validPage}},
+ "--start", "1", "--end", "2", "--page", "2", "--limit", "3", "--query", "fixture")
+ want := map[string]any{"starTime": 1, "endTime": 2, "pageNum": "2", "pageSize": "3", "query": "fixture"}
+ if !reflect.DeepEqual(caller.arguments[0], want) {
+ t.Fatalf("list pending params=%#v want=%#v", caller.arguments[0], want)
+ }
+ })
+
+ for name, args := range map[string][]string{
+ "negative cursor": {"--cursor", "-1"},
+ "zero limit": {"--limit", "0"},
+ "oversized limit": {"--limit", "101"},
+ } {
+ t.Run("list-forms validate "+name, func(t *testing.T) {
+ expectError(t, ListForms, map[string][]string{}, args...)
+ })
+ }
+ t.Run("list-forms call failure", func(t *testing.T) {
+ expectError(t, ListForms, map[string][]string{"list_user_visible_process": {"__ERROR__"}})
+ })
+ t.Run("list-forms projection failure", func(t *testing.T) {
+ expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":false}}`}})
+ })
+ t.Run("list-forms null result fails closed", func(t *testing.T) {
+ expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":null}`}})
+ })
+ t.Run("list-forms pagination failure", func(t *testing.T) {
+ expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"processCodeList":[]}}`}})
+ })
+ t.Run("list-forms success", func(t *testing.T) {
+ expectSuccess(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":true,"nextCursor":2,"processCodeList":[{"processCode":"p","processName":"fixture"}]}}`}}, "--cursor", "1", "--limit", "2")
+ })
+
+ t.Run("search empty query", func(t *testing.T) {
+ caller := expectError(t, SearchForms, map[string][]string{}, "--query", " ")
+ if len(caller.history) != 0 {
+ t.Fatalf("blank query made calls: %v", caller.history)
+ }
+ })
+ t.Run("search call failure", func(t *testing.T) {
+ expectError(t, SearchForms, map[string][]string{"search_form": {"__ERROR__"}}, "--query", "fixture")
+ })
+ t.Run("search projection failure", func(t *testing.T) {
+ expectError(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":{}}`}}, "--query", "fixture")
+ })
+ t.Run("search known nonempty", func(t *testing.T) {
+ caller := expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", " fixture ")
+ if got := caller.arguments[0]["query"]; got != "fixture" {
+ t.Fatalf("normalized query=%#v, want fixture", got)
+ }
+ })
+ t.Run("search legitimate empty", func(t *testing.T) {
+ expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[]}`}}, "--query", "guaranteed-zero-fixture")
+ })
+
+ for _, declaration := range []shortcut.Shortcut{ListExecuted, ListSubmitted, ListCc} {
+ declaration := declaration
+ tool := map[string]string{"+list-executed": "get_done_tasks", "+list-submitted": "get_submitted_instances", "+list-cc": "get_noticed_instances"}[declaration.Command]
+ t.Run(declaration.Command+" invalid page", func(t *testing.T) {
+ expectError(t, declaration, map[string][]string{}, "--page", "bad")
+ })
+ t.Run(declaration.Command+" invalid limit", func(t *testing.T) {
+ expectError(t, declaration, map[string][]string{}, "--limit", "bad")
+ })
+ t.Run(declaration.Command+" call failure", func(t *testing.T) {
+ expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}})
+ })
+ t.Run(declaration.Command+" full params", func(t *testing.T) {
+ caller := expectSuccess(t, declaration, map[string][]string{tool: {validPage}}, "--page", "2", "--limit", "3", "--query", " fixture ")
+ want := map[string]any{"pageNumber": "2", "pageSize": "3", "query": "fixture"}
+ if !reflect.DeepEqual(caller.arguments[0], want) {
+ t.Fatalf("params=%#v want=%#v", caller.arguments[0], want)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageOACompatibilityReadBranches(t *testing.T) {
+ expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
+ t.Helper()
+ caller := &oaCoverageCaller{responses: responses}
+ if _, err := runOACoverage(t, declaration, caller, args...); err == nil {
+ t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args)
+ }
+ return caller
+ }
+ expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller {
+ t.Helper()
+ caller := &oaCoverageCaller{responses: responses}
+ if _, err := runOACoverage(t, declaration, caller, args...); err != nil {
+ t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err)
+ }
+ return caller
+ }
+ terminal := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}`
+
+ for _, declaration := range []shortcut.Shortcut{PendingApprovals, DoneApprovals} {
+ declaration := declaration
+ tool := map[string]string{"+pending": "list_pending_approvals", "+done-approvals": "get_done_tasks"}[declaration.Command]
+ t.Run(declaration.Command+" invalid limit", func(t *testing.T) {
+ caller := expectError(t, declaration, map[string][]string{}, "--limit", "0")
+ if len(caller.history) != 0 {
+ t.Fatalf("validation made calls: %v", caller.history)
+ }
+ })
+ t.Run(declaration.Command+" call failure", func(t *testing.T) {
+ expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}})
+ })
+ t.Run(declaration.Command+" projection failure", func(t *testing.T) {
+ expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":false}}`}})
+ })
+ t.Run(declaration.Command+" null result fails closed", func(t *testing.T) {
+ expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":null}`}})
+ })
+ t.Run(declaration.Command+" pagination failure", func(t *testing.T) {
+ expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"values":[]}}`}})
+ })
+ t.Run(declaration.Command+" refuses incomplete first page", func(t *testing.T) {
+ expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":true,"values":[]}}`}})
+ })
+ t.Run(declaration.Command+" default limit", func(t *testing.T) {
+ expectSuccess(t, declaration, map[string][]string{tool: {terminal}})
+ })
+ t.Run(declaration.Command+" explicit limit", func(t *testing.T) {
+ caller := expectSuccess(t, declaration, map[string][]string{tool: {terminal}}, "--limit", "3")
+ if caller.arguments[0]["pageSize"] != float64(3) {
+ t.Fatalf("explicit limit params=%#v", caller.arguments[0])
+ }
+ })
+ }
+
+ for name, args := range map[string][]string{
+ "bad page": {"--page", "0"},
+ "bad limit": {"--limit", "101"},
+ } {
+ t.Run("my-initiated validate "+name, func(t *testing.T) {
+ expectError(t, MyInitiated, map[string][]string{}, args...)
+ })
+ }
+ t.Run("my-initiated call failure", func(t *testing.T) {
+ expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {"__ERROR__"}})
+ })
+ t.Run("my-initiated projection failure", func(t *testing.T) {
+ expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":false}}`}})
+ })
+ t.Run("my-initiated null result fails closed", func(t *testing.T) {
+ expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":null}`}})
+ })
+ t.Run("my-initiated pagination failure", func(t *testing.T) {
+ expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"values":[]}}`}})
+ })
+ t.Run("my-initiated query and continuation", func(t *testing.T) {
+ caller := expectSuccess(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}, "--query", "fixture", "--page", "2", "--limit", "3")
+ want := map[string]any{"pageNumber": float64(2), "pageSize": float64(3), "query": "fixture"}
+ if !reflect.DeepEqual(caller.arguments[0], want) {
+ t.Fatalf("my initiated params=%#v want=%#v", caller.arguments[0], want)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageOARequiredFlagValidationClosures(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ flagName string
+ validate func(*shortcut.RuntimeContext) error
+ }{
+ {name: "search forms", flagName: "query", validate: SearchForms.Validate},
+ {name: "approve", flagName: "keyword", validate: Approve.Validate},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ declaration := shortcut.Shortcut{
+ Service: "oa", Command: "+coverage-" + strings.ReplaceAll(tc.name, " ", "-"), Product: "oa", Description: "fixture", Risk: shortcut.RiskRead,
+ Flags: []shortcut.Flag{{Name: tc.flagName, Type: shortcut.FlagString}},
+ Execute: tc.validate,
+ }
+ caller := &oaCoverageCaller{responses: map[string][]string{}}
+ if _, err := runOACoverage(t, declaration, caller); err == nil {
+ t.Fatal("empty required semantic value was accepted")
+ }
+ if len(caller.history) != 0 {
+ t.Fatalf("validation made calls: %v", caller.history)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageOAApproveFailureLedger(t *testing.T) {
+ pending := func(values string, hasMore bool) string {
+ return `{"success":true,"result":{"hasMore":` + map[bool]string{true: "true", false: "false"}[hasMore] + `,"values":` + values + `}}`
+ }
+ tasks := func(values string) string {
+ return `{"success":true,"result":{"taskIdList":` + values + `}}`
+ }
+ validPending := pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, false)
+ validTasks := tasks(`[{"taskId":1}]`)
+
+ cases := []struct {
+ name string
+ responses map[string][]string
+ }{
+ {name: "pending call failure", responses: map[string][]string{"list_pending_approvals": {"__ERROR__"}}},
+ {name: "pending projection failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}},
+ {name: "pending null result", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}},
+ {name: "pending pagination failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}},
+ {name: "pending is incomplete", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, true)}}},
+ {name: "zero matches", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"other"}]`, false)}}},
+ {name: "task call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {"__ERROR__"}}},
+ {name: "task projection failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {`{"success":true,"result":{}}`}}},
+ {name: "task count is not one", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[]`)}}},
+ {name: "task identity is not numeric", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[{"taskId":"not-numeric"}]`)}}},
+ {name: "write call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {"__ERROR__"}}},
+ {name: "write business failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":false}`}}},
+ {name: "write missing success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"result":{"accepted":true}}`}}},
+ {name: "write null success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":null}`}}},
+ {name: "readback call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, "__ERROR__"}, "approve_processInstance": {`{"success":true}`}}},
+ {name: "readback malformed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, `{"success":true,"result":{}}`}, "approve_processInstance": {`{"success":true}`}}},
+ {name: "write not observed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, validTasks}, "approve_processInstance": {`{"success":true}`}}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &oaCoverageCaller{responses: tc.responses}
+ if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--yes"); err == nil {
+ t.Fatalf("failure scenario succeeded; calls=%v", caller.history)
+ }
+ })
+ }
+
+ t.Run("empty keyword validates before calls", func(t *testing.T) {
+ caller := &oaCoverageCaller{responses: map[string][]string{}}
+ if err := runOAConfirmedCoverage(t, caller, "--keyword", " ", "--yes"); err == nil {
+ t.Fatal("blank keyword succeeded")
+ }
+ if len(caller.history) != 0 {
+ t.Fatalf("blank keyword made calls: %v", caller.history)
+ }
+ })
+
+ t.Run("comment is sent only after confirmation", func(t *testing.T) {
+ caller := &oaCoverageCaller{responses: map[string][]string{
+ "list_pending_approvals": {validPending},
+ "list_pending_tasks": {validTasks, tasks(`[]`)},
+ "approve_processInstance": {`{"success":true}`},
+ }}
+ if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--comment", "approved", "--yes"); err != nil {
+ t.Fatal(err)
+ }
+ if caller.arguments[2]["remark"] != "approved" {
+ t.Fatalf("write args=%#v", caller.arguments[2])
+ }
+ })
+}
diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go
new file mode 100644
index 00000000..0bacde28
--- /dev/null
+++ b/internal/shortcut/oa/smart.go
@@ -0,0 +1,272 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package oa
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, params map[string]any) error {
+ operation := "oa/" + tool
+ data, err := rt.CallMCPData("oa", tool, params)
+ if err != nil {
+ return err
+ }
+ instances, err := oaProjectInstances(data, operation, "result.values")
+ if err != nil {
+ return err
+ }
+ result, _ := data["result"].(map[string]any)
+ page, err := oaHasMorePage(result, operation, 1)
+ if err != nil {
+ return err
+ }
+ if page.HasMore {
+ return oaResponseError(operation, "unavailable_continuation", "兼容 Shortcut 没有页码参数且结果仍有后续页;请改用对应可分页命令")
+ }
+ return outputOACompleteCollection(rt, collection, instances)
+}
+
+var PendingApprovals = shortcut.Shortcut{
+ Service: "oa", Command: "+pending", Product: "oa",
+ Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)",
+ Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时不进入 Agent 公开发现。",
+ Risk: shortcut.RiskRead,
+ Safety: oaReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: oaContract(
+ "+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)",
+ "兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。",
+ true,
+ oaCollectionResult("pending", "严格验证的待审批摘要"), nil,
+ []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +pending --limit 10",
+ ),
+ Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}},
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}},
+ Tips: []string{`dws oa +pending --limit 10`},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) {
+ return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
+ }
+ return nil
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ now := time.Now()
+ params := map[string]any{"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli())}
+ if rt.Changed("limit") {
+ params["pageSize"] = float64(rt.Int("limit"))
+ }
+ return oaFirstPageOnly(rt, "list_pending_approvals", "pending", params)
+ },
+}
+
+var DoneApprovals = shortcut.Shortcut{
+ Service: "oa", Command: "+done-approvals", Product: "oa",
+ Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表",
+ Intent: "兼容入口:读取我已处理审批的首个完整页;新调用优先使用可分页的 +list-executed。",
+ Risk: shortcut.RiskRead,
+ Safety: oaReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: oaContract(
+ "+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表",
+ "兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。",
+ true,
+ oaCollectionResult("done", "严格验证的已处理审批摘要"), nil,
+ []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +done-approvals --limit 10",
+ ),
+ Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}},
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}},
+ Tips: []string{`dws oa +done-approvals --limit 10`},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) {
+ return apperrors.NewValidation("--limit 必须在 1 到 100 之间")
+ }
+ return nil
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ limit := 20
+ if rt.Changed("limit") {
+ limit = rt.Int("limit")
+ }
+ return oaFirstPageOnly(rt, "get_done_tasks", "done", map[string]any{"pageNumber": float64(1), "pageSize": float64(limit)})
+ },
+}
+
+var MyInitiated = shortcut.Shortcut{
+ Service: "oa", Command: "+my-initiated", Product: "oa",
+ Description: "列出我发起(提交)的审批单据",
+ Intent: "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。",
+ Risk: shortcut.RiskRead,
+ Safety: oaReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: oaContract(
+ "+my-initiated", "列出我发起(提交)的审批单据",
+ "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。",
+ true,
+ oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"),
+ []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}},
+ "dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销",
+ ),
+ Flags: []shortcut.Flag{
+ {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)"},
+ {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1);--page 必须大于 0", Default: "1"},
+ {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20);--limit 必须在 1-100", Default: "20"},
+ },
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
+ },
+ Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`},
+ Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ const operation = "oa/get_submitted_instances"
+ params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))}
+ if query := rt.Str("query"); query != "" {
+ params["query"] = query
+ }
+ data, err := rt.CallMCPData("oa", "get_submitted_instances", params)
+ if err != nil {
+ return err
+ }
+ items, err := oaProjectInstances(data, operation, "result.values")
+ if err != nil {
+ return err
+ }
+ result, _ := data["result"].(map[string]any)
+ page, err := oaHasMorePage(result, operation, rt.Int("page"))
+ if err != nil {
+ return err
+ }
+ return outputOAPage(rt, "initiated", items, page)
+ },
+}
+
+type oaApprovalMatch struct {
+ id string
+ title string
+}
+
+func oaMatchApprovals(items []map[string]any, keyword string) []oaApprovalMatch {
+ needle := strings.ToLower(strings.TrimSpace(keyword))
+ matches := make([]oaApprovalMatch, 0)
+ for _, item := range items {
+ id := oaIdentity(item, "processInstanceId")
+ title := oaFirstString(item, "title", "processInstanceTitle")
+ businessID := oaIdentity(item, "businessId")
+ if strings.Contains(strings.ToLower(title), needle) || strings.Contains(strings.ToLower(businessID), needle) || strings.EqualFold(id, needle) {
+ matches = append(matches, oaApprovalMatch{id: id, title: title})
+ }
+ }
+ return matches
+}
+
+var Approve = shortcut.Shortcut{
+ Service: "oa", Command: "+approve-by", Product: "oa",
+ Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)",
+ Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前不进入 Agent 公开发现。",
+ Risk: shortcut.RiskHighWrite,
+ Safety: oaWriteSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: oaContract(
+ "+approve-by", "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)",
+ "仅在用户明确确认同意、关键词唯一定位实例且任务读回可验证时使用;任何歧义、分页不完整或读回失败都会阻止成功。",
+ true,
+ oaWriteResult("同意审批并通过精确任务读回验证"), nil,
+ []contract.ParamDecl{{Name: "keyword", Property: "query"}, {Name: "comment", Property: "remark"}},
+ "dws oa +approve-by --keyword 报销",
+ ),
+ Flags: []shortcut.Flag{
+ {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true},
+ {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)"},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword"}, Description: "--keyword 去除空白后不能为空,且必须唯一匹配完整待办集合中的一条实例"}},
+ Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ keyword := strings.TrimSpace(rt.Str("keyword"))
+ if keyword == "" {
+ return apperrors.NewValidation("--keyword 不能为空")
+ }
+ return nil
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ keyword := strings.TrimSpace(rt.Str("keyword"))
+ now := time.Now()
+ pending, err := rt.CallMCPData("oa", "list_pending_approvals", map[string]any{
+ "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli()), "query": keyword,
+ })
+ if err != nil {
+ return err
+ }
+ items, err := oaProjectInstances(pending, "oa/list_pending_approvals", "result.values")
+ if err != nil {
+ return err
+ }
+ result, _ := pending["result"].(map[string]any)
+ page, err := oaHasMorePage(result, "oa/list_pending_approvals", 1)
+ if err != nil {
+ return err
+ }
+ if page.HasMore {
+ return oaResponseError("oa/list_pending_approvals", "ambiguous_incomplete_search", "待办搜索仍有后续页,无法证明关键词唯一")
+ }
+ matches := oaMatchApprovals(items, keyword)
+ if len(matches) != 1 {
+ return apperrors.NewValidation(fmt.Sprintf("关键词必须唯一匹配一条待审批实例,当前匹配 %d 条", len(matches)))
+ }
+ instanceID := matches[0].id
+ tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID})
+ if err != nil {
+ return err
+ }
+ tasks, err := oaProjectTasks(tasksData, "oa/list_pending_tasks")
+ if err != nil {
+ return err
+ }
+ if len(tasks) != 1 {
+ return apperrors.NewValidation(fmt.Sprintf("审批实例必须唯一对应一条待处理任务,当前为 %d 条", len(tasks)))
+ }
+ taskID := tasks[0]["taskId"].(string)
+ numericTaskID, err := strconv.ParseFloat(taskID, 64)
+ if err != nil {
+ return oaResponseError("oa/list_pending_tasks", "malformed_task_identity", "taskId 不是可写入审批接口的数字")
+ }
+ writeArgs := map[string]any{"processInstanceId": instanceID, "taskId": numericTaskID}
+ if comment := rt.Str("comment"); comment != "" {
+ writeArgs["remark"] = comment
+ }
+ receipt, err := rt.CallMCPWriteDataStrict("oa", "approve_processInstance", writeArgs)
+ if err != nil {
+ return err
+ }
+ if err := oaRequireSuccess(receipt, "oa/approve_processInstance"); err != nil {
+ return err
+ }
+ readback, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID})
+ if err != nil {
+ return oaPostWriteError("oa/list_pending_tasks", "readback_failed", "审批写入后无法读取任务状态;远端效果未知")
+ }
+ remaining, err := oaProjectTasks(readback, "oa/list_pending_tasks")
+ if err != nil {
+ return oaPostWriteError("oa/list_pending_tasks", "readback_malformed", "审批写入后的任务读回无法验证;远端效果未知")
+ }
+ for _, task := range remaining {
+ if task["taskId"] == taskID {
+ return oaPostWriteError("oa/list_pending_tasks", "write_not_observed", "审批任务写后读回仍处于待处理集合")
+ }
+ }
+ return rt.Output(map[string]any{"processInstanceId": instanceID, "taskId": taskID, "verified": true})
+ },
+}
+
+func init() {
+ shortcut.Register(PendingApprovals, DoneApprovals, Approve, MyInitiated)
+}
diff --git a/internal/shortcut/public_catalog_consistency_test.go b/internal/shortcut/public_catalog_consistency_test.go
new file mode 100644
index 00000000..afd3a2a1
--- /dev/null
+++ b/internal/shortcut/public_catalog_consistency_test.go
@@ -0,0 +1,59 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+
+package shortcut
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T) {
+ _, testFile, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("locate public catalog test file")
+ }
+ catalogPath := filepath.Join(filepath.Dir(testFile), "..", "..", "docs", "shortcut-public-catalog.json")
+ raw, err := os.ReadFile(catalogPath)
+ if err != nil {
+ t.Fatalf("read public catalog: %v", err)
+ }
+ var catalog struct {
+ Count int `json:"count"`
+ Results []struct {
+ Service string `json:"service"`
+ Command string `json:"command"`
+ } `json:"results"`
+ }
+ if err := json.Unmarshal(raw, &catalog); err != nil {
+ t.Fatalf("decode public catalog: %v", err)
+ }
+ if catalog.Count != len(catalog.Results) {
+ t.Fatalf("public catalog count = %d, results = %d", catalog.Count, len(catalog.Results))
+ }
+ if catalog.Count != len(publicShortcutCatalog) {
+ t.Fatalf("public catalog count = %d, generated Go keys = %d", catalog.Count, len(publicShortcutCatalog))
+ }
+ seen := make(map[string]struct{}, len(catalog.Results))
+ for i, row := range catalog.Results {
+ key := publicCatalogKey(row.Service, row.Command)
+ if row.Service == "" || row.Command == "" {
+ t.Fatalf("public catalog result %d has an empty identity", i)
+ }
+ if _, duplicate := seen[key]; duplicate {
+ t.Fatalf("public catalog result %d duplicates %q", i, key)
+ }
+ seen[key] = struct{}{}
+ if _, generated := publicShortcutCatalog[key]; !generated {
+ t.Fatalf("public catalog result %d is missing from generated Go keys: %q", i, key)
+ }
+ }
+ for key := range publicShortcutCatalog {
+ if _, documented := seen[key]; !documented {
+ t.Fatalf("generated Go key is missing from public catalog JSON: %q", key)
+ }
+ }
+}
diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go
index 0aba731a..1d81b103 100644
--- a/internal/shortcut/public_catalog_generated.go
+++ b/internal/shortcut/public_catalog_generated.go
@@ -101,23 +101,12 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"aitable\u0000+workflow-list": {},
"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": {},
@@ -276,10 +265,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"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+access-change": {},
"doc\u0000+access-grant": {},
"doc\u0000+access-revoke": {},
@@ -353,15 +339,13 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"drive\u0000+version-get": {},
"drive\u0000+version-history": {},
"drive\u0000+version-revert": {},
- "mail\u0000+contact-list": {},
"mail\u0000+find-mail-user": {},
"mail\u0000+folder-list": {},
- "mail\u0000+recent-mail": {},
+ "mail\u0000+message": {},
+ "mail\u0000+messages": {},
"mail\u0000+search-mail": {},
- "mail\u0000+tag-list": {},
- "mail\u0000+template-list": {},
- "mail\u0000+thread-list": {},
- "mail\u0000+unread-mail": {},
+ "mail\u0000+thread": {},
+ "mail\u0000+triage": {},
"mail\u0000+user-search": {},
"minutes\u0000+action-items": {},
"minutes\u0000+apply-permission": {},
@@ -390,15 +374,11 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"minutes\u0000+update": {},
"minutes\u0000+upload": {},
"minutes\u0000+upload-and-analyze": {},
- "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": {},
+ "report\u0000+report-latest": {},
+ "report\u0000+template-search": {},
"sheet\u0000+list-sheets": {},
"sheet\u0000+read": {},
"todo\u0000+assign": {},
diff --git a/internal/shortcut/report/common.go b/internal/shortcut/report/common.go
new file mode 100644
index 00000000..2bac4e58
--- /dev/null
+++ b/internal/shortcut/report/common.go
@@ -0,0 +1,477 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package report
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+const reportCompositeReason = "Reviewed Report Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable report/template identities, truthful pagination, local filtering, output projection, and multi-step detail verification."
+
+type reportPageEvidence struct {
+ HasMore bool
+ Next string
+}
+
+func reportReadSafety() contract.SafetySpec {
+ return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
+}
+
+func reportCollectionResult(collection, description string) *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(fmt.Sprintf(
+ `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前页严格验证的日志数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具有稳定 reportId 的日志摘要","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"creatorUserId":{"type":"string","description":"日志创建人稳定用户 ID"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"}},"required":["reportId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"服务端分页证据是否证明当前查询已结束"}},"required":["count",%q,"complete"],"additionalProperties":false}`,
+ description, collection, description, collection,
+ )),
+ SensitivePaths: []string{collection + ".templateName", collection + ".creatorName", collection + ".creatorUserId"},
+ }
+}
+
+func reportTemplateSearchResult() *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的日志模板搜索结果","properties":{"count":{"type":"integer","description":"匹配模板数量"},"templates":{"type":"array","description":"名称匹配且具有稳定 ID 的模板","items":{"type":"object","description":"一条日志模板摘要","properties":{"templateId":{"type":"string","description":"稳定模板 ID"},"name":{"type":"string","description":"模板名称"},"lastModifiedTime":{"type":"integer","description":"模板最后修改时间毫秒值"}},"required":["templateId","name"],"additionalProperties":false}}},"required":["count","templates"],"additionalProperties":false}`),
+ SensitivePaths: []string{"templates.name"},
+ }
+}
+
+func reportLatestResult() *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的最近日志详情","properties":{"report":{"type":"object","description":"稳定身份匹配的日志详情","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"title":{"type":"string","description":"日志标题"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"},"fields":{"type":"array","description":"严格验证的日志字段","items":{"type":"object","description":"日志字段值","properties":{"key":{"type":"string","description":"字段名称"},"value":{"type":"string","description":"字段内容"},"sort":{"type":"integer","description":"字段排序值"},"type":{"type":"integer","description":"字段类型码"}},"required":["key","value","sort","type"],"additionalProperties":false}}},"required":["reportId","fields"],"additionalProperties":false}},"required":["report"],"additionalProperties":false}`),
+ SensitivePaths: []string{"report.title", "report.templateName", "report.creatorName", "report.fields.value"},
+ }
+}
+
+func reportPagination() *contract.PaginationSpec {
+ return &contract.PaginationSpec{
+ Kind: contract.PaginationKindCursor,
+ CursorParameter: "cursor",
+ MetaPath: contract.PaginationMetaPath,
+ EndpointExhaustedPath: contract.PaginationExhaustedPath,
+ NextTokenPath: contract.PaginationNextTokenPath,
+ }
+}
+
+func reportContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
+ name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
+ cliPath := "report " + command
+ return corecmd.ContractDecl{
+ Description: description,
+ Result: result,
+ Pagination: pagination,
+ Parameters: params,
+ Identity: contract.ToolIdentitySpec{
+ ProductID: "report", Name: name, CanonicalPath: "report." + name,
+ CLIPath: cliPath, PrimaryCLIPath: cliPath,
+ },
+ Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: reportCompositeReason},
+ Selection: contract.SelectionSpec{
+ AgentSummary: description,
+ UseWhen: []string{intent},
+ AvoidWhen: []string{"钉钉在线文档使用 doc,待办使用 todo,OA 审批使用 oa;缺少稳定日志或模板身份时不要猜测"},
+ Examples: examples,
+ },
+ }
+}
+
+func reportResponseError(operation, reason, message string) error {
+ return apperrors.NewAPI(message,
+ apperrors.WithOperation(operation),
+ apperrors.WithOrigin("mcp"),
+ apperrors.WithFailureStage("response_validation"),
+ apperrors.WithRetryable(false),
+ apperrors.WithReason(reason),
+ )
+}
+
+func reportRequireSuccess(data map[string]any, operation string) error {
+ if len(data) == 0 {
+ return reportResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 Report 调用成功或结果确实为空")
+ }
+ raw, present := data["success"]
+ if !present {
+ return reportResponseError(operation, "missing_success", "Report 响应缺少 success 业务状态")
+ }
+ success, ok := raw.(bool)
+ if !ok {
+ return reportResponseError(operation, "invalid_success_type", fmt.Sprintf("Report success 应为布尔值,实际为 %T", raw))
+ }
+ if success {
+ return nil
+ }
+ message := "Report 服务明确返回失败"
+ for _, key := range []string{"errorMsg", "errorMessage", "message"} {
+ if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
+ message = strings.TrimSpace(value)
+ break
+ }
+ }
+ return reportResponseError(operation, "remote_failure", message)
+}
+
+func reportObjectCollection(value any, operation, path string) ([]map[string]any, error) {
+ raw, ok := value.([]any)
+ if !ok {
+ return nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report %s 应为数组,实际为 %T", path, value))
+ }
+ items := make([]map[string]any, 0, len(raw))
+ for index, item := range raw {
+ object, ok := item.(map[string]any)
+ if !ok || len(object) == 0 {
+ return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report %s[%d] 不是非空对象", path, index))
+ }
+ items = append(items, object)
+ }
+ return items, nil
+}
+
+func reportListCollection(data map[string]any, operation string) ([]map[string]any, map[string]any, error) {
+ if err := reportRequireSuccess(data, operation); err != nil {
+ return nil, nil, err
+ }
+ result, present := data["result"]
+ if !present {
+ return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result 日志集合")
+ }
+ if _, ok := result.([]any); ok {
+ items, err := reportObjectCollection(result, operation, "result")
+ return items, data, err
+ }
+ container, ok := result.(map[string]any)
+ if !ok || container == nil {
+ return nil, nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report result 应为数组或带 report_list 的对象,实际为 %T", result))
+ }
+ raw, present := container["report_list"]
+ if !present {
+ return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result.report_list 数组")
+ }
+ items, err := reportObjectCollection(raw, operation, "result.report_list")
+ return items, container, err
+}
+
+func reportRequiredString(item map[string]any, operation string, index int, keys ...string) (string, error) {
+ var selected string
+ for _, key := range keys {
+ raw, present := item[key]
+ if !present || raw == nil {
+ continue
+ }
+ value, ok := raw.(string)
+ value = strings.TrimSpace(value)
+ if !ok || value == "" {
+ return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 必须是非空字符串", index, key))
+ }
+ if selected != "" && selected != value {
+ return "", reportResponseError(operation, "conflicting_item_identity", fmt.Sprintf("Report 结果第 %d 项的身份字段冲突", index))
+ }
+ selected = value
+ }
+ if selected == "" {
+ return "", reportResponseError(operation, "missing_item_identity", fmt.Sprintf("Report 结果第 %d 项缺少稳定身份", index))
+ }
+ return selected, nil
+}
+
+func reportOptionalString(item map[string]any, operation string, index int, keys ...string) (string, error) {
+ for _, key := range keys {
+ raw, present := item[key]
+ if !present || raw == nil {
+ continue
+ }
+ value, ok := raw.(string)
+ if !ok {
+ return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为字符串,实际为 %T", index, key, raw))
+ }
+ return strings.TrimSpace(value), nil
+ }
+ return "", nil
+}
+
+func reportInteger(value any) (int64, bool) {
+ switch typed := value.(type) {
+ case float64:
+ if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 {
+ return 0, false
+ }
+ return int64(typed), true
+ case int:
+ return int64(typed), true
+ case int64:
+ return typed, true
+ case json.Number:
+ parsed, err := typed.Int64()
+ return parsed, err == nil
+ default:
+ return 0, false
+ }
+}
+
+func reportOptionalInteger(item map[string]any, operation string, index int, keys ...string) (int64, bool, error) {
+ for _, key := range keys {
+ raw, present := item[key]
+ if !present || raw == nil {
+ continue
+ }
+ value, ok := reportInteger(raw)
+ if !ok {
+ return 0, false, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为整数", index, key))
+ }
+ return value, true, nil
+ }
+ return 0, false, nil
+}
+
+func reportProjectEntries(data map[string]any, operation string) ([]map[string]any, reportPageEvidence, error) {
+ items, pageContainer, err := reportListCollection(data, operation)
+ if err != nil {
+ return nil, reportPageEvidence{}, err
+ }
+ entries := make([]map[string]any, 0, len(items))
+ seen := make(map[string]struct{}, len(items))
+ for index, item := range items {
+ id, identityErr := reportRequiredString(item, operation, index, "reportId", "report_id", "report_Id")
+ if identityErr != nil {
+ return nil, reportPageEvidence{}, identityErr
+ }
+ if _, duplicate := seen[id]; duplicate {
+ return nil, reportPageEvidence{}, reportResponseError(operation, "duplicate_item_identity", "当前 Report 页包含重复 reportId")
+ }
+ seen[id] = struct{}{}
+ projected := map[string]any{"reportId": id}
+ for target, aliases := range map[string][]string{
+ "templateName": {"templateName", "template_name", "reportTemplateName", "report_template_name"},
+ "creatorName": {"creatorName", "creator_name", "creatorUserName", "senderName", "authorName"},
+ "creatorUserId": {"creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"},
+ } {
+ value, valueErr := reportOptionalString(item, operation, index, aliases...)
+ if valueErr != nil {
+ return nil, reportPageEvidence{}, valueErr
+ }
+ if value != "" {
+ projected[target] = value
+ }
+ }
+ for target, aliases := range map[string][]string{
+ "createTime": {"createTime", "create_time", "gmtCreate", "sendTime"},
+ "modifiedTime": {"modifiedTime", "modified_time", "gmtModified", "modifyTime"},
+ } {
+ value, present, valueErr := reportOptionalInteger(item, operation, index, aliases...)
+ if valueErr != nil {
+ return nil, reportPageEvidence{}, valueErr
+ }
+ if present {
+ projected[target] = value
+ }
+ }
+ entries = append(entries, projected)
+ }
+ page, err := reportPage(data, pageContainer, operation, len(entries))
+ if err != nil {
+ return nil, reportPageEvidence{}, err
+ }
+ return entries, page, nil
+}
+
+func reportPage(data, result map[string]any, operation string, count int) (reportPageEvidence, error) {
+ raw, present := data["hasMore"]
+ if nested, exists := result["hasMore"]; exists {
+ if present && !sameReportScalar(raw, nested) {
+ return reportPageEvidence{}, reportResponseError(operation, "conflicting_pagination", "Report 顶层与 result.hasMore 冲突")
+ }
+ raw, present = nested, true
+ }
+ if !present {
+ return reportPageEvidence{}, reportResponseError(operation, "missing_pagination", "Report 列表缺少 hasMore,不能把当前页宣称为完整结果")
+ }
+ hasMore, ok := raw.(bool)
+ if !ok {
+ return reportPageEvidence{}, reportResponseError(operation, "malformed_pagination", "Report hasMore 应为布尔值")
+ }
+ page := reportPageEvidence{HasMore: hasMore}
+ next, nextPresent, err := reportNextCursor(data, result, operation)
+ if err != nil {
+ return reportPageEvidence{}, err
+ }
+ if !hasMore {
+ // The Report service echoes an integer cursor on terminal pages. It is a
+ // page receipt, not a continuation: hasMore=false is authoritative and
+ // the unified projection intentionally omits next_token. Parsing above
+ // still rejects wrong types and conflicting cursor fields.
+ return page, nil
+ }
+ if count == 0 {
+ return reportPageEvidence{}, reportResponseError(operation, "empty_page_with_continuation", "Report 空页仍声明 hasMore=true")
+ }
+ if !nextPresent || next <= 0 {
+ return reportPageEvidence{}, reportResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 continuation cursor")
+ }
+ page.Next = strconv.FormatInt(next, 10)
+ return page, nil
+}
+
+func reportNextCursor(data, result map[string]any, operation string) (int64, bool, error) {
+ var selected int64
+ found := false
+ for _, object := range []map[string]any{data, result} {
+ for _, key := range []string{"nextCursor", "cursor"} {
+ raw, present := object[key]
+ if !present || raw == nil {
+ continue
+ }
+ value, ok := reportInteger(raw)
+ if !ok {
+ return 0, false, reportResponseError(operation, "malformed_pagination", fmt.Sprintf("Report %s 应为整数", key))
+ }
+ if found && selected != value {
+ return 0, false, reportResponseError(operation, "conflicting_pagination", "Report continuation cursor 字段冲突")
+ }
+ selected, found = value, true
+ }
+ }
+ return selected, found, nil
+}
+
+func sameReportScalar(left, right any) bool {
+ leftJSON, leftErr := json.Marshal(left)
+ rightJSON, rightErr := json.Marshal(right)
+ return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON)
+}
+
+func reportProjectTemplates(data map[string]any, operation string) ([]map[string]any, error) {
+ if err := reportRequireSuccess(data, operation); err != nil {
+ return nil, err
+ }
+ raw, present := data["items"]
+ if !present {
+ return nil, reportResponseError(operation, "missing_collection", "Report 模板响应缺少 items 数组")
+ }
+ items, err := reportObjectCollection(raw, operation, "items")
+ if err != nil {
+ return nil, err
+ }
+ templates := make([]map[string]any, 0, len(items))
+ seen := make(map[string]struct{}, len(items))
+ for index, item := range items {
+ id, identityErr := reportRequiredString(item, operation, index, "report_template_id")
+ if identityErr != nil {
+ return nil, identityErr
+ }
+ name, nameErr := reportRequiredString(item, operation, index, "report_template_name")
+ if nameErr != nil {
+ return nil, nameErr
+ }
+ if _, duplicate := seen[id]; duplicate {
+ return nil, reportResponseError(operation, "duplicate_item_identity", "Report 模板响应包含重复 templateId")
+ }
+ seen[id] = struct{}{}
+ projected := map[string]any{"templateId": id, "name": name}
+ modified, present, modifiedErr := reportOptionalInteger(item, operation, index, "last_modified_time")
+ if modifiedErr != nil {
+ return nil, modifiedErr
+ }
+ if present {
+ projected["lastModifiedTime"] = modified
+ }
+ templates = append(templates, projected)
+ }
+ return templates, nil
+}
+
+func reportProjectDetail(data map[string]any, operation, expectedID string) (map[string]any, error) {
+ if err := reportRequireSuccess(data, operation); err != nil {
+ return nil, err
+ }
+ result, ok := data["result"].(map[string]any)
+ if !ok || len(result) == 0 {
+ return nil, reportResponseError(operation, "missing_result", "Report 详情成功响应缺少非空 result 对象")
+ }
+ id, err := reportRequiredString(result, operation, 0, "report_Id", "reportId", "report_id")
+ if err != nil {
+ return nil, err
+ }
+ if id != expectedID {
+ return nil, reportResponseError(operation, "identity_mismatch", "Report 详情 reportId 与请求目标不一致")
+ }
+ projected := map[string]any{"reportId": id}
+ for target, aliases := range map[string][]string{
+ "title": {"report_name", "reportName", "title"},
+ "templateName": {"report_template_name", "templateName"},
+ "creatorName": {"creatorName", "senderName"},
+ } {
+ value, valueErr := reportOptionalString(result, operation, 0, aliases...)
+ if valueErr != nil {
+ return nil, valueErr
+ }
+ if value != "" {
+ projected[target] = value
+ }
+ }
+ for target, aliases := range map[string][]string{
+ "createTime": {"createTime", "create_time"}, "modifiedTime": {"modifiedTime", "modified_time"},
+ } {
+ value, present, valueErr := reportOptionalInteger(result, operation, 0, aliases...)
+ if valueErr != nil {
+ return nil, valueErr
+ }
+ if present {
+ projected[target] = value
+ }
+ }
+ rawFields, present := result["report_content"]
+ if !present {
+ return nil, reportResponseError(operation, "missing_collection", "Report 详情缺少 result.report_content 数组")
+ }
+ fields, err := reportObjectCollection(rawFields, operation, "result.report_content")
+ if err != nil {
+ return nil, err
+ }
+ projectedFields := make([]map[string]any, 0, len(fields))
+ for index, field := range fields {
+ key, keyErr := reportRequiredString(field, operation, index, "key")
+ if keyErr != nil {
+ return nil, keyErr
+ }
+ rawValue, valuePresent := field["value"]
+ value, valueOK := rawValue.(string)
+ if !valuePresent || !valueOK {
+ return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 value 必须是字符串", index))
+ }
+ sortValue, sortOK := reportInteger(field["sort"])
+ typeValue, typeOK := reportInteger(field["type"])
+ if !sortOK || !typeOK {
+ return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 sort/type 必须是整数", index))
+ }
+ projectedFields = append(projectedFields, map[string]any{"key": key, "value": value, "sort": sortValue, "type": typeValue})
+ }
+ projected["fields"] = projectedFields
+ return projected, nil
+}
+
+func outputReportPage(rt *shortcut.RuntimeContext, collection string, entries []map[string]any, page reportPageEvidence) error {
+ payload := map[string]any{"count": len(entries), collection: entries, "complete": !page.HasMore}
+ if !output.UsesUnifiedResult(rt.Command()) {
+ if page.HasMore {
+ payload["nextCursor"] = page.Next
+ }
+ return rt.Output(payload)
+ }
+ pagination, err := output.NewPagination(!page.HasMore, page.Next)
+ if err != nil {
+ return reportResponseError("report/pagination", "invalid_pagination", err.Error())
+ }
+ meta := &output.Meta{Count: output.NewCount(len(entries)), Pagination: pagination}
+ return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
+}
diff --git a/internal/shortcut/report/latest.go b/internal/shortcut/report/latest.go
new file mode 100644
index 00000000..1d633c5b
--- /dev/null
+++ b/internal/shortcut/report/latest.go
@@ -0,0 +1,130 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package report
+
+import (
+ "strings"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+var ReportLatest = shortcut.Shortcut{
+ Service: "report", Command: "+report-latest", Product: "report",
+ Description: "读取我最近提交的一篇日志详情",
+ Intent: "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。",
+ Risk: shortcut.RiskRead,
+ Safety: reportReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: reportContract(
+ "+report-latest", "读取我最近提交的一篇日志详情",
+ "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。",
+ reportLatestResult(), nil,
+ []contract.ParamDecl{
+ {Name: "keyword", Property: "report_template_name"},
+ {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"},
+ },
+ "dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`,
+ ),
+ Flags: []shortcut.Flag{
+ {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"},
+ {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"},
+ {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"},
+ },
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"},
+ },
+ Tips: []string{"dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if rt.Changed("start") != rt.Changed("end") {
+ return apperrors.NewValidation("--start 与 --end 必须同时提供", apperrors.WithReason("incomplete_creation_range"))
+ }
+ if rt.Changed("start") {
+ _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays)
+ return err
+ }
+ return nil
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ const listOperation = "report/get_send_report_list"
+ now := time.Now()
+ end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
+ start := end.Add(-reportMaximumOutboxDays * 24 * time.Hour)
+ if rt.Changed("start") {
+ startMillis, endMillis, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays)
+ if err != nil {
+ return err
+ }
+ start, end = time.UnixMilli(startMillis), time.UnixMilli(endMillis)
+ }
+ params := map[string]any{
+ "cursor": 0, "size": 20,
+ "startTime": start.UnixMilli(), "endTime": end.UnixMilli(),
+ }
+ if keyword := strings.TrimSpace(rt.Str("keyword")); keyword != "" {
+ params["report_template_name"] = keyword
+ }
+ data, err := rt.CallMCPData("report", "get_send_report_list", params)
+ if err != nil {
+ return err
+ }
+ entries, page, err := reportProjectEntries(data, listOperation)
+ if err != nil {
+ return err
+ }
+ if page.HasMore {
+ return reportResponseError(listOperation, "incomplete_latest_candidates", "发件箱仍有后续页,不能从不完整集合宣称最新日志")
+ }
+ if len(entries) == 0 {
+ return apperrors.NewValidation("最近 20 天没有可验证的已发送日志", apperrors.WithReason("no_sent_report_fixture"))
+ }
+ latestID, err := reportLatestEntryID(entries, listOperation)
+ if err != nil {
+ return err
+ }
+ const detailOperation = "report/get_report_entry_details"
+ detailData, err := rt.CallMCPData("report", "get_report_entry_details", map[string]any{"report_id": latestID})
+ if err != nil {
+ return err
+ }
+ detail, err := reportProjectDetail(detailData, detailOperation, latestID)
+ if err != nil {
+ return err
+ }
+ return rt.Output(map[string]any{"report": detail})
+ },
+}
+
+func reportLatestEntryID(entries []map[string]any, operation string) (string, error) {
+ var latestID string
+ var latestTime int64
+ latestCount := 0
+ for index, entry := range entries {
+ created, ok := entry["createTime"].(int64)
+ if !ok || created <= 0 {
+ return "", reportResponseError(operation, "missing_latest_order", "发件箱项目缺少正整数 createTime,不能证明哪一篇最新")
+ }
+ id, ok := entry["reportId"].(string)
+ if !ok || strings.TrimSpace(id) == "" {
+ return "", reportResponseError(operation, "missing_item_identity", "发件箱项目缺少稳定 reportId")
+ }
+ if index == 0 || created > latestTime {
+ latestID, latestTime = id, created
+ latestCount = 1
+ } else if created == latestTime {
+ latestCount++
+ }
+ }
+ if latestCount != 1 {
+ return "", reportResponseError(operation, "ambiguous_latest_order", "多篇日志具有相同的最高 createTime,不能确定唯一最新日志")
+ }
+ return latestID, nil
+}
+
+func init() {
+ shortcut.Register(ReportLatest)
+}
diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go
index 28aa7938..9fb8c52b 100644
--- a/internal/shortcut/report/report.go
+++ b/internal/shortcut/report/report.go
@@ -1,103 +1,126 @@
// Copyright 2026 Alibaba Group
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
+// Licensed under the Apache License, Version 2.0
-// Package report declares declarative shortcuts for the DingTalk
-// "报告/日志" (OA 周报应用) MCP tools. Each shortcut is a thin wrapper over a
-// single MCP tool call; tool names and parameter keys mirror the DWS report
-// helper (internal/helpers/report.go) verbatim.
+// Package report registers strict declarative shortcuts for DingTalk reports.
package report
import (
"fmt"
+ "strconv"
"strings"
"time"
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
-
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
-// reportParseISOMillis parses an ISO-8601 timestamp (e.g. 2026-03-10T00:00:00+08:00)
-// into Unix milliseconds, matching the helper's parseISOTimeToMillis behavior.
-func reportParseISOMillis(name, s string) (int64, error) {
- s = strings.TrimSpace(s)
- t, err := time.Parse(time.RFC3339, s)
+const (
+ reportMaximumInboxDays = 180
+ reportMaximumOutboxDays = 20
+)
+
+func reportParseISOMillis(name, value string) (int64, error) {
+ parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
if err != nil {
- return 0, fmt.Errorf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): %v", name, err)
+ return 0, apperrors.NewValidation(
+ fmt.Sprintf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00)", name),
+ apperrors.WithReason("invalid_time_format"),
+ )
}
- return t.UnixMilli(), nil
+ return parsed.UnixMilli(), nil
+}
+
+func reportValidatePage(cursor, size int) error {
+ if cursor < 0 {
+ return apperrors.NewValidation("--cursor 不能小于 0", apperrors.WithReason("invalid_cursor"))
+ }
+ if size < 1 || size > 20 {
+ return apperrors.NewValidation("--size 必须在 1 到 20 之间", apperrors.WithReason("invalid_page_size"))
+ }
+ return nil
+}
+
+func reportValidateRange(startName, startValue, endName, endValue string, maximumDays int) (int64, int64, error) {
+ start, err := reportParseISOMillis(startName, startValue)
+ if err != nil {
+ return 0, 0, err
+ }
+ end, err := reportParseISOMillis(endName, endValue)
+ if err != nil {
+ return 0, 0, err
+ }
+ if end <= start {
+ return 0, 0, apperrors.NewValidation("结束时间必须晚于开始时间", apperrors.WithReason("invalid_time_range"))
+ }
+ if maximumDays > 0 && end-start > int64(maximumDays)*int64(24*time.Hour/time.Millisecond) {
+ return 0, 0, apperrors.NewValidation(
+ fmt.Sprintf("时间范围不能超过 %d 天", maximumDays),
+ apperrors.WithReason("time_range_too_large"),
+ )
+ }
+ return start, end, nil
+}
+
+func reportValidateContinuation(page reportPageEvidence, current int, operation string) error {
+ if !page.HasMore {
+ return nil
+ }
+ next, err := strconv.ParseInt(page.Next, 10, 64)
+ if err != nil || next <= int64(current) {
+ return reportResponseError(operation, "stalled_cursor", "Report continuation cursor 没有严格前进")
+ }
+ return nil
}
-// TemplateList lists the report templates available to the current user.
-// TemplateGet reads a single report template's field definitions by name.
-// InboxList lists reports received by the current user within a time window.
var InboxList = shortcut.Shortcut{
- Service: "report",
- Command: "+inbox-list",
- Product: "report",
- Description: "列出我收到的日报(按时间范围分页)",
- Intent: "当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。",
- Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "report",
- Name: "shortcut_inbox_list",
- CanonicalPath: "report.shortcut_inbox_list",
- CLIPath: "report +inbox-list",
- PrimaryCLIPath: "report +inbox-list",
+ Service: "report", Command: "+inbox-list", Product: "report",
+ Description: "列出我收到的日志",
+ Intent: "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。",
+ Risk: shortcut.RiskRead,
+ Safety: reportReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: reportContract(
+ "+inbox-list", "列出我收到的日志",
+ "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。",
+ reportCollectionResult("reports", "严格验证的收件箱日志页"), reportPagination(),
+ []contract.ParamDecl{
+ {Name: "start", Property: "start"}, {Name: "end", Property: "end"},
+ {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"},
+ {Name: "sender-user-ids", Property: "senderUserIds", InterfaceType: "array"},
},
- Description: "列出我收到的日报(按时间范围分页)",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "列出我收到的日报(按时间范围分页)",
- UseWhen: []string{"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"},
- },
- },
+ "dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20",
+ ),
Flags: []shortcut.Flag{
- {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)", Required: true},
- {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)", Required: true},
- {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
- {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"},
- {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表,逗号分隔,过滤指定发送人"},
+ {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true},
+ {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true},
+ {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"},
+ {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"},
+ {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表"},
},
- Tips: []string{
- `dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`,
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "结束时间必须晚于开始时间,跨度不得超过 180 天"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"},
},
- Execute: func(rt *shortcut.RuntimeContext) error {
- startMs, err := reportParseISOMillis("start", rt.Str("start"))
- if err != nil {
+ Tips: []string{`dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil {
return err
}
- endMs, err := reportParseISOMillis("end", rt.Str("end"))
+ _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays)
+ return err
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ const operation = "report/get_received_report_list"
+ start, end, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays)
if err != nil {
return err
}
params := map[string]any{
- "startTime": startMs,
- "endTime": endMs,
- "cursor": rt.Int("cursor"),
- "size": rt.Int("size"),
+ "startTime": start, "endTime": end,
+ "cursor": rt.Int("cursor"), "size": rt.Int("size"),
}
if rt.Changed("sender-user-ids") {
params["senderUserIds"] = rt.StrSlice("sender-user-ids")
@@ -106,192 +129,164 @@ var InboxList = shortcut.Shortcut{
if err != nil {
return err
}
- reports := reportEntryListProject(data)
- return rt.Output(map[string]any{"count": len(reports), "reports": reports})
+ reports, page, err := reportProjectEntries(data, operation)
+ if err != nil {
+ return err
+ }
+ if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil {
+ return err
+ }
+ return outputReportPage(rt, "reports", reports, page)
},
}
-// reportEntryListProject reshapes the raw report-list responses
-// (get_received_report_list / get_send_report_list) into a clean, stable list
-// of report summaries — the output-projection fidelity the framework applies to
-// every list command. Both the list container and the per-item field names are
-// probed defensively across candidate keys so the projection tolerates
-// response-shape drift and never fabricates data: an empty/unknown shape yields
-// an empty list rather than a crash.
-func reportEntryListProject(data map[string]any) []map[string]any {
- raw := reportEntryListResolveList(data)
- out := make([]map[string]any, 0, len(raw))
- for _, item := range raw {
- m, ok := item.(map[string]any)
- if !ok {
- continue
- }
- row := map[string]any{}
- if v, ok := reportEntryListFirst(m, "reportId", "report_id", "id"); ok {
- row["reportId"] = v
- }
- if v, ok := reportEntryListFirst(m, "templateName", "template_name", "reportTemplateName"); ok {
- row["templateName"] = v
- }
- if v, ok := reportEntryListFirst(m, "creatorName", "creator_name", "creatorUserName", "senderName", "authorName"); ok {
- row["creatorName"] = v
- }
- if v, ok := reportEntryListFirst(m, "creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"); ok {
- row["creatorUserId"] = v
- }
- if v, ok := reportEntryListFirst(m, "createTime", "create_time", "gmtCreate", "sendTime"); ok {
- row["createTime"] = v
- }
- if v, ok := reportEntryListFirst(m, "modifiedTime", "modified_time", "gmtModified", "modifyTime"); ok {
- row["modifiedTime"] = v
- }
- if len(row) > 0 {
- out = append(out, row)
- }
- }
- return out
-}
-
-// reportEntryListResolveList locates the list payload inside the response,
-// tolerating a bare top-level array or nesting one level under a common
-// envelope key.
-func reportEntryListResolveList(data map[string]any) []any {
- if data == nil {
- return []any{}
- }
- // get_received_report_list / get_send_report_list nest the list under
- // result.report_list (snake_case); "report_list" MUST be probed — the
- // camelCase "reportList" alone leaves +inbox-list / +outbox-list silently
- // empty despite the backend returning reports.
- for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} {
- v, ok := data[key]
- if !ok {
- continue
- }
- if arr, ok := v.([]any); ok {
- return arr
- }
- if inner, ok := v.(map[string]any); ok {
- for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} {
- if arr, ok := inner[ik].([]any); ok {
- return arr
- }
- }
- }
- }
- return []any{}
-}
-
-// reportEntryListFirst returns the first present candidate key's value.
-func reportEntryListFirst(m map[string]any, keys ...string) (any, bool) {
- for _, k := range keys {
- if v, ok := m[k]; ok {
- return v, true
- }
- }
- return nil, false
-}
-
-// OutboxList lists reports sent/created by the current user.
var OutboxList = shortcut.Shortcut{
- Service: "report",
- Command: "+outbox-list",
- Product: "report",
- Description: "列出我发出的日报(可选时间/模版名过滤)",
- Intent: "当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。",
- Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "report",
- Name: "shortcut_outbox_list",
- CanonicalPath: "report.shortcut_outbox_list",
- CLIPath: "report +outbox-list",
- PrimaryCLIPath: "report +outbox-list",
+ Service: "report", Command: "+outbox-list", Product: "report",
+ Description: "列出我发出的日志",
+ Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。",
+ Risk: shortcut.RiskRead,
+ Safety: reportReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: reportContract(
+ "+outbox-list", "列出我发出的日志",
+ "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。",
+ reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(),
+ []contract.ParamDecl{
+ {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"},
+ {Name: "start", Property: "start"}, {Name: "end", Property: "end"},
+ {Name: "modified-start", Property: "modifiedStart"}, {Name: "modified-end", Property: "modifiedEnd"},
+ {Name: "template-name", Property: "templateName"},
},
- Description: "列出我发出的日报(可选时间/模版名过滤)",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "列出我发出的日报(可选时间/模版名过滤)",
- UseWhen: []string{"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{
- "dws report +outbox-list --cursor 0 --size 20",
- "dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\"",
- },
- },
- },
+ "dws report +outbox-list --cursor 0 --size 20",
+ ),
Flags: []shortcut.Flag{
- {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"},
- {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"},
- {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601 (可选,服务端单次跨度上限 20 天)"},
- {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601 (可选)"},
- {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601 (可选)"},
- {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601 (可选)"},
- {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称 (可选,不传查全部)"},
+ {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"},
+ {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"},
+ {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"},
+ {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"},
+ {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"},
+ {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"},
+ {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称"},
},
- Tips: []string{
- `dws report +outbox-list --cursor 0 --size 20`,
- `dws report +outbox-list --cursor 0 --size 20 --template-name "日报"`,
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "创建时间范围必须有效且不得超过 20 天"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"modified-start", "modified-end"}, Description: "修改时间必须成对提供、范围有效且不得超过 20 天"},
},
- Execute: func(rt *shortcut.RuntimeContext) error {
- params := map[string]any{
- "cursor": rt.Int("cursor"),
- "size": rt.Int("size"),
+ Tips: []string{"dws report +outbox-list --cursor 0 --size 20"},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil {
+ return err
}
- if rt.Changed("start") {
- ms, err := reportParseISOMillis("start", rt.Str("start"))
- if err != nil {
+ if rt.Changed("start") && rt.Changed("end") {
+ if _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays); err != nil {
return err
}
- params["startTime"] = ms
}
- if rt.Changed("end") {
- ms, err := reportParseISOMillis("end", rt.Str("end"))
- if err != nil {
- return err
- }
- params["endTime"] = ms
+ if rt.Changed("modified-start") != rt.Changed("modified-end") {
+ return apperrors.NewValidation("--modified-start 与 --modified-end 必须同时提供", apperrors.WithReason("incomplete_modified_range"))
}
if rt.Changed("modified-start") {
- ms, err := reportParseISOMillis("modified-start", rt.Str("modified-start"))
- if err != nil {
- return err
- }
- params["modifiedStartTime"] = ms
+ _, _, err := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays)
+ return err
}
- if rt.Changed("modified-end") {
- ms, err := reportParseISOMillis("modified-end", rt.Str("modified-end"))
- if err != nil {
- return err
- }
- params["modifiedEndTime"] = ms
+ return nil
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ const operation = "report/get_send_report_list"
+ now := time.Now()
+ defaultEnd := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
+ startValue := defaultEnd.Add(-reportMaximumOutboxDays * 24 * time.Hour).Format(time.RFC3339)
+ endValue := defaultEnd.Format(time.RFC3339)
+ if rt.Changed("start") {
+ startValue = rt.Str("start")
}
- if rt.Changed("template-name") {
- params["report_template_name"] = rt.Str("template-name")
+ if rt.Changed("end") {
+ endValue = rt.Str("end")
+ }
+ start, end, err := reportValidateRange("start", startValue, "end", endValue, reportMaximumOutboxDays)
+ if err != nil {
+ return err
+ }
+ params := map[string]any{
+ "cursor": rt.Int("cursor"), "size": rt.Int("size"),
+ "startTime": start, "endTime": end,
+ }
+ if rt.Changed("modified-start") {
+ modifiedStart, modifiedEnd, rangeErr := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays)
+ if rangeErr != nil {
+ return rangeErr
+ }
+ params["modifiedStartTime"], params["modifiedEndTime"] = modifiedStart, modifiedEnd
+ }
+ if name := strings.TrimSpace(rt.Str("template-name")); name != "" {
+ params["report_template_name"] = name
}
data, err := rt.CallMCPData("report", "get_send_report_list", params)
if err != nil {
return err
}
- reports := reportEntryListProject(data)
- return rt.Output(map[string]any{"count": len(reports), "reports": reports})
+ reports, page, err := reportProjectEntries(data, operation)
+ if err != nil {
+ return err
+ }
+ if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil {
+ return err
+ }
+ return outputReportPage(rt, "reports", reports, page)
},
}
-// EntryGet reads the full body of a single report by id.
-// EntryStats reads the read-receipt statistics of a single report.
-// EntrySubmit submits a new report against a template.
+var TemplateSearch = shortcut.Shortcut{
+ Service: "report", Command: "+template-search", Product: "report",
+ Description: "按名称搜索可用日志模板",
+ Intent: "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。",
+ Risk: shortcut.RiskRead,
+ Safety: reportReadSafety(),
+ OutputRollout: output.RolloutUnifiedActive,
+ Contract: reportContract(
+ "+template-search", "按名称搜索可用日志模板",
+ "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。",
+ reportTemplateSearchResult(), nil,
+ []contract.ParamDecl{{Name: "query", Property: "report_template_name"}},
+ "dws report +template-search --query 周报",
+ ),
+ Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "模板名称关键词,不区分大小写;--query 不能为空", Required: true}},
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空"}},
+ Tips: []string{"dws report +template-search --query 周报"},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if strings.TrimSpace(rt.Str("query")) == "" {
+ return apperrors.NewValidation("--query 不能为空", apperrors.WithReason("empty_query"))
+ }
+ return nil
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ const operation = "report/get_available_report_templates"
+ data, err := rt.CallMCPData("report", "get_available_report_templates", map[string]any{})
+ if err != nil {
+ return err
+ }
+ templates, err := reportProjectTemplates(data, operation)
+ if err != nil {
+ return err
+ }
+ query := strings.ToLower(strings.TrimSpace(rt.Str("query")))
+ matches := make([]map[string]any, 0)
+ for _, template := range templates {
+ if strings.Contains(strings.ToLower(template["name"].(string)), query) {
+ matches = append(matches, template)
+ }
+ }
+ payload := map[string]any{"count": len(matches), "templates": matches}
+ if !output.UsesUnifiedResult(rt.Command()) {
+ return rt.Output(payload)
+ }
+ meta := &output.Meta{Count: output.NewCount(len(matches))}
+ return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
+ },
+}
+
func init() {
- shortcut.Register(
- InboxList,
- OutboxList,
- )
+ shortcut.Register(InboxList, OutboxList, TemplateSearch)
}
diff --git a/internal/shortcut/report/report_list_projection_test.go b/internal/shortcut/report/report_list_projection_test.go
deleted file mode 100644
index faa8c95e..00000000
--- a/internal/shortcut/report/report_list_projection_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright 2026 Alibaba Group
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package report
-
-import (
- "encoding/json"
- "testing"
-)
-
-// TestReportEntryListResolveListSnakeShape guards against projection-data-loss:
-// get_received_report_list / get_send_report_list nest the list under
-// result.report_list (snake_case); the resolver must probe "report_list" or
-// +inbox-list / +outbox-list silently return empty despite the backend having
-// reports.
-func TestReportEntryListResolveListSnakeShape(t *testing.T) {
- const raw = `{"result":{"report_list":[
- {"reportId":"r1","templateName":"daily report"},
- {"reportId":"r2","templateName":"weekly report"}
- ]}}`
- var data map[string]any
- if err := json.Unmarshal([]byte(raw), &data); err != nil {
- t.Fatalf("unmarshal fixture: %v", err)
- }
- if got := reportEntryListResolveList(data); len(got) != 2 {
- t.Fatalf("lower/upper mismatch: result.report_list has 2 entries, resolver returned %d", len(got))
- }
-}
diff --git a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go
new file mode 100644
index 00000000..48fae36f
--- /dev/null
+++ b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go
@@ -0,0 +1,625 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package report
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "math"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+type reportCoverageCall struct {
+ tool string
+ args map[string]any
+}
+
+type reportCoverageCaller struct {
+ responses map[string][]string
+ history []reportCoverageCall
+}
+
+func (caller *reportCoverageCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
+ caller.history = append(caller.history, reportCoverageCall{tool: tool, args: args})
+ queue := caller.responses[tool]
+ if len(queue) == 0 {
+ return nil, errors.New("missing Report fake response for " + tool)
+ }
+ caller.responses[tool] = queue[1:]
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
+}
+
+func (*reportCoverageCaller) Format() string { return "json" }
+func (*reportCoverageCaller) DryRun() bool { return false }
+func (*reportCoverageCaller) Fields() string { return "" }
+func (*reportCoverageCaller) JQ() string { return "" }
+
+func runReportCoverage(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) (*cobra.Command, error) {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs(args)
+ return cmd, cmd.Execute()
+}
+
+func directReportRuntime(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) *shortcut.RuntimeContext {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ if err := cmd.Flags().Parse(args); err != nil {
+ t.Fatal(err)
+ }
+ return shortcut.RuntimeContextForTest(cmd, declaration)
+}
+
+func TestCrossPlatformCoverageReportContractsAreStrictTypedAndUnified(t *testing.T) {
+ declarations := []shortcut.Shortcut{InboxList, OutboxList, TemplateSearch, ReportLatest}
+ for _, declaration := range declarations {
+ if declaration.Contract.Empty() || declaration.Contract.Result == nil {
+ t.Errorf("%s lacks Contract/Result", declaration.Command)
+ }
+ if declaration.Safety.Effect != "read" || declaration.Safety.Confirmation != "not_required" {
+ t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety)
+ }
+ if declaration.OutputRollout != output.RolloutUnifiedActive {
+ t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
+ }
+ if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" {
+ t.Errorf("%s interface=%+v", declaration.Command, declaration.Contract.Interface)
+ }
+ }
+ if InboxList.Contract.Pagination == nil || OutboxList.Contract.Pagination == nil {
+ t.Fatal("Report list shortcuts must publish cursor pagination")
+ }
+ if TemplateSearch.Contract.Pagination != nil || ReportLatest.Contract.Pagination != nil {
+ t.Fatal("non-paginated Report shortcuts published pagination")
+ }
+}
+
+func TestCrossPlatformCoverageReportListResponseMatrix(t *testing.T) {
+ valid := map[string]any{
+ "success": true,
+ "result": map[string]any{
+ "report_list": []any{map[string]any{"reportId": "report-1", "templateName": "fixture", "createTime": float64(10)}},
+ },
+ "hasMore": false,
+ }
+ entries, page, err := reportProjectEntries(valid, "report/get_received_report_list")
+ if err != nil || len(entries) != 1 || page.HasMore || entries[0]["createTime"] != int64(10) {
+ t.Fatalf("valid projection entries=%#v page=%+v err=%v", entries, page, err)
+ }
+ empty := map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "cursor": nil}
+ if entries, page, err := reportProjectEntries(empty, "report/get_received_report_list"); err != nil || len(entries) != 0 || page.HasMore {
+ t.Fatalf("terminal empty entries=%#v page=%+v err=%v", entries, page, err)
+ }
+ terminalReceipt := map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": false, "nextCursor": float64(1)}
+ if entries, page, err := reportProjectEntries(terminalReceipt, "report/get_received_report_list"); err != nil || len(entries) != 1 || page.HasMore || page.Next != "" {
+ t.Fatalf("terminal cursor receipt entries=%#v page=%+v err=%v", entries, page, err)
+ }
+ fixtures := map[string]map[string]any{
+ "empty response": map[string]any{},
+ "missing success": map[string]any{"result": map[string]any{"report_list": []any{}}, "hasMore": false},
+ "wrong success": map[string]any{"success": "true", "result": map[string]any{"report_list": []any{}}, "hasMore": false},
+ "remote failure": map[string]any{"success": false, "result": map[string]any{"report_list": []any{}}, "hasMore": false},
+ "missing result": map[string]any{"success": true, "hasMore": false},
+ "wrong result": map[string]any{"success": true, "result": "bad", "hasMore": false},
+ "missing collection": map[string]any{"success": true, "result": map[string]any{}, "hasMore": false},
+ "wrong collection": map[string]any{"success": true, "result": map[string]any{"report_list": map[string]any{}}, "hasMore": false},
+ "bad item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{"bad"}}, "hasMore": false},
+ "empty item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{}}}, "hasMore": false},
+ "missing identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"createTime": float64(1)}}}, "hasMore": false},
+ "wrong identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": float64(1)}}}, "hasMore": false},
+ "duplicate identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "same"}, map[string]any{"reportId": "same"}}}, "hasMore": false},
+ "wrong optional": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "createTime": "1"}}}, "hasMore": false},
+ "missing pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}},
+ "wrong pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": "false"},
+ "empty continuation": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": true, "nextCursor": float64(2)},
+ "missing continuation": map[string]any{
+ "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true,
+ },
+ "wrong continuation": map[string]any{
+ "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, "nextCursor": "2",
+ },
+ "wrong terminal cursor": map[string]any{
+ "success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "nextCursor": "2",
+ },
+ "conflicting has more": map[string]any{
+ "success": true, "result": map[string]any{"report_list": []any{}, "hasMore": true}, "hasMore": false,
+ },
+ "conflicting continuation": map[string]any{
+ "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}, "cursor": float64(3)}, "hasMore": true, "nextCursor": float64(2),
+ },
+ }
+ for name, fixture := range fixtures {
+ if projected, _, projectErr := reportProjectEntries(fixture, "report/get_received_report_list"); projectErr == nil {
+ t.Errorf("%s returned success: %#v", name, projected)
+ }
+ }
+ if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "2"}, 2, "report/list"); err == nil {
+ t.Fatal("stalled continuation returned success")
+ }
+}
+
+func TestCrossPlatformCoverageReportTemplateResponseMatrix(t *testing.T) {
+ valid := map[string]any{"success": true, "items": []any{
+ map[string]any{"report_template_id": "template-1", "report_template_name": "Fixture Weekly", "last_modified_time": float64(2)},
+ map[string]any{"report_template_id": "template-2", "report_template_name": "Fixture Daily"},
+ }}
+ templates, err := reportProjectTemplates(valid, "report/get_available_report_templates")
+ if err != nil || len(templates) != 2 || templates[0]["lastModifiedTime"] != int64(2) {
+ t.Fatalf("valid templates=%#v err=%v", templates, err)
+ }
+ for name, fixture := range map[string]map[string]any{
+ "empty response": {},
+ "missing success": {"items": []any{}},
+ "wrong success": {"success": float64(1), "items": []any{}},
+ "missing collection": {"success": true},
+ "wrong collection": {"success": true, "items": map[string]any{}},
+ "bad item": {"success": true, "items": []any{"bad"}},
+ "missing id": {"success": true, "items": []any{map[string]any{"report_template_name": "fixture"}}},
+ "missing name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1"}}},
+ "wrong name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": float64(1)}}},
+ "duplicate id": {"success": true, "items": []any{map[string]any{"report_template_id": "same", "report_template_name": "one"}, map[string]any{"report_template_id": "same", "report_template_name": "two"}}},
+ "wrong modified": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": "fixture", "last_modified_time": "2"}}},
+ } {
+ if projected, projectErr := reportProjectTemplates(fixture, "report/get_available_report_templates"); projectErr == nil {
+ t.Errorf("%s returned success: %#v", name, projected)
+ }
+ }
+ if projected, err := reportProjectTemplates(map[string]any{"success": true, "items": []any{}}, "report/templates"); err != nil || len(projected) != 0 {
+ t.Fatalf("explicit empty template collection=%#v err=%v", projected, err)
+ }
+}
+
+func TestCrossPlatformCoverageReportDetailResponseMatrix(t *testing.T) {
+ valid := map[string]any{"success": true, "result": map[string]any{
+ "report_Id": "report-2", "report_name": "fixture", "createTime": float64(2),
+ "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": float64(1), "type": float64(2)}},
+ }}
+ detail, err := reportProjectDetail(valid, "report/get_report_entry_details", "report-2")
+ if err != nil || detail["reportId"] != "report-2" || len(detail["fields"].([]map[string]any)) != 1 {
+ t.Fatalf("valid detail=%#v err=%v", detail, err)
+ }
+ for name, fixture := range map[string]map[string]any{
+ "empty response": {},
+ "missing result": {"success": true},
+ "empty result": {"success": true, "result": map[string]any{}},
+ "missing id": {"success": true, "result": map[string]any{"report_content": []any{}}},
+ "identity mismatch": {"success": true, "result": map[string]any{"report_Id": "other", "report_content": []any{}}},
+ "missing collection": {"success": true, "result": map[string]any{"report_Id": "report-2"}},
+ "wrong collection": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": map[string]any{}}},
+ "bad field": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{"bad"}}},
+ "missing field key": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"value": "value", "sort": float64(1), "type": float64(2)}}}},
+ "wrong field type": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": "1", "type": float64(2)}}}},
+ } {
+ if projected, projectErr := reportProjectDetail(fixture, "report/get_report_entry_details", "report-2"); projectErr == nil {
+ t.Errorf("%s returned success: %#v", name, projected)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageReportExactShortcutsProjectUnifiedData(t *testing.T) {
+ templateCaller := &reportCoverageCaller{responses: map[string][]string{
+ "get_available_report_templates": {`{"success":true,"items":[{"report_template_id":"template-1","report_template_name":"Fixture Weekly","last_modified_time":2},{"report_template_id":"template-2","report_template_name":"Fixture Daily"}]}`},
+ }}
+ cmd, err := runReportCoverage(t, TemplateSearch, templateCaller, "--query", "weekly")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 {
+ t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr)
+ }
+ var envelope map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatal(err)
+ }
+ data := envelope["data"].(map[string]any)
+ if data["count"] != float64(1) || len(data["templates"].([]any)) != 1 {
+ t.Fatalf("template search data=%#v", data)
+ }
+ if _, leaked := data["items"]; leaked {
+ t.Fatalf("template search leaked raw collection: %#v", data)
+ }
+
+ inboxCaller := &reportCoverageCaller{responses: map[string][]string{
+ "get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false,"cursor":null}`},
+ }}
+ cmd, err = runReportCoverage(t, InboxList, inboxCaller,
+ "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00")
+ if err != nil {
+ t.Fatal(err)
+ }
+ stdout.Reset()
+ cmd.SetOut(&stdout)
+ if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted {
+ t.Fatalf("inbox emit=(%t,%v)", emitted, emitErr)
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatal(err)
+ }
+ data = envelope["data"].(map[string]any)
+ if data["count"] != float64(1) || data["complete"] != true {
+ t.Fatalf("inbox data=%#v", data)
+ }
+}
+
+func TestCrossPlatformCoverageReportLatestRequiresCompleteOrderedListAndExactReadback(t *testing.T) {
+ missingOrder := &reportCoverageCaller{responses: map[string][]string{
+ "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":false}`},
+ }}
+ if _, err := runReportCoverage(t, ReportLatest, missingOrder); err == nil || len(missingOrder.history) != 1 {
+ t.Fatalf("missing order err=%v history=%v", err, missingOrder.history)
+ }
+
+ incomplete := &reportCoverageCaller{responses: map[string][]string{
+ "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":true,"nextCursor":20}`},
+ }}
+ if _, err := runReportCoverage(t, ReportLatest, incomplete); err == nil || len(incomplete.history) != 1 {
+ t.Fatalf("incomplete err=%v history=%v", err, incomplete.history)
+ }
+
+ tied := &reportCoverageCaller{responses: map[string][]string{
+ "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":2},{"reportId":"report-2","createTime":2}]},"hasMore":false}`},
+ }}
+ if _, err := runReportCoverage(t, ReportLatest, tied); err == nil || len(tied.history) != 1 {
+ t.Fatalf("tied latest err=%v history=%v", err, tied.history)
+ }
+
+ caller := &reportCoverageCaller{responses: map[string][]string{
+ "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1},{"reportId":"report-2","createTime":2}]},"hasMore":false}`},
+ "get_report_entry_details": {`{"success":true,"result":{"report_Id":"report-2","report_name":"fixture","createTime":2,"report_content":[{"key":"field","value":"value","sort":1,"type":2}]}}`},
+ }}
+ cmd, err := runReportCoverage(t, ReportLatest, caller,
+ "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-20T00:00:00+08:00")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(caller.history) != 2 || caller.history[0].tool != "get_send_report_list" || caller.history[1].tool != "get_report_entry_details" || caller.history[1].args["report_id"] != "report-2" {
+ t.Fatalf("exact call history=%#v", caller.history)
+ }
+ if caller.history[0].args["startTime"] != int64(1782835200000) || caller.history[0].args["endTime"] != int64(1784476800000) {
+ t.Fatalf("explicit range args=%#v", caller.history[0].args)
+ }
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted {
+ t.Fatalf("latest emit=(%t,%v)", emitted, emitErr)
+ }
+ var envelope map[string]any
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatal(err)
+ }
+ projected := envelope["data"].(map[string]any)["report"].(map[string]any)
+ _, leaked := projected["success"]
+ if projected["reportId"] != "report-2" || leaked {
+ t.Fatalf("latest projection=%s", stdout.String())
+ }
+}
+
+func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *testing.T) {
+ tests := []struct {
+ declaration shortcut.Shortcut
+ args []string
+ }{
+ {InboxList, []string{"--start", "2026-07-02T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}},
+ {InboxList, []string{"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00", "--size", "21"}},
+ {OutboxList, []string{"--modified-start", "2026-07-01T00:00:00+08:00"}},
+ {ReportLatest, []string{"--start", "2026-07-01T00:00:00+08:00"}},
+ {ReportLatest, []string{"--start", "2026-07-22T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}},
+ }
+ for _, test := range tests {
+ caller := &reportCoverageCaller{responses: map[string][]string{}}
+ if _, err := runReportCoverage(t, test.declaration, caller, test.args...); err == nil || len(caller.history) != 0 {
+ t.Errorf("%s args=%v err=%v calls=%v", test.declaration.Command, test.args, err, caller.history)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageReportPrimitiveStrictnessAndProjectionBranches(t *testing.T) {
+ operation := "report/coverage"
+ if err := reportRequireSuccess(map[string]any{"success": false, "errorMessage": " rejected "}, operation); err == nil {
+ t.Fatal("remote failure with message returned success")
+ }
+ arrayEntries, container, err := reportListCollection(map[string]any{
+ "success": true,
+ "result": []any{map[string]any{"report_id": "report-array"}},
+ "hasMore": false,
+ }, operation)
+ if err != nil || len(arrayEntries) != 1 || container["hasMore"] != false {
+ t.Fatalf("array collection entries=%#v container=%#v err=%v", arrayEntries, container, err)
+ }
+ if _, err := reportRequiredString(map[string]any{"reportId": "one", "report_id": "two"}, operation, 0, "reportId", "report_id"); err == nil {
+ t.Fatal("conflicting aliases returned success")
+ }
+ if _, err := reportOptionalString(map[string]any{"name": 1}, operation, 0, "name"); err == nil {
+ t.Fatal("non-string optional value returned success")
+ }
+
+ integerCases := []struct {
+ value any
+ want int64
+ ok bool
+ }{
+ {float64(7), 7, true}, {math.NaN(), 0, false}, {math.Inf(1), 0, false},
+ {1.5, 0, false}, {int(8), 8, true}, {int64(9), 9, true},
+ {json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {"11", 0, false},
+ }
+ for _, test := range integerCases {
+ got, ok := reportInteger(test.value)
+ if got != test.want || ok != test.ok {
+ t.Errorf("reportInteger(%#v)=(%d,%t), want (%d,%t)", test.value, got, ok, test.want, test.ok)
+ }
+ }
+
+ fullEntry := map[string]any{
+ "success": true,
+ "result": map[string]any{
+ "hasMore": true,
+ "nextCursor": json.Number("2"),
+ "report_list": []any{map[string]any{
+ "report_id": "report-full", "report_template_name": "Weekly",
+ "senderName": "sender", "senderUserId": "user", "gmtCreate": int(12), "modifyTime": int64(13),
+ }},
+ },
+ }
+ entries, page, err := reportProjectEntries(fullEntry, operation)
+ if err != nil || len(entries) != 1 || !page.HasMore || page.Next != "2" || entries[0]["modifiedTime"] != int64(13) {
+ t.Fatalf("full entry entries=%#v page=%+v err=%v", entries, page, err)
+ }
+ if err := reportValidateContinuation(page, 1, operation); err != nil {
+ t.Fatalf("advancing continuation: %v", err)
+ }
+ if err := reportValidateContinuation(reportPageEvidence{}, 0, operation); err != nil {
+ t.Fatalf("terminal continuation: %v", err)
+ }
+ if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "bad"}, 0, operation); err == nil {
+ t.Fatal("malformed continuation returned success")
+ }
+ badOptional := map[string]any{
+ "success": true, "hasMore": false,
+ "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "senderName": 1}}},
+ }
+ if _, _, err := reportProjectEntries(badOptional, operation); err == nil {
+ t.Fatal("bad optional entry field returned success")
+ }
+
+ completeDetail := map[string]any{"success": true, "result": map[string]any{
+ "reportId": "report-detail", "reportName": "Title", "templateName": "Template", "senderName": "Creator",
+ "create_time": int(4), "modified_time": json.Number("5"),
+ "report_content": []any{map[string]any{"key": "field", "value": "", "sort": int(1), "type": int64(2)}},
+ }}
+ detail, err := reportProjectDetail(completeDetail, operation, "report-detail")
+ if err != nil || detail["modifiedTime"] != int64(5) || len(detail["fields"].([]map[string]any)) != 1 {
+ t.Fatalf("complete detail=%#v err=%v", detail, err)
+ }
+ for name, fixture := range map[string]map[string]any{
+ "bad optional string": {"success": true, "result": map[string]any{"reportId": "report-detail", "reportName": 1, "report_content": []any{}}},
+ "bad optional integer": {"success": true, "result": map[string]any{"reportId": "report-detail", "create_time": "4", "report_content": []any{}}},
+ "missing field value": {"success": true, "result": map[string]any{"reportId": "report-detail", "report_content": []any{map[string]any{"key": "field", "sort": 1, "type": 2}}}},
+ } {
+ if projected, projectErr := reportProjectDetail(fixture, operation, "report-detail"); projectErr == nil {
+ t.Errorf("%s returned success: %#v", name, projected)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageReportValidationHelpersCoverAllContracts(t *testing.T) {
+ if _, err := reportParseISOMillis("start", "not-a-time"); err == nil {
+ t.Fatal("invalid ISO timestamp returned success")
+ }
+ if err := reportValidatePage(-1, 20); err == nil {
+ t.Fatal("negative cursor returned success")
+ }
+ if err := reportValidatePage(0, 0); err == nil {
+ t.Fatal("zero page size returned success")
+ }
+ if err := reportValidatePage(0, 20); err != nil {
+ t.Fatal(err)
+ }
+ for _, test := range []struct {
+ name, start, end string
+ maximumDays int
+ }{
+ {"bad start", "bad", "2026-07-02T00:00:00+08:00", 20},
+ {"bad end", "2026-07-01T00:00:00+08:00", "bad", 20},
+ {"too wide", "2026-07-01T00:00:00+08:00", "2026-08-01T00:00:00+08:00", 20},
+ } {
+ if _, _, err := reportValidateRange("start", test.start, "end", test.end, test.maximumDays); err == nil {
+ t.Errorf("%s returned success", test.name)
+ }
+ }
+ if _, _, err := reportValidateRange("start", "2026-07-01T00:00:00+08:00", "end", "2026-08-01T00:00:00+08:00", 0); err != nil {
+ t.Fatalf("unbounded valid range: %v", err)
+ }
+}
+
+func TestCrossPlatformCoverageReportExecutorsSuccessErrorsAndZeroCalls(t *testing.T) {
+ start := "2026-07-01T00:00:00+08:00"
+ end := "2026-07-02T00:00:00+08:00"
+ validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]} ,"hasMore":false}`
+
+ inbox := &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {validList}}}
+ if _, err := runReportCoverage(t, InboxList, inbox, "--start", start, "--end", end, "--sender-user-ids", "user-1,user-2"); err != nil {
+ t.Fatal(err)
+ }
+ if len(inbox.history) != 1 || len(inbox.history[0].args["senderUserIds"].([]string)) != 2 {
+ t.Fatalf("inbox args/history=%#v", inbox.history)
+ }
+ for _, test := range []struct {
+ name string
+ caller *reportCoverageCaller
+ args []string
+ }{
+ {"call error", &reportCoverageCaller{responses: map[string][]string{}}, []string{"--start", start, "--end", end}},
+ {"projection error", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true}`}}}, []string{"--start", start, "--end", end}},
+ {"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--start", start, "--end", end, "--cursor", "2"}},
+ } {
+ if _, err := runReportCoverage(t, InboxList, test.caller, test.args...); err == nil {
+ t.Errorf("inbox %s returned success", test.name)
+ }
+ }
+ directInbox := directReportRuntime(t, InboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end)
+ if err := InboxList.Execute(directInbox); err == nil {
+ t.Fatal("direct inbox invalid range returned success")
+ }
+
+ outboxAll := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-2","createTime":2}]},"hasMore":true,"nextCursor":3}`}}}
+ if _, err := runReportCoverage(t, OutboxList, outboxAll,
+ "--cursor", "2", "--start", start, "--end", end,
+ "--modified-start", start, "--modified-end", end, "--template-name", " Weekly "); err != nil {
+ t.Fatal(err)
+ }
+ if len(outboxAll.history) != 1 || outboxAll.history[0].args["report_template_name"] != "Weekly" || outboxAll.history[0].args["modifiedStartTime"] == nil {
+ t.Fatalf("outbox args/history=%#v", outboxAll.history)
+ }
+ defaultOutbox := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {validList}}}
+ if _, err := runReportCoverage(t, OutboxList, defaultOutbox); err != nil {
+ t.Fatal(err)
+ }
+ for _, test := range []struct {
+ name string
+ caller *reportCoverageCaller
+ args []string
+ }{
+ {"call error", &reportCoverageCaller{responses: map[string][]string{}}, nil},
+ {"projection error", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true}`}}}, nil},
+ {"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--cursor", "2"}},
+ } {
+ if _, err := runReportCoverage(t, OutboxList, test.caller, test.args...); err == nil {
+ t.Errorf("outbox %s returned success", test.name)
+ }
+ }
+ directOutbox := directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end)
+ if err := OutboxList.Execute(directOutbox); err == nil {
+ t.Fatal("direct outbox bad creation range returned success")
+ }
+ directOutbox = directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}},
+ "--modified-start", "bad", "--modified-end", end)
+ if err := OutboxList.Execute(directOutbox); err == nil {
+ t.Fatal("direct outbox bad modified range returned success")
+ }
+
+ for _, test := range []struct {
+ name string
+ declaration shortcut.Shortcut
+ args []string
+ }{
+ {"outbox invalid page", OutboxList, []string{"--size", "0"}},
+ {"outbox invalid creation range", OutboxList, []string{"--start", "bad", "--end", end}},
+ {"outbox valid modified range", OutboxList, []string{"--modified-start", start, "--modified-end", end}},
+ {"template empty query", TemplateSearch, []string{"--query", " "}},
+ } {
+ caller := &reportCoverageCaller{responses: map[string][]string{}}
+ _, err := runReportCoverage(t, test.declaration, caller, test.args...)
+ if test.name == "outbox valid modified range" {
+ if err == nil || len(caller.history) != 1 {
+ t.Errorf("%s err=%v history=%#v", test.name, err, caller.history)
+ }
+ continue
+ }
+ if err == nil || len(caller.history) != 0 {
+ t.Errorf("%s err=%v history=%#v", test.name, err, caller.history)
+ }
+ }
+
+ for name, caller := range map[string]*reportCoverageCaller{
+ "call error": {responses: map[string][]string{}},
+ "projection error": {responses: map[string][]string{"get_available_report_templates": {`{"success":true}`}}},
+ } {
+ if _, err := runReportCoverage(t, TemplateSearch, caller, "--query", "weekly"); err == nil {
+ t.Errorf("template %s returned success", name)
+ }
+ }
+ directTemplate := directReportRuntime(t, TemplateSearch, &reportCoverageCaller{responses: map[string][]string{}}, "--query", " ")
+ if err := TemplateSearch.Validate(directTemplate); err == nil {
+ t.Fatal("direct template whitespace query returned success")
+ }
+ legacyTemplate := TemplateSearch
+ legacyTemplate.OutputRollout = output.RolloutLegacyOnly
+ legacyTemplateCaller := &reportCoverageCaller{responses: map[string][]string{"get_available_report_templates": {`{"success":true,"items":[]}`}}}
+ if _, err := runReportCoverage(t, legacyTemplate, legacyTemplateCaller, "--query", "zero"); err != nil {
+ t.Fatalf("legacy template: %v", err)
+ }
+}
+
+func TestCrossPlatformCoverageReportLatestExecutorRemainingBranches(t *testing.T) {
+ validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false}`
+ validDetail := `{"success":true,"result":{"report_Id":"report-1","report_content":[]}}`
+ defaultCaller := &reportCoverageCaller{responses: map[string][]string{
+ "get_send_report_list": {validList}, "get_report_entry_details": {validDetail},
+ }}
+ if _, err := runReportCoverage(t, ReportLatest, defaultCaller, "--keyword", " Weekly "); err != nil {
+ t.Fatal(err)
+ }
+ if defaultCaller.history[0].args["report_template_name"] != "Weekly" {
+ t.Fatalf("latest keyword args=%#v", defaultCaller.history[0].args)
+ }
+
+ for name, caller := range map[string]*reportCoverageCaller{
+ "list call error": {responses: map[string][]string{}},
+ "list projection error": {responses: map[string][]string{
+ "get_send_report_list": {`{"success":true}`},
+ }},
+ "empty candidates": {responses: map[string][]string{
+ "get_send_report_list": {`{"success":true,"result":{"report_list":[]},"hasMore":false}`},
+ }},
+ "detail call error": {responses: map[string][]string{
+ "get_send_report_list": {validList},
+ }},
+ "detail projection error": {responses: map[string][]string{
+ "get_send_report_list": {validList}, "get_report_entry_details": {`{"success":true}`},
+ }},
+ } {
+ if _, err := runReportCoverage(t, ReportLatest, caller); err == nil {
+ t.Errorf("latest %s returned success", name)
+ }
+ }
+ directLatest := directReportRuntime(t, ReportLatest, &reportCoverageCaller{responses: map[string][]string{}},
+ "--start", "bad", "--end", "2026-07-02T00:00:00+08:00")
+ if err := ReportLatest.Execute(directLatest); err == nil {
+ t.Fatal("direct latest invalid range returned success")
+ }
+ if _, err := reportLatestEntryID([]map[string]any{{"createTime": int64(1), "reportId": 1}}, "report/latest"); err == nil {
+ t.Fatal("non-string latest report identity returned success")
+ }
+}
+
+func TestCrossPlatformCoverageReportOutputLegacyAndInvalidPagination(t *testing.T) {
+ command := &cobra.Command{Use: "report-page"}
+ command.SetOut(io.Discard)
+ command.SetContext(context.Background())
+ declaration := shortcut.Shortcut{Service: "report", Product: "report"}
+ rt := shortcut.RuntimeContextForTest(command, declaration)
+ output.SetCommandRollout(command, output.RolloutLegacyOnly)
+ if err := outputReportPage(rt, "reports", []map[string]any{{"reportId": "report-1"}}, reportPageEvidence{HasMore: true, Next: "2"}); err != nil {
+ t.Fatalf("legacy report page: %v", err)
+ }
+
+ ctx, _ := output.WithResultStore(context.Background())
+ command.SetContext(ctx)
+ output.SetCommandRollout(command, output.RolloutUnifiedActive)
+ if err := outputReportPage(rt, "reports", nil, reportPageEvidence{HasMore: true}); err == nil {
+ t.Fatal("unified page without continuation returned success")
+ }
+}
diff --git a/internal/shortcut/responsecheck/responsecheck.go b/internal/shortcut/responsecheck/responsecheck.go
new file mode 100644
index 00000000..1b175933
--- /dev/null
+++ b/internal/shortcut/responsecheck/responsecheck.go
@@ -0,0 +1,181 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+// Package responsecheck contains fail-closed response validators shared by
+// built-in shortcuts. It deliberately does not guess that a missing collection
+// means an empty result: callers must name the exact reviewed wire paths.
+package responsecheck
+
+import (
+ "fmt"
+ "strings"
+
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+)
+
+// Error returns a non-retryable response-validation error. The remote request
+// has completed, but the response is not sufficient evidence of success.
+func Error(operation, reason, message string) error {
+ return apperrors.NewAPI(message,
+ apperrors.WithOperation(operation),
+ apperrors.WithOrigin("mcp"),
+ apperrors.WithFailureStage("response_validation"),
+ apperrors.WithRetryable(false),
+ apperrors.WithReason(reason),
+ )
+}
+
+// RequireSuccess validates the common service envelope and returns it. A
+// missing success field is rejected because the products using this helper
+// have been observed to publish an explicit boolean success field.
+func RequireSuccess(data map[string]any, operation string) (map[string]any, error) {
+ if len(data) == 0 {
+ return nil, Error(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空")
+ }
+ envelope := data
+ if content, ok := data["content"].(map[string]any); ok {
+ envelope = content
+ }
+ raw, present := envelope["success"]
+ if !present {
+ return nil, Error(operation, "missing_success", "响应缺少 success 业务状态,无法证明调用成功")
+ }
+ success, ok := raw.(bool)
+ if !ok {
+ return nil, Error(operation, "malformed_success", "响应 success 字段不是布尔值")
+ }
+ if !success {
+ message := firstString(envelope, "errorMessage", "errorMsg", "message", "error")
+ if message == "" {
+ message = "服务明确返回 success=false"
+ }
+ return nil, Error(operation, "remote_failure", message)
+ }
+ return envelope, nil
+}
+
+// RequireResult returns the explicit non-null result value from a successful
+// response. Empty arrays and empty objects remain valid values; absence/null do
+// not prove an empty business result.
+func RequireResult(data map[string]any, operation string) (any, error) {
+ envelope, err := RequireSuccess(data, operation)
+ if err != nil {
+ return nil, err
+ }
+ result, present := envelope["result"]
+ if !present || result == nil {
+ return nil, Error(operation, "missing_result", "成功响应缺少非空 result 字段")
+ }
+ return result, nil
+}
+
+// RequireObjectResult returns an explicit object result.
+func RequireObjectResult(data map[string]any, operation string) (map[string]any, error) {
+ result, err := RequireResult(data, operation)
+ if err != nil {
+ return nil, err
+ }
+ object, ok := result.(map[string]any)
+ if !ok {
+ return nil, Error(operation, "malformed_result", fmt.Sprintf("响应 result 应为对象,实际为 %T", result))
+ }
+ return object, nil
+}
+
+// RequireSingleObjectResult returns one non-empty business object. It accepts
+// the two reviewed detail shapes used by product services: result={...} and
+// result=[{...}]. Empty arrays, multiple rows, empty objects, and scalar values
+// cannot prove that the requested unique resource was read.
+func RequireSingleObjectResult(data map[string]any, operation string) (map[string]any, error) {
+ result, err := RequireResult(data, operation)
+ if err != nil {
+ return nil, err
+ }
+ var object map[string]any
+ switch typed := result.(type) {
+ case map[string]any:
+ object = typed
+ case []any:
+ if len(typed) != 1 {
+ return nil, Error(operation, "unexpected_detail_count", fmt.Sprintf("详情响应应唯一,实际返回 %d 项", len(typed)))
+ }
+ var ok bool
+ object, ok = typed[0].(map[string]any)
+ if !ok {
+ return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result[0] 应为对象,实际为 %T", typed[0]))
+ }
+ default:
+ return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result 应为对象或单元素对象数组,实际为 %T", result))
+ }
+ if len(object) == 0 {
+ return nil, Error(operation, "empty_result_object", "成功响应的详情对象为空,无法证明资源读取成功")
+ }
+ return object, nil
+}
+
+// RequireObjectCollection resolves the first present reviewed path, requires
+// an array at that exact path, and rejects malformed elements instead of
+// silently dropping them. A present empty array is a valid empty result.
+func RequireObjectCollection(data map[string]any, operation string, paths ...string) ([]map[string]any, error) {
+ envelope, err := RequireSuccess(data, operation)
+ if err != nil {
+ return nil, err
+ }
+ for _, path := range paths {
+ value, present := lookup(envelope, path)
+ if !present {
+ continue
+ }
+ raw, ok := value.([]any)
+ if !ok {
+ return nil, Error(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
+ }
+ items := make([]map[string]any, 0, len(raw))
+ for index, item := range raw {
+ object, ok := item.(map[string]any)
+ if !ok || len(object) == 0 {
+ return nil, Error(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
+ }
+ items = append(items, object)
+ }
+ return items, nil
+ }
+ return nil, Error(operation, "missing_collection", "成功响应缺少已审核的结果数组;不能把未知响应结构当作空结果")
+}
+
+// LookupObject returns the object at a reviewed dotted path.
+func LookupObject(data map[string]any, operation, path string) (map[string]any, error) {
+ value, present := lookup(data, path)
+ if !present {
+ return nil, Error(operation, "missing_object", fmt.Sprintf("响应缺少 %s 对象", path))
+ }
+ object, ok := value.(map[string]any)
+ if !ok {
+ return nil, Error(operation, "malformed_object", fmt.Sprintf("响应 %s 应为对象,实际为 %T", path, value))
+ }
+ return object, nil
+}
+
+func lookup(data map[string]any, path string) (any, bool) {
+ var current any = data
+ for _, segment := range strings.Split(path, ".") {
+ object, ok := current.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ current, ok = object[segment]
+ if !ok {
+ return nil, false
+ }
+ }
+ return current, true
+}
+
+func firstString(object map[string]any, keys ...string) string {
+ for _, key := range keys {
+ if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
diff --git a/internal/shortcut/responsecheck/responsecheck_test.go b/internal/shortcut/responsecheck/responsecheck_test.go
new file mode 100644
index 00000000..41a5c3c2
--- /dev/null
+++ b/internal/shortcut/responsecheck/responsecheck_test.go
@@ -0,0 +1,161 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package responsecheck
+
+import "testing"
+
+func TestCrossPlatformCoverageRequireObjectCollection(t *testing.T) {
+ tests := []struct {
+ name string
+ data map[string]any
+ wantLen int
+ wantErr bool
+ }{
+ {name: "non-empty", data: map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}, wantLen: 1},
+ {name: "content envelope", data: map[string]any{"content": map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}}, wantLen: 1},
+ {name: "explicit empty", data: map[string]any{"success": true, "result": []any{}}, wantLen: 0},
+ {name: "missing reviewed path", data: map[string]any{"success": true}, wantErr: true},
+ {name: "missing collection", data: map[string]any{"success": true, "result": map[string]any{}}, wantErr: true},
+ {name: "wrong collection type", data: map[string]any{"success": true, "result": "bad"}, wantErr: true},
+ {name: "malformed item", data: map[string]any{"success": true, "result": []any{"bad"}}, wantErr: true},
+ {name: "empty item", data: map[string]any{"success": true, "result": []any{map[string]any{}}}, wantErr: true},
+ {name: "missing success", data: map[string]any{"result": []any{}}, wantErr: true},
+ {name: "remote failure", data: map[string]any{"success": false, "result": []any{}}, wantErr: true},
+ {name: "empty response", data: map[string]any{}, wantErr: true},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := RequireObjectCollection(tc.data, "test/read", "result")
+ if (err != nil) != tc.wantErr {
+ t.Fatalf("error = %v, wantErr %v", err, tc.wantErr)
+ }
+ if err == nil && len(got) != tc.wantLen {
+ t.Fatalf("len = %d, want %d", len(got), tc.wantLen)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageRequireObjectCollectionNested(t *testing.T) {
+ data := map[string]any{
+ "success": true,
+ "result": map[string]any{
+ "items": []any{map[string]any{"id": "one"}},
+ },
+ }
+ items, err := RequireObjectCollection(data, "test/nested", "result.items")
+ if err != nil {
+ t.Fatalf("RequireObjectCollection: %v", err)
+ }
+ if len(items) != 1 {
+ t.Fatalf("items = %#v", items)
+ }
+}
+
+func TestCrossPlatformCoverageRequireResultRejectsNull(t *testing.T) {
+ if _, err := RequireResult(map[string]any{"success": true, "result": nil}, "test/null"); err == nil {
+ t.Fatal("expected null result to fail closed")
+ }
+ if _, err := RequireObjectResult(map[string]any{"success": true, "result": []any{}}, "test/object"); err == nil {
+ t.Fatal("expected array result to fail object validation")
+ }
+}
+
+func TestCrossPlatformCoverageRequireSingleObjectResult(t *testing.T) {
+ for _, data := range []map[string]any{
+ {"success": true, "result": map[string]any{"id": "one"}},
+ {"success": true, "result": []any{map[string]any{"id": "one"}}},
+ } {
+ object, err := RequireSingleObjectResult(data, "test/detail")
+ if err != nil || object["id"] != "one" {
+ t.Fatalf("valid detail shape rejected: object=%v err=%v", object, err)
+ }
+ }
+ for _, data := range []map[string]any{
+ {},
+ {"success": true, "result": map[string]any{}},
+ {"success": true, "result": []any{}},
+ {"success": true, "result": []any{map[string]any{"id": "one"}, map[string]any{"id": "two"}}},
+ {"success": true, "result": []any{"bad"}},
+ {"success": true, "result": "bad"},
+ } {
+ if object, err := RequireSingleObjectResult(data, "test/detail"); err == nil {
+ t.Fatalf("ambiguous/malformed detail returned success: %v", object)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageResponseEnvelopeFailures(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ data map[string]any
+ }{
+ {name: "missing success", data: map[string]any{"result": map[string]any{}}},
+ {name: "malformed success", data: map[string]any{"success": "true"}},
+ {name: "failure with explicit message", data: map[string]any{"success": false, "errorMsg": " reviewed failure "}},
+ {name: "failure without message", data: map[string]any{"success": false, "error": 42}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := RequireSuccess(tc.data, "test/envelope"); err == nil {
+ t.Fatal("expected fail-closed envelope validation")
+ }
+ })
+ }
+
+ if _, err := RequireResult(map[string]any{}, "test/result"); err == nil {
+ t.Fatal("expected upstream envelope failure")
+ }
+ if _, err := RequireResult(map[string]any{"success": true}, "test/result"); err == nil {
+ t.Fatal("expected missing result failure")
+ }
+ if object, err := RequireObjectResult(map[string]any{"success": true, "result": map[string]any{"id": "one"}}, "test/object"); err != nil || object["id"] != "one" {
+ t.Fatalf("valid object result rejected: object=%v err=%v", object, err)
+ }
+ if _, err := RequireObjectResult(map[string]any{}, "test/object"); err == nil {
+ t.Fatal("expected upstream object envelope failure")
+ }
+}
+
+func TestCrossPlatformCoverageLookupObject(t *testing.T) {
+ data := map[string]any{
+ "result": map[string]any{
+ "detail": map[string]any{"id": "one"},
+ "scalar": "bad",
+ },
+ }
+ object, err := LookupObject(data, "test/lookup", "result.detail")
+ if err != nil || object["id"] != "one" {
+ t.Fatalf("valid nested object rejected: object=%v err=%v", object, err)
+ }
+ if _, err := LookupObject(data, "test/lookup", "result.missing"); err == nil {
+ t.Fatal("expected missing nested object failure")
+ }
+ if _, err := LookupObject(data, "test/lookup", "result.scalar"); err == nil {
+ t.Fatal("expected malformed nested object failure")
+ }
+ if _, present := lookup(map[string]any{"result": "scalar"}, "result.detail"); present {
+ t.Fatal("scalar parent must not satisfy a nested lookup")
+ }
+}
+
+func TestCrossPlatformCoverageCollectionPathFallbackAndHelpers(t *testing.T) {
+ items, err := RequireObjectCollection(
+ map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"id": "one"}}}},
+ "test/fallback",
+ "result.records",
+ "result.items",
+ )
+ if err != nil || len(items) != 1 {
+ t.Fatalf("reviewed path fallback failed: items=%v err=%v", items, err)
+ }
+ if _, err := RequireObjectCollection(map[string]any{}, "test/fallback", "result"); err == nil {
+ t.Fatal("expected collection envelope failure")
+ }
+ if got := firstString(map[string]any{"first": 7, "second": " ", "third": " value "}, "first", "second", "third"); got != "value" {
+ t.Fatalf("firstString = %q, want value", got)
+ }
+ if got := firstString(map[string]any{"only": " "}, "only"); got != "" {
+ t.Fatalf("firstString whitespace = %q, want empty", got)
+ }
+}
diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go
index fbb5b312..60aa42c6 100644
--- a/internal/shortcut/semantic_catalog.go
+++ b/internal/shortcut/semantic_catalog.go
@@ -34,6 +34,21 @@ var calendarSemanticCatalogJSON []byte
//go:embed semantic_catalog_todo.json
var todoSemanticCatalogJSON []byte
+//go:embed semantic_catalog_attendance.json
+var attendanceSemanticCatalogJSON []byte
+
+//go:embed semantic_catalog_mail.json
+var mailSemanticCatalogJSON []byte
+
+//go:embed semantic_catalog_oa.json
+var oaSemanticCatalogJSON []byte
+
+//go:embed semantic_catalog_ding.json
+var dingSemanticCatalogJSON []byte
+
+//go:embed semantic_catalog_report.json
+var reportSemanticCatalogJSON []byte
+
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -42,13 +57,14 @@ type semanticCatalogFile struct {
}
type semanticCatalogRecord struct {
- Disposition SemanticDisposition `json:"disposition"`
- SemanticDelta string `json:"semantic_delta"`
- Risk Risk `json:"risk"`
- Availability Availability `json:"availability,omitempty"`
- Primary string `json:"primary,omitempty"`
- Public bool `json:"public"`
- Reviewed bool `json:"reviewed"`
+ Disposition SemanticDisposition `json:"disposition"`
+ SemanticDelta string `json:"semantic_delta"`
+ Risk Risk `json:"risk"`
+ Availability Availability `json:"availability,omitempty"`
+ Primary string `json:"primary,omitempty"`
+ Public bool `json:"public"`
+ CompatibilityVisible bool `json:"compatibility_visible,omitempty"`
+ Reviewed bool `json:"reviewed"`
}
var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
@@ -60,6 +76,11 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
wikiSemanticCatalogJSON,
calendarSemanticCatalogJSON,
todoSemanticCatalogJSON,
+ attendanceSemanticCatalogJSON,
+ mailSemanticCatalogJSON,
+ oaSemanticCatalogJSON,
+ dingSemanticCatalogJSON,
+ reportSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -120,6 +141,14 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) {
panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q",
command, record.Availability))
}
+ // A command that was already part of the visible CLI contract cannot be
+ // hidden merely because Agent publication is withdrawn. Compatibility
+ // visibility owns only historical CLI discovery; availability independently
+ // records whether that compatibility path still executes. public=false keeps
+ // both available and unavailable compatibility leaves out of Agent routes.
+ if record.CompatibilityVisible && record.Public {
+ panic(fmt.Sprintf("semantic catalog command %q cannot be both public and compatibility-visible", command))
+ }
key := publicCatalogKey(source.Service, command)
if _, exists := out[key]; exists {
panic(fmt.Sprintf("duplicate shortcut semantic catalog entry %s %s", source.Service, command))
@@ -138,6 +167,7 @@ func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) {
s.Availability = record.Availability
s.PrimaryCommand = record.Primary
s.SemanticReviewed = record.Reviewed
- s.Hidden = !record.Public
+ s.CompatibilityVisible = record.CompatibilityVisible
+ s.Hidden = !record.Public && !record.CompatibilityVisible
return s, true
}
diff --git a/internal/shortcut/semantic_catalog_attendance.json b/internal/shortcut/semantic_catalog_attendance.json
new file mode 100644
index 00000000..ab478fcf
--- /dev/null
+++ b/internal/shortcut/semantic_catalog_attendance.json
@@ -0,0 +1,44 @@
+{
+ "version": 1,
+ "service": "attendance",
+ "default_availability": "unavailable",
+ "shortcuts": {
+ "+check-result": {"disposition":"semantic_adapter","semantic_delta":"对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+check-record": {"disposition":"semantic_adapter","semantic_delta":"查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+list-approve": {"disposition":"semantic_adapter","semantic_delta":"把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+get-approve-template": {"disposition":"semantic_adapter","semantic_delta":"把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"两次独立 clean HEAD 的已知非空与保证零命中请求中,getScheduleByRange 均以退出码 0 返回 literal null;Shortcut 正确非零拒绝,但下游没有提供可验证的显式排班数组或 typed error,修复前不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+search-class": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+get-class": {"disposition":"schema_leaf","semantic_delta":"真实搜索 classId 可取得非空详情,但 get_class_detail 的 shiftVO 不回显 id/classId,无法诚实证明详情属于请求 ID;等待下游补充稳定 ID 回显。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
+ "+search-adjustment-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+search-overtime-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+get-overtime-rule": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+get-summary": {"disposition":"schema_leaf","semantic_delta":"真实响应只返回统计项,未回显请求用户、周期或统计类型,无法将结果精确绑定到请求;在下游补齐身份回显前保持不可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"前五个设置场景可严格绑定用户和返回类型,但 bossAttendStatNotify 在当前安全身份下返回 NO_PERMISSION;缺少 capability discovery、逐场景权限合同和安全权限 fixture,暂不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有非空假期类型列表且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+my-attendance": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的今天时间窗只返回合法空集合;缺少已知非空当天打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+this-month": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的本月时间窗只返回合法空集合;缺少已知非空本月打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+
+ "+get-adjustment-rule": {"disposition":"schema_leaf","semantic_delta":"真实搜索返回规则,但详情接口对搜索得到的候选主键明确 success=true 且 result=null;在下游修复主键或详情返回前不可宣称可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+search-group": {"disposition":"semantic_adapter","semantic_delta":"严格投影已实现,但当前安全账号只有显式空结果,缺少已知非空考勤组 fixture,不能完成真实数据证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+query-report-data": {"disposition":"semantic_adapter","semantic_delta":"报表列发现接口返回 null,无法取得已验证 columnId;使用未验证列只得到空数组,不能证明报表查询正确。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+list-report-columns": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,既不是列数组也不是可证明的合法空结果。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
+ "+query-report-leave": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,无法证明假期报表任务成功或确实为空。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
+ "+get-global-setting": {"disposition":"schema_leaf","semantic_delta":"读取企业全局设置需要当前测试身份没有的管理员权限;缺少最小权限 fixture 前保持不可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
+ "+get-leave-balance": {"disposition":"schema_leaf","semantic_delta":"使用真实用户和已发现假期编码仍无法得到可验证结果;下游需补齐稳定响应契约或可控余额 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
+ "+get-group": {"disposition":"schema_leaf","semantic_delta":"详情读取需要稳定考勤组 ID;当前搜索没有已知非空安全 fixture,不能完成精确身份读回。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
+ "+get-group-filtered": {"disposition":"schema_leaf","semantic_delta":"过滤详情同样依赖稳定考勤组 ID;当前没有安全非空 fixture 可验证字段选择和身份绑定。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
+
+ "+import-schedule": {"disposition":"schema_leaf","semantic_delta":"会改写员工排班;缺少隔离考勤组、班次、成员和写后读回/清理 fixture,不能安全开放。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+create-class": {"disposition":"schema_leaf","semantic_delta":"会创建企业班次;缺少隔离命名、稳定创建回执、精确详情读回和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+update-class": {"disposition":"schema_leaf","semantic_delta":"会影响使用该班次的员工;缺少可恢复测试班次和严格更新后读回证据。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+update-group-members": {"disposition":"schema_leaf","semantic_delta":"会改变考勤组成员范围;缺少隔离成员 fixture、逐项结果和可验证回滚。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+create-group": {"disposition":"schema_leaf","semantic_delta":"会创建考勤组并影响打卡规则;缺少安全班次、成员、稳定回执和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+update-group": {"disposition":"schema_leaf","semantic_delta":"会修改考勤组规则;当前无安全非空考勤组 fixture 和完整读回/恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+update-leave-type": {"disposition":"schema_leaf","semantic_delta":"会修改企业假期规则并影响适用员工;缺少隔离假期类型和精确恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+save-leave-balance": {"disposition":"schema_leaf","semantic_delta":"会直接改写员工假期余额;缺少隔离员工、幂等写入语义、余额读回和补偿恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+boss-check": {"disposition":"schema_leaf","semantic_delta":"会直接改写打卡结果;缺少隔离排班/结果 fixture、强确认、精确终态读回和恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}
+ }
+}
diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go
index 61d00569..df822179 100644
--- a/internal/shortcut/semantic_catalog_coverage_test.go
+++ b/internal/shortcut/semantic_catalog_coverage_test.go
@@ -121,6 +121,21 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T)
}
}
}`,
+ "compatibility-visible public": `{
+ "version": 1,
+ "service": "chat",
+ "default_availability": "available",
+ "shortcuts": {
+ "+messages": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "reviewed",
+ "risk": "read",
+ "public": true,
+ "compatibility_visible": true,
+ "reviewed": true
+ }
+ }
+ }`,
}
original := semanticCatalogJSON
t.Cleanup(func() { semanticCatalogJSON = original })
@@ -172,6 +187,38 @@ func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testin
}
}
+func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) {
+ item, ok := applyReviewedSemanticCatalog(Shortcut{Service: "attendance", Command: "+get-summary"})
+ if !ok || item.Hidden || !item.CompatibilityVisible || item.Availability != AvailabilityUnavailable {
+ t.Fatalf("compatibility-visible semantic item = %#v, found=%v", item, ok)
+ }
+ if InPublicCatalog(item.Service, item.Command) {
+ t.Fatal("compatibility-visible unavailable shortcut entered the public catalog")
+ }
+
+ available := map[string]semanticCatalogRecord{}
+ loadSemanticCatalog([]byte(`{
+ "version":1,
+ "service":"compatibility-test",
+ "default_availability":"unavailable",
+ "shortcuts":{
+ "+legacy":{
+ "disposition":"semantic_adapter",
+ "semantic_delta":"historical CLI path remains executable but is not Agent-public",
+ "risk":"write",
+ "availability":"available",
+ "public":false,
+ "compatibility_visible":true,
+ "reviewed":true
+ }
+ }
+ }`), available)
+ record := available[publicCatalogKey("compatibility-test", "+legacy")]
+ if record.Public || !record.CompatibilityVisible || record.Availability != AvailabilityAvailable {
+ t.Fatalf("available compatibility record = %#v", record)
+ }
+}
+
func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) {
caller := &runtimeReadCoverageCaller{}
old := helpers.GetCaller()
diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json
new file mode 100644
index 00000000..7c1565d5
--- /dev/null
+++ b/internal/shortcut/semantic_catalog_ding.json
@@ -0,0 +1,48 @@
+{
+ "version": 1,
+ "service": "ding",
+ "default_availability": "unavailable",
+ "shortcuts": {
+ "+list": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "严格验证 success、result.dingMessages、稳定 openDingId 与 hasMore/nextCursor;exact Shortcut 已完成已知非空与保证零项证明,但真实续页返回停滞 cursor,严格实现拒绝虚假分页并保持不可用。",
+ "risk": "read",
+ "public": false,
+ "compatibility_visible": true,
+ "reviewed": true
+ },
+ "+receiver-status": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
+ "risk": "read",
+ "availability": "available",
+ "public": true,
+ "reviewed": true
+ },
+ "+send-personal": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行,并修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;因接收项没有稳定接收人身份且撤回无可查询终态,不进入 Agent 公共发现。",
+ "risk": "write",
+ "availability": "available",
+ "public": false,
+ "compatibility_visible": true,
+ "reviewed": true
+ },
+ "+send-by-message": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "覆盖基于消息的应用内、短信和电话 DING并映射 lark-cli 三种 urgent 任务;隔离 APP fixture 已证明源消息、稳定 DING 回执和目标 DING 读回,但接收人身份与撤回终态仍不可验证,保持不可用。",
+ "risk": "write",
+ "public": false,
+ "reviewed": true
+ },
+ "+recall-personal": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行;个人 DING 撤回只返回布尔成功且不回显终态,不能作为 Agent 可验证写能力,因此不进入公共发现。",
+ "risk": "high-risk-write",
+ "availability": "available",
+ "public": false,
+ "compatibility_visible": true,
+ "reviewed": true
+ }
+ }
+}
diff --git a/internal/shortcut/semantic_catalog_mail.json b/internal/shortcut/semantic_catalog_mail.json
new file mode 100644
index 00000000..8e3b260a
--- /dev/null
+++ b/internal/shortcut/semantic_catalog_mail.json
@@ -0,0 +1,25 @@
+{
+ "version": 1,
+ "service": "mail",
+ "default_availability": "unavailable",
+ "shortcuts": {
+ "+triage": {"disposition":"primary_smart","semantic_delta":"对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+search-mail": {"disposition":"semantic_adapter","semantic_delta":"用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+unread-mail": {"disposition":"primary_smart","semantic_delta":"固定 isRead:false 查询缺少可控 guaranteed-zero 条件;当前没有专用空邮箱 fixture,且不得修改真实邮件已读状态制造空结果,因此保持 unavailable。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+recent-mail": {"disposition":"primary_smart","semantic_delta":"严格读取 result.conversations、稳定会话 ID 与分页证据;但当前全部可见文件夹都有会话,缺少已验证空文件夹 fixture,无法完成双态发布审计。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+message": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+messages": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+thread": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations、稳定 ID 和分页;但当前全部可见文件夹都有会话,且时间过滤未提供可靠零命中,缺少已验证空文件夹 fixture。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+folder-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;但入口没有 query/parent 过滤且没有专用空邮箱 fixture,无法证明 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+user-search": {"disposition":"semantic_adapter","semantic_delta":"搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+find-mail-user": {"disposition":"primary_smart","semantic_delta":"面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。","risk":"read","availability":"available","public":true,"reviewed":true},
+ "+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;但入口没有 query,模板删除后也没有 typed nonfound,不能把列表消失当 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID 和分页;但入口没有 query,且 create/delete 缺稳定身份闭环,无法安全构造 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
+ "+draft-create": {"disposition":"primary_smart","semantic_delta":"已实现确认、稳定 messageId 回执和精确读回;但真实 fixture 连续两次 batch-delete 后仍能按同 ID 读取,无法证明安全清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+draft-edit": {"disposition":"primary_smart","semantic_delta":"已实现至少一项变更、严格回执和按原 messageId 读回;但草稿删除后仍可读,缺少可隔离且可证明零残留的真实 fixture。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+template-create": {"disposition":"primary_smart","semantic_delta":"核心字段可按稳定模板 ID 读回,已移除无法验证的 from/isDraft 新入口;但 delete 后 get 仅返回未分类失败,没有 typed nonfound 证据可证明清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
+ "+template-update": {"disposition":"primary_smart","semantic_delta":"真实更新回执仅含明确 success,上层使用请求稳定 ID 精确读回核对变更;但模板删除后缺少 typed nonfound,无法证明安全 fixture 零残留。","risk":"write","availability":"unavailable","public":false,"reviewed":true}
+ }
+}
diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json
new file mode 100644
index 00000000..0aee7c46
--- /dev/null
+++ b/internal/shortcut/semantic_catalog_oa.json
@@ -0,0 +1,96 @@
+{
+ "version": 1,
+ "service": "oa",
+ "default_availability": "unavailable",
+ "shortcuts": {
+ "+list-executed": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝,因此不进入 Agent 公开发现。",
+ "risk": "read",
+ "availability": "available",
+ "public": false,
+ "compatibility_visible": true,
+ "reviewed": true
+ },
+ "+list-submitted": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,因此不进入 Agent 公开发现。",
+ "risk": "read",
+ "availability": "available",
+ "public": false,
+ "compatibility_visible": true,
+ "reviewed": true
+ },
+ "+my-initiated": {
+ "disposition": "alias_internal",
+ "semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持非公开。",
+ "risk": "read",
+ "availability": "available",
+ "primary": "+list-submitted",
+ "public": false,
+ "compatibility_visible": true,
+ "reviewed": true
+ },
+ "+search-forms": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
+ "risk": "read",
+ "availability": "available",
+ "public": true,
+ "reviewed": true
+ },
+ "+done-approvals": {
+ "disposition": "alias_internal",
+ "semantic_delta": "保留旧的首屏已处理审批摘要;已验证严格非空结果,但不提供页码,新的 Agent 路由统一使用 +list-executed。",
+ "risk": "read",
+ "availability": "available",
+ "primary": "+list-executed",
+ "public": false,
+ "reviewed": true
+ },
+ "+list-forms": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失、畸形、停滞或倒退游标,下游修复前不进入 Agent 公开发现。",
+ "risk": "read",
+ "availability": "available",
+ "public": false,
+ "compatibility_visible": true,
+ "reviewed": true
+ },
+ "+list-pending": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture,因此不进入 Agent 公开发现。",
+ "risk": "read",
+ "availability": "available",
+ "public": false,
+ "compatibility_visible": true,
+ "reviewed": true
+ },
+ "+pending": {
+ "disposition": "alias_internal",
+ "semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均保持非公开。",
+ "risk": "read",
+ "availability": "available",
+ "primary": "+list-pending",
+ "public": false,
+ "reviewed": true
+ },
+ "+list-cc": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture,因此不进入 Agent 公开发现。",
+ "risk": "read",
+ "availability": "available",
+ "public": false,
+ "compatibility_visible": true,
+ "reviewed": true
+ },
+ "+approve-by": {
+ "disposition": "primary_smart",
+ "semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,因此不进入 Agent 公开发现。",
+ "risk": "high-risk-write",
+ "availability": "available",
+ "public": false,
+ "reviewed": true
+ }
+ }
+}
diff --git a/internal/shortcut/semantic_catalog_report.json b/internal/shortcut/semantic_catalog_report.json
new file mode 100644
index 00000000..59f11983
--- /dev/null
+++ b/internal/shortcut/semantic_catalog_report.json
@@ -0,0 +1,39 @@
+{
+ "version": 1,
+ "service": "report",
+ "default_availability": "unavailable",
+ "shortcuts": {
+ "+inbox-list": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
+ "risk": "read",
+ "availability": "available",
+ "public": true,
+ "reviewed": true
+ },
+ "+outbox-list": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
+ "risk": "read",
+ "availability": "available",
+ "public": true,
+ "reviewed": true
+ },
+ "+template-search": {
+ "disposition": "semantic_adapter",
+ "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
+ "risk": "read",
+ "availability": "available",
+ "public": true,
+ "reviewed": true
+ },
+ "+report-latest": {
+ "disposition": "primary_smart",
+ "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
+ "risk": "read",
+ "availability": "available",
+ "public": true,
+ "reviewed": true
+ }
+ }
+}
diff --git a/internal/shortcut/smart/approve.go b/internal/shortcut/smart/approve.go
deleted file mode 100644
index 9ad9a36b..00000000
--- a/internal/shortcut/smart/approve.go
+++ /dev/null
@@ -1,277 +0,0 @@
-// Copyright 2026 Alibaba Group
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package smart
-
-import (
- "fmt"
- "strconv"
- "strings"
- "time"
-
- apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
-)
-
-// Approve: agree to ONE of MY pending OA approvals by matching a keyword in its
-// title / order number, in a single command (钉钉原生编排能力).
-//
-// Steps (all tool names + params copied verbatim from helpers/oa.go):
-//
-// 1. list my pending approvals via list_pending_approvals (starTime / endTime
-// as float64 millis over a recent window, plus query=keyword for
-// server-side narrowing — mirroring `dws oa approval list-pending`);
-//
-// 2. scan the returned instances and keep the ones whose title / order number
-// contains --keyword: none → "没找到待审批单据", many → list candidates
-// (标题 + processInstanceId) so the caller can be more specific, exactly one
-// → take its processInstanceId;
-//
-// 3. resolve the pending taskId for that instance via list_pending_tasks
-// (processInstanceId), then agree via approve_processInstance
-// (processInstanceId + taskId as float64, optional remark) —
-// mirroring `dws oa approval tasks` + `dws oa approval approve`.
-//
-// dws oa +approve-by --keyword 报销
-// dws oa +approve-by --keyword 出差单 --comment "同意"
-var Approve = shortcut.Shortcut{
- Service: "oa",
- Command: "+approve-by",
- Product: "oa",
- Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)",
- Intent: "当你只记得某张待你审批单据的标题或单号关键词、想直接把它审批通过,却不想先翻待办列表、" +
- "复制审批实例 ID 再查任务 ID 时使用;内部先拉取你近三个月待处理的审批单,按标题/单号包含关键词匹配:" +
- "没匹配到会提示「没找到待审批单据」,匹配到多条会列出候选(标题+实例ID)让你写得更精确," +
- "唯一命中时先取出它的待审批任务 ID,再以「同意」提交。这会真实提交审批决策且不可撤回,请确认关键词足够精确。",
- Risk: shortcut.RiskWrite,
- Flags: []shortcut.Flag{
- {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true},
- {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)", Required: false},
- },
- Tips: []string{
- `dws oa +approve-by --keyword 报销`,
- `dws oa +approve-by --keyword 出差单 --comment "同意"`,
- },
- Execute: func(rt *shortcut.RuntimeContext) error {
- keyword := strings.TrimSpace(rt.Str("keyword"))
- if keyword == "" {
- return apperrors.NewValidation("请用 --keyword 提供待审批单据的单号或标题关键词")
- }
-
- // Step 1 — list my pending approvals. starTime/endTime are float64
- // milliseconds and query narrows server-side, mirroring
- // helpers.list-pending (list_pending_approvals). Window: last ~90 days.
- now := time.Now()
- listArgs := map[string]any{
- "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()),
- "endTime": float64(now.UnixMilli()),
- "query": keyword,
- }
- data, err := rt.CallMCPData("oa", "list_pending_approvals", listArgs)
- if err != nil {
- return err
- }
-
- // Step 2 — match by title / order number substring.
- matches := shortcutApproveMatch(data, keyword)
- switch {
- case len(matches) == 0:
- return apperrors.NewValidation(fmt.Sprintf(
- "没找到待审批单据:待我处理的审批里没有标题/单号包含 %q 的单据。", keyword))
- case len(matches) > 1:
- return apperrors.NewValidation(fmt.Sprintf(
- "%q 匹配到 %d 条待审批单据,请用更精确的关键词,或用 `dws oa approval approve --instance-id --task-id` 指定:%s",
- keyword, len(matches), strings.Join(shortcutApproveLabels(matches), ";")))
- }
-
- instanceID := matches[0].instanceID
-
- // Step 3a — resolve the pending taskId for this instance via
- // list_pending_tasks (processInstanceId), mirroring `dws oa approval tasks`.
- tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{
- "processInstanceId": instanceID,
- })
- if err != nil {
- return err
- }
- taskID := shortcutApproveTaskID(tasksData)
- if taskID == "" {
- return apperrors.NewValidation(fmt.Sprintf(
- "没能拿到 %q 的待审批任务 ID,请用 `dws oa approval tasks --instance-id %s` 查看后手动 approve。",
- matches[0].title, instanceID))
- }
-
- // Step 3b — agree. taskId is passed as float64 and remark is optional,
- // mirroring helpers.approve (approve_processInstance).
- taskIDNum, err := strconv.ParseFloat(taskID, 64)
- if err != nil {
- return apperrors.NewValidation(fmt.Sprintf("任务 ID %q 不是合法数字,无法审批。", taskID))
- }
- approveArgs := map[string]any{
- "processInstanceId": instanceID,
- "taskId": taskIDNum,
- }
- if c := strings.TrimSpace(rt.Str("comment")); c != "" {
- approveArgs["remark"] = c
- }
- return rt.CallMCP("approve_processInstance", approveArgs)
- },
-}
-
-// shortcutApproveInstance is the minimal identity we need from a pending-approval
-// entry: its processInstanceId (fed to approve/tasks) and a human title/order
-// number for matching + disambiguation.
-type shortcutApproveInstance struct {
- instanceID string
- title string
-}
-
-// shortcutApproveMatch walks a list_pending_approvals response and returns the
-// instances whose title / order number contains keyword (case-insensitive). The
-// gateway wraps the list under one of several common container keys, so we probe
-// them defensively (mirroring latestMinutesItems / shortcutTodoCards).
-func shortcutApproveMatch(data map[string]any, keyword string) []shortcutApproveInstance {
- items := shortcutApproveItems(data)
- kw := strings.ToLower(keyword)
- var out []shortcutApproveInstance
- seen := map[string]bool{}
- for _, m := range items {
- id := shortcutApproveInstanceID(m)
- if id == "" || seen[id] {
- continue
- }
- title := shortcutApproveTitle(m)
- // Match on either the visible title/order number or the instance id.
- if !strings.Contains(strings.ToLower(title), kw) &&
- !strings.Contains(strings.ToLower(id), kw) {
- continue
- }
- seen[id] = true
- if title == "" {
- title = id
- }
- out = append(out, shortcutApproveInstance{instanceID: id, title: title})
- }
- return out
-}
-
-// shortcutApproveItems pulls the list of pending-approval entries out of the
-// response, probing common container shapes before scanning for a bare list.
-func shortcutApproveItems(data map[string]any) []map[string]any {
- for _, key := range []string{"result", "list", "instances", "items", "data", "records", "processList"} {
- if arr, ok := data[key].([]any); ok {
- return shortcutApproveToMaps(arr)
- }
- if inner, ok := data[key].(map[string]any); ok {
- for _, k2 := range []string{"list", "instances", "items", "records", "result", "processList"} {
- if arr, ok := inner[k2].([]any); ok {
- return shortcutApproveToMaps(arr)
- }
- }
- }
- }
- return nil
-}
-
-func shortcutApproveToMaps(arr []any) []map[string]any {
- out := make([]map[string]any, 0, len(arr))
- for _, it := range arr {
- if m, ok := it.(map[string]any); ok {
- out = append(out, m)
- }
- }
- return out
-}
-
-// shortcutApproveInstanceID reads an entry's processInstanceId (the value the
-// helper feeds to --instance-id), tolerating a few common field names and both
-// string / numeric JSON encodings.
-func shortcutApproveInstanceID(m map[string]any) string {
- for _, key := range []string{"processInstanceId", "processInstanceID", "instanceId", "businessId"} {
- if s := shortcutApproveStr(m[key]); s != "" {
- return s
- }
- }
- return ""
-}
-
-// shortcutApproveTitle reads a human-facing title / order number for matching
-// and disambiguation, probing common field names.
-func shortcutApproveTitle(m map[string]any) string {
- for _, key := range []string{"title", "processTitle", "name", "subject", "businessId", "processCode"} {
- if s := shortcutApproveStr(m[key]); s != "" {
- return s
- }
- }
- return ""
-}
-
-// shortcutApproveTaskID walks a list_pending_tasks response and returns the
-// first pending taskId (the value the helper feeds to --task-id).
-func shortcutApproveTaskID(data map[string]any) string {
- items := shortcutApproveItems(data)
- // list_pending_tasks may also return a bare {"result": [...]}/task list; the
- // probe above covers those. Some gateways return a single task object.
- if len(items) == 0 {
- if id := shortcutApproveTaskIDField(data); id != "" {
- return id
- }
- if r, ok := data["result"].(map[string]any); ok {
- if id := shortcutApproveTaskIDField(r); id != "" {
- return id
- }
- }
- return ""
- }
- for _, m := range items {
- if id := shortcutApproveTaskIDField(m); id != "" {
- return id
- }
- }
- return ""
-}
-
-func shortcutApproveTaskIDField(m map[string]any) string {
- for _, key := range []string{"taskId", "taskID", "activityId"} {
- if s := shortcutApproveStr(m[key]); s != "" {
- return s
- }
- }
- return ""
-}
-
-// shortcutApproveStr coerces a JSON value into a string, tolerating string and
-// numeric encodings (taskId / instance id can arrive as either).
-func shortcutApproveStr(v any) string {
- switch t := v.(type) {
- case string:
- return t
- case float64:
- return strconv.FormatFloat(t, 'f', -1, 64)
- case int64:
- return strconv.FormatInt(t, 10)
- }
- return ""
-}
-
-func shortcutApproveLabels(items []shortcutApproveInstance) []string {
- out := make([]string, 0, len(items))
- for _, c := range items {
- out = append(out, fmt.Sprintf("%s(instanceId=%s)", c.title, c.instanceID))
- }
- return out
-}
-
-func init() {
- shortcut.Register(Approve)
-}
diff --git a/internal/shortcut/smart/attendance_strict.go b/internal/shortcut/smart/attendance_strict.go
new file mode 100644
index 00000000..12ebddf8
--- /dev/null
+++ b/internal/shortcut/smart/attendance_strict.go
@@ -0,0 +1,62 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package smart
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
+)
+
+func attendanceRecordsResult() *contract.ResultSpec {
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ DataSchema: json.RawMessage(`{"type":"object","description":"严格校验的个人考勤打卡流水","properties":{"count":{"type":"integer","description":"有效打卡流水数量"},"records":{"type":"array","description":"个人打卡流水","items":{"type":"object","description":"打卡流水记录","additionalProperties":true}},"complete":{"type":"boolean","description":"当前时间窗内结果是否完整"}},"required":["count","records","complete"],"additionalProperties":false}`),
+ }
+}
+
+func outputStrictAttendanceRecords(rt *shortcut.RuntimeContext, data map[string]any) error {
+ records, err := responsecheck.RequireObjectCollection(data, "attendance-wukong/query_check_record", "result")
+ if err != nil {
+ return err
+ }
+ seen := make(map[int64]struct{}, len(records))
+ for index, record := range records {
+ identity, ok := smartAttendancePositiveInteger(record["id"])
+ if !ok {
+ return responsecheck.Error("attendance-wukong/query_check_record", "invalid_item_identity", fmt.Sprintf("第 %d 项缺少大于 0 的稳定打卡 ID", index))
+ }
+ if _, duplicate := seen[identity]; duplicate {
+ return responsecheck.Error("attendance-wukong/query_check_record", "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定打卡 ID 重复", index))
+ }
+ seen[identity] = struct{}{}
+ }
+ return rt.Output(map[string]any{
+ "count": len(records),
+ "records": records,
+ "complete": true,
+ })
+}
+
+func smartAttendancePositiveInteger(value any) (int64, bool) {
+ switch number := value.(type) {
+ case int:
+ return int64(number), number > 0
+ case int32:
+ return int64(number), number > 0
+ case int64:
+ return number, number > 0
+ case float64:
+ return int64(number), number > 0 && number == float64(int64(number))
+ case json.Number:
+ parsed, err := strconv.ParseInt(string(number), 10, 64)
+ return parsed, err == nil && parsed > 0
+ default:
+ return 0, false
+ }
+}
diff --git a/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go
new file mode 100644
index 00000000..cb50b30c
--- /dev/null
+++ b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go
@@ -0,0 +1,245 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package smart
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+func TestCrossPlatformCoverageSmartAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) {
+ for _, declaration := range []struct {
+ name string
+ rollout output.RolloutState
+ hasResult bool
+ availability string
+ }{
+ {MyAttendance.Command, MyAttendance.OutputRollout, MyAttendance.Contract.Result != nil, MyAttendance.Contract.Interface.Availability},
+ {ThisMonthAttendance.Command, ThisMonthAttendance.OutputRollout, ThisMonthAttendance.Contract.Result != nil, ThisMonthAttendance.Contract.Interface.Availability},
+ } {
+ if declaration.rollout != output.RolloutLegacyOnly {
+ t.Errorf("%s rollout = %q, want legacy_only while unavailable", declaration.name, declaration.rollout)
+ }
+ if declaration.hasResult || declaration.availability != contract.InterfaceAvailable {
+ t.Errorf("%s must omit Result and preserve its Schema-compatible non-public Interface", declaration.name)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageSmartAttendanceProfileFailsClosed(t *testing.T) {
+ valid := map[string]any{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}}
+ if got := strictAttendanceCurrentUserID(valid); got != "u1" {
+ t.Fatalf("valid profile identity = %q", got)
+ }
+ invalid := []map[string]any{
+ nil,
+ {"result": map[string]any{"userId": "stale"}},
+ {"success": false, "result": map[string]any{"userId": "stale"}},
+ {"success": true, "result": nil},
+ {"success": true, "result": []any{"malformed", map[string]any{"userId": "u1"}}},
+ {"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"userId": "u2"}}},
+ {"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u2"}}},
+ }
+ for index, profile := range invalid {
+ if got := strictAttendanceCurrentUserID(profile); got != "" {
+ t.Errorf("invalid profile %d resolved %q", index, got)
+ }
+ }
+}
+
+type smartAttendanceProfileCaller struct {
+ profile string
+ attendance string
+ profileErr error
+ attendanceErr error
+ calls []string
+ arguments []map[string]any
+}
+
+func (c *smartAttendanceProfileCaller) CallTool(_ context.Context, server, tool string, arguments map[string]any) (*edition.ToolResult, error) {
+ c.calls = append(c.calls, server+"/"+tool)
+ c.arguments = append(c.arguments, arguments)
+ text := c.profile
+ callErr := c.profileErr
+ if len(c.calls) > 1 {
+ text = c.attendance
+ callErr = c.attendanceErr
+ }
+ if callErr != nil {
+ return nil, callErr
+ }
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
+}
+func (*smartAttendanceProfileCaller) Format() string { return "json" }
+func (*smartAttendanceProfileCaller) DryRun() bool { return false }
+func (*smartAttendanceProfileCaller) Fields() string { return "" }
+func (*smartAttendanceProfileCaller) JQ() string { return "" }
+
+func TestCrossPlatformCoverageSmartAttendanceInvalidProfileMakesZeroAttendanceCalls(t *testing.T) {
+ caller := &smartAttendanceProfileCaller{}
+ helpers.InitDepsForTest(t, caller)
+ for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} {
+ caller.calls = nil
+ caller.arguments = nil
+ caller.profile = `{"success":false,"result":{"userId":"stale"}}`
+ rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration)
+ if err := declaration.Execute(rt); err == nil {
+ t.Errorf("%s accepted success=false profile", declaration.Command)
+ }
+ if len(caller.calls) != 1 || !strings.HasSuffix(caller.calls[0], "/get_current_user_profile") {
+ t.Errorf("%s calls = %#v, want profile call only", declaration.Command, caller.calls)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageSmartAttendanceStrictResolverShapes(t *testing.T) {
+ valid := []map[string]any{
+ {"success": true, "result": map[string]any{"userId": " u1 "}},
+ {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}},
+ {"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u1"}}},
+ {"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}},
+ }
+ for index, profile := range valid {
+ if got := strictAttendanceCurrentUserID(profile); got != "u1" {
+ t.Errorf("valid profile %d resolved %q", index, got)
+ }
+ }
+ invalid := []map[string]any{
+ {"success": true, "result": []any{}},
+ {"success": true, "result": map[string]any{}},
+ {"success": true, "result": map[string]any{"userId": 1}},
+ {"success": true, "result": map[string]any{"userId": " "}},
+ {"success": true, "result": map[string]any{"orgEmployeeModel": "bad"}},
+ {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": 1}}},
+ {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": " "}}},
+ {"success": true, "result": true},
+ }
+ for index, profile := range invalid {
+ if got := strictAttendanceCurrentUserID(profile); got != "" {
+ t.Errorf("invalid profile %d resolved %q", index, got)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageSmartAttendanceRecordIdentityFailsClosed(t *testing.T) {
+ for _, data := range []map[string]any{
+ {"success": true, "result": []any{map[string]any{"id": 0}}},
+ {"success": true, "result": []any{map[string]any{"id": ""}}},
+ {"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}},
+ } {
+ if records, err := responsecheck.RequireObjectCollection(data, "attendance/test", "result"); err == nil {
+ seen := map[int64]struct{}{}
+ valid := true
+ for _, record := range records {
+ identity, ok := smartAttendancePositiveInteger(record["id"])
+ if !ok {
+ valid = false
+ break
+ }
+ if _, duplicate := seen[identity]; duplicate {
+ valid = false
+ break
+ }
+ seen[identity] = struct{}{}
+ }
+ if valid {
+ t.Fatalf("invalid records accepted: %#v", data)
+ }
+ }
+ }
+}
+
+func TestCrossPlatformCoverageSmartAttendanceRecordOutputContract(t *testing.T) {
+ result := attendanceRecordsResult()
+ if result == nil || len(result.Outcomes) != 2 || !json.Valid(result.DataSchema) {
+ t.Fatalf("invalid strict attendance result declaration: %#v", result)
+ }
+
+ for _, value := range []any{int(1), int32(2), int64(3), float64(4), json.Number("5")} {
+ if _, ok := smartAttendancePositiveInteger(value); !ok {
+ t.Errorf("valid record identity rejected: %#v", value)
+ }
+ }
+ for _, value := range []any{int(0), int32(-1), int64(0), float64(1.5), json.Number("bad"), "1", nil} {
+ if _, ok := smartAttendancePositiveInteger(value); ok {
+ t.Errorf("invalid record identity accepted: %#v", value)
+ }
+ }
+
+ rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "attendance-test"}, shortcut.Shortcut{})
+ invalid := []map[string]any{
+ nil,
+ {"success": false, "result": []any{}},
+ {"success": true, "result": nil},
+ {"success": true, "result": []any{"bad"}},
+ {"success": true, "result": []any{map[string]any{"id": 0}}},
+ {"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}},
+ }
+ for index, data := range invalid {
+ if err := outputStrictAttendanceRecords(rt, data); err == nil {
+ t.Errorf("invalid strict output %d accepted", index)
+ }
+ }
+ if err := outputStrictAttendanceRecords(rt, map[string]any{"success": true, "result": []any{map[string]any{"id": int64(1)}}}); err != nil {
+ t.Fatalf("valid strict output rejected: %v", err)
+ }
+}
+
+func TestCrossPlatformCoverageSmartAttendanceExecuteSuccessAndFailures(t *testing.T) {
+ validProfile := `{"success":true,"result":{"userId":"u1"}}`
+ validAttendance := `{"success":true,"result":[{"id":1}]}`
+ for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} {
+ t.Run(declaration.Command+" success", func(t *testing.T) {
+ caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: validAttendance}
+ helpers.InitDepsForTest(t, caller)
+ rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration)
+ if err := declaration.Execute(rt); err != nil {
+ t.Fatal(err)
+ }
+ if len(caller.calls) != 2 || !strings.HasSuffix(caller.calls[1], "/query_check_record") {
+ t.Fatalf("calls=%#v", caller.calls)
+ }
+ request, ok := caller.arguments[1]["QueryCheckRecordRequest"].(map[string]any)
+ users, usersOK := request["userIds"].([]string)
+ if !ok || !usersOK || len(users) != 1 || users[0] != "u1" || strings.TrimSpace(fmt.Sprint(request["checkDateFrom"])) == "" || strings.TrimSpace(fmt.Sprint(request["checkDateTo"])) == "" {
+ t.Fatalf("request=%#v", caller.arguments[1])
+ }
+ })
+ t.Run(declaration.Command+" profile call error", func(t *testing.T) {
+ caller := &smartAttendanceProfileCaller{profileErr: fmt.Errorf("profile unavailable")}
+ helpers.InitDepsForTest(t, caller)
+ rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration)
+ if err := declaration.Execute(rt); err == nil || len(caller.calls) != 1 {
+ t.Fatalf("err=%v calls=%#v", err, caller.calls)
+ }
+ })
+ t.Run(declaration.Command+" attendance call error", func(t *testing.T) {
+ caller := &smartAttendanceProfileCaller{profile: validProfile, attendanceErr: fmt.Errorf("attendance unavailable")}
+ helpers.InitDepsForTest(t, caller)
+ rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration)
+ if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 {
+ t.Fatalf("err=%v calls=%#v", err, caller.calls)
+ }
+ })
+ t.Run(declaration.Command+" malformed attendance", func(t *testing.T) {
+ caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: `{"success":true,"result":null}`}
+ helpers.InitDepsForTest(t, caller)
+ rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration)
+ if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 {
+ t.Fatalf("err=%v calls=%#v", err, caller.calls)
+ }
+ })
+ }
+}
diff --git a/internal/shortcut/smart/calendar_shared.go b/internal/shortcut/smart/calendar_shared.go
index a50709cf..96bee89e 100644
--- a/internal/shortcut/smart/calendar_shared.go
+++ b/internal/shortcut/smart/calendar_shared.go
@@ -23,6 +23,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat"
)
const calendarSmartMaxPages = 20
@@ -215,6 +216,7 @@ func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string
if !hasMore && next != "" {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空")
}
+ list, _ = calendarcompat.NormalizeTerminalEmptyEvents(list, result)
events := make([]map[string]any, 0, len(list))
for index, item := range list {
event, ok := item.(map[string]any)
diff --git a/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go
index a5b989cf..eb65e7da 100644
--- a/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go
+++ b/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go
@@ -148,6 +148,18 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
t.Fatalf("explicit empty page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
}
+ items, hasMore, next, err = calendarSmartEventPage(map[string]any{
+ "success": true,
+ "result": map[string]any{
+ "events": []any{map[string]any{
+ "attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil,
+ }},
+ "hasMore": false,
+ },
+ })
+ if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
+ t.Fatalf("service empty sentinel items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
+ }
invalidPages := []map[string]any{
{},
@@ -157,6 +169,7 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": true}},
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": false, "nextCursor": "unexpected"}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "event-placeholder"}}, "hasMore": false}},
+ {"success": true, "result": map[string]any{"events": []any{map[string]any{"summary": nil}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{"bad-item"}, "hasMore": false}},
}
for index, page := range invalidPages {
diff --git a/internal/shortcut/smart/done_approvals.go b/internal/shortcut/smart/done_approvals.go
deleted file mode 100644
index f75b51fe..00000000
--- a/internal/shortcut/smart/done_approvals.go
+++ /dev/null
@@ -1,109 +0,0 @@
-// Copyright 2026 Alibaba Group
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package smart
-
-import (
- apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
-)
-
-// DoneApprovals: READ-ONLY list of the approval tasks I have already processed
-// (my approval history), projected to clean fields. Unlike `oa +approve-by`
-// (which agrees to an approval) or `oa +pending` (which lists what still waits
-// for me), this shortcut only looks back at what I've already handled — it never
-// approves / rejects / mutates anything.
-//
-// Steps (tool name + param keys copied verbatim from helpers/oa.go):
-//
-// 1. list my done approvals via get_done_tasks with pageNumber / pageSize as
-// float64, mirroring `dws oa approval list-executed`; optional --limit maps
-// to pageSize (float64). This tool takes NO time window (unlike
-// list_pending_approvals) — only pageNumber/pageSize/query.
-//
-// 2. defensively unwrap the returned instance list (reusing shortcutApproveItems
-// from approve.go, multiple candidate container keys + one nested level) and
-// project each entry to a readable shape {title, originatorName,
-// processInstanceId, createTime}, every field probed across candidate keys.
-//
-// 3. if nothing has been processed, report "没有已处理的审批记录" instead of an
-// empty raw dump.
-//
-// dws oa +done-approvals
-// dws oa +done-approvals --limit 10
-var DoneApprovals = shortcut.Shortcut{
- Service: "oa",
- Command: "+done-approvals",
- Product: "oa",
- Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表",
- Intent: "当你只想快速回看「我已经处理过(同意/拒绝)」的审批任务历史——每条的标题、发起人、审批实例 ID 和创建时间——" +
- "而不想拿到一大坨原始字段时使用;内部拉取你的已办审批单,再在本地投影出可读字段。" +
- "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改任何审批;" +
- "若没有任何已处理记录则提示「没有已处理的审批记录」。",
- Risk: shortcut.RiskRead,
- Flags: []shortcut.Flag{
- {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false},
- },
- Tips: []string{
- `dws oa +done-approvals`,
- `dws oa +done-approvals --limit 10`,
- },
- Execute: func(rt *shortcut.RuntimeContext) error {
- // Step 1 — list my done approvals. pageNumber/pageSize are float64. Keys
- // copied verbatim from helpers.list-executed (get_done_tasks). This tool
- // carries no time window; pageNumber defaults to the first page.
- // pageSize is required by get_done_tasks — the 1:1 list-executed always
- // sends one (a missing/zero pageSize triggers a backend business error),
- // so default to 20 and let --limit override.
- params := map[string]any{
- "pageNumber": float64(1),
- "pageSize": float64(20),
- }
- if rt.Changed("limit") {
- if n := rt.Int("limit"); n > 0 {
- params["pageSize"] = float64(n)
- }
- }
- data, err := rt.CallMCPData("oa", "get_done_tasks", params)
- if err != nil {
- return err
- }
-
- // Step 2 — project entries. shortcutApproveItems (approve.go) defensively
- // unwraps the list container (result/list/instances/items/data/records/
- // processList, incl. one nested level).
- items := shortcutApproveItems(data)
- results := make([]map[string]any, 0, len(items))
- for _, m := range items {
- results = append(results, map[string]any{
- // shortcutApproveTitle probes title/processTitle/name/subject/…
- "title": shortcutApproveTitle(m),
- // shortcutApproveInstanceID probes processInstanceId/instanceId/…
- "processInstanceId": shortcutApproveInstanceID(m),
- "originatorName": pendingApprovalsOriginator(m),
- "createTime": pendingApprovalsCreateTime(m),
- })
- }
-
- // Step 3 — empty result guard.
- if len(results) == 0 {
- return apperrors.NewValidation("没有已处理的审批记录")
- }
-
- return rt.Output(map[string]any{"count": len(results), "done": results})
- },
-}
-
-func init() {
- shortcut.Register(DoneApprovals)
-}
diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go
index 98d2be5f..db2fe0d2 100644
--- a/internal/shortcut/smart/find_mail_user.go
+++ b/internal/shortcut/smart/find_mail_user.go
@@ -15,11 +15,11 @@ package smart
import (
"strconv"
+ "strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
- apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -71,6 +71,13 @@ var FindMailUser = shortcut.Shortcut{
PrimaryCLIPath: "mail +find-mail-user",
},
Description: "按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等)",
+ Parameters: []contract.ParamDecl{
+ // Keep the published composite Shortcut property stable. Execute owns
+ // the explicit query -> keyword adapter for search_mail_users.
+ {Name: "query", Property: "query"},
+ {Name: "limit", Property: "limit"},
+ {Name: "cursor", Property: "cursor"},
+ },
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -87,8 +94,19 @@ var FindMailUser = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
- {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填)", Required: true},
- {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选)", Required: false},
+ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填且不能为空)", Required: true},
+ {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选,1-100)", Required: false},
+ {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false},
+ },
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"},
+ },
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if err := smartMailValidateRequiredText(rt, "query"); err != nil {
+ return err
+ }
+ return smartMailValidatePageSize(rt, "limit", false)
},
Tips: []string{
`dws mail +find-mail-user --query "张三"`,
@@ -101,69 +119,109 @@ var FindMailUser = shortcut.Shortcut{
if rt.Changed("limit") {
args["size"] = strconv.Itoa(rt.Int("limit"))
}
+ if rt.Changed("cursor") {
+ args["cursor"] = rt.Str("cursor")
+ }
data, err := rt.CallMCPData("mail", "search_mail_users", args)
if err != nil {
return err
}
// Step 2 — project matched users.
- users := findMailUserUnwrap(data)
+ users, err := smartMailCollection(data, "mail/search_mail_users", "users")
+ if err != nil {
+ return err
+ }
results := make([]map[string]any, 0, len(users))
for _, u := range users {
- results = append(results, map[string]any{
- "name": findMailUserString(u, "name", "displayName", "userName", "nick"),
- "nickname": findMailUserString(u, "nickname", "nick", "displayName"),
- "email": findMailUserString(u, "email", "mail", "emailAddress", "address", "account"),
- "employeeNo": findMailUserAny(u, "employeeNo", "employeeNumber", "jobNumber"),
- "jobTitle": findMailUserString(u, "jobTitle", "title", "position"),
- "workLocation": findMailUserString(u, "workLocation", "location", "workPlace"),
- "id": findMailUserAny(u, "id", "userId", "userid"),
- })
+ projected, err := findMailUserProjection(u)
+ if err != nil {
+ return err
+ }
+ results = append(results, projected)
}
- // Step 3 — empty result guard.
- if len(results) == 0 {
- return apperrors.NewValidation("没搜到邮箱联系人")
+ complete, next, err := smartMailPage(data, "mail/search_mail_users", "", rt.Str("cursor"))
+ if err != nil {
+ return err
}
-
- return rt.Output(map[string]any{"users": results, "count": len(results)})
+ return smartMailOutputPage(rt, "users", results, complete, next)
},
}
-// findMailUserUnwrap extracts the user list from a search_mail_users response.
-// The helper documents the list under "users"; we probe several container keys
-// at the top level and one level deep, defensively.
-func findMailUserUnwrap(data map[string]any) []map[string]any {
- keys := []string{"users", "contacts", "result", "data", "list", "items", "records"}
- for _, key := range keys {
- if arr, ok := data[key].([]any); ok {
- return findMailUserToMaps(arr)
- }
- if inner, ok := data[key].(map[string]any); ok {
- for _, k2 := range keys {
- if arr, ok := inner[k2].([]any); ok {
- return findMailUserToMaps(arr)
- }
- }
- }
+func findMailUserProjection(user map[string]any) (map[string]any, error) {
+ result := map[string]any{
+ "name": findMailUserString(user, "name", "displayName", "userName", "nick"),
+ "nickname": findMailUserString(user, "nickname", "nick", "displayName"),
+ "employeeNo": findMailUserAny(user, "employeeNo", "employeeNumber", "jobNumber"),
+ "jobTitle": findMailUserString(user, "jobTitle", "title", "position"),
+ "workLocation": findMailUserString(user, "workLocation", "location", "workPlace"),
}
- return nil
+ email, emailPresent, err := findMailUserIdentityString(user, "email", "mail", "emailAddress", "address", "account")
+ if err != nil {
+ return nil, err
+ }
+ if emailPresent {
+ result["email"] = email
+ }
+ id, idPresent, err := findMailUserIdentity(user, "id", "userId", "userid")
+ if err != nil {
+ return nil, err
+ }
+ if idPresent {
+ result["id"] = id
+ }
+ if !emailPresent && !idPresent {
+ return nil, smartMailError("mail/search_mail_users", "missing_item_identity", "邮箱用户缺少非空 id 或 email")
+ }
+ return result, nil
}
-func findMailUserToMaps(arr []any) []map[string]any {
- out := make([]map[string]any, 0, len(arr))
- for _, it := range arr {
- if m, ok := it.(map[string]any); ok {
- out = append(out, m)
+func findMailUserIdentityString(m map[string]any, keys ...string) (string, bool, error) {
+ for _, key := range keys {
+ value, present := m[key]
+ if !present || value == nil {
+ continue
+ }
+ text, ok := value.(string)
+ if !ok {
+ return "", false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 email 必须是字符串")
+ }
+ text = strings.TrimSpace(text)
+ if text != "" {
+ return text, true, nil
}
}
- return out
+ return "", false, nil
+}
+
+func findMailUserIdentity(m map[string]any, keys ...string) (any, bool, error) {
+ for _, key := range keys {
+ value, present := m[key]
+ if !present || value == nil {
+ continue
+ }
+ if text, ok := value.(string); ok {
+ text = strings.TrimSpace(text)
+ if text != "" {
+ return text, true, nil
+ }
+ continue
+ }
+ switch value.(type) {
+ case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
+ return value, true, nil
+ default:
+ return nil, false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 id 必须是字符串或数字")
+ }
+ }
+ return nil, false, nil
}
func findMailUserString(m map[string]any, keys ...string) string {
for _, key := range keys {
- if s, ok := m[key].(string); ok && s != "" {
- return s
+ if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" {
+ return strings.TrimSpace(s)
}
}
return ""
@@ -172,8 +230,11 @@ func findMailUserString(m map[string]any, keys ...string) string {
func findMailUserAny(m map[string]any, keys ...string) any {
for _, key := range keys {
if v, ok := m[key]; ok && v != nil {
- if s, isStr := v.(string); isStr && s == "" {
- continue
+ if s, isStr := v.(string); isStr {
+ if strings.TrimSpace(s) == "" {
+ continue
+ }
+ return strings.TrimSpace(s)
}
return v
}
@@ -182,5 +243,6 @@ func findMailUserAny(m map[string]any, keys ...string) any {
}
func init() {
+ hardenSmartMail(&FindMailUser, "users", "严格校验的邮箱用户搜索结果")
shortcut.Register(FindMailUser)
}
diff --git a/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go
new file mode 100644
index 00000000..bd62a5f7
--- /dev/null
+++ b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go
@@ -0,0 +1,389 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package smart
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "reflect"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func smartMailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext {
+ t.Helper()
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ for name, value := range values {
+ if err := cmd.Flags().Set(name, value); err != nil {
+ t.Fatalf("set --%s: %v", name, err)
+ }
+ }
+ return shortcut.RuntimeContextForTest(cmd, declaration)
+}
+
+func TestCrossPlatformCoverageSmartMailStrictWireBranches(t *testing.T) {
+ for _, declaration := range []shortcut.Shortcut{SearchMail, FindMailUser} {
+ rt := smartMailRuntimeForCoverage(t, declaration, map[string]string{"query": " "})
+ if err := declaration.Validate(rt); err == nil {
+ t.Fatal("direct required query validator accepted whitespace")
+ }
+ }
+ if err := smartMailSuccess(map[string]any{}, "mail/test"); err == nil {
+ t.Fatal("empty response unexpectedly succeeded")
+ }
+ if err := smartMailSuccess(map[string]any{"success": false}, "mail/test"); err == nil {
+ t.Fatal("explicit failure unexpectedly succeeded")
+ }
+ if err := smartMailSuccess(map[string]any{"success": "true"}, "mail/test"); err != nil {
+ t.Fatalf("string success rejected: %v", err)
+ }
+ if _, ok := smartMailLookup(map[string]any{"result": "bad"}, "result.items"); ok {
+ t.Fatal("non-object lookup unexpectedly succeeded")
+ }
+ if _, err := smartMailRows(map[string]any{}, "mail/test", "items", "id"); err == nil {
+ t.Fatal("rows accepted invalid response")
+ }
+ if _, err := smartMailRows(map[string]any{"success": true, "items": []any{map[string]any{"name": "missing id"}}}, "mail/test", "items", "id"); err == nil {
+ t.Fatal("rows accepted missing identity")
+ }
+
+ items, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": 0, "nextCursor": "$"}, "mail/search")
+ if err != nil || len(items) != 0 {
+ t.Fatalf("explicit empty search rejected: %v %v", items, err)
+ }
+ if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": -1, "nextCursor": "$"}, "mail/search"); err == nil {
+ t.Fatal("negative total unexpectedly succeeded")
+ }
+ if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{map[string]any{"subject": "missing id"}}, "total": 1, "nextCursor": "$"}, "mail/search"); err == nil {
+ t.Fatal("search row without id unexpectedly succeeded")
+ }
+ if smartMailEmptySearchSentinel(map[string]any{}) {
+ t.Fatal("empty object treated as reviewed sentinel")
+ }
+ if !smartMailEmptySearchSentinel(map[string]any{"tags": nil}) {
+ t.Fatal("reviewed null-only sentinel rejected")
+ }
+
+ for _, tc := range []struct {
+ value any
+ want int
+ ok bool
+ }{
+ {value: 7, want: 7, ok: true},
+ {value: float64(8), want: 8, ok: true},
+ {value: 1.5, ok: false},
+ {value: "9", want: 9, ok: true},
+ {value: []any{}, ok: false},
+ } {
+ got, ok := smartMailInt(tc.value)
+ if got != tc.want || ok != tc.ok {
+ t.Fatalf("smartMailInt(%#v)=(%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok)
+ }
+ }
+
+ for name, data := range map[string]map[string]any{
+ "missing prefix": {},
+ "malformed prefix": {"result": []any{}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, _, err := smartMailPage(data, "mail/page", "result", ""); err == nil {
+ t.Fatal("invalid prefixed page unexpectedly succeeded")
+ }
+ })
+ }
+ for name, data := range map[string]map[string]any{
+ "missing cursor": {},
+ "wrong cursor": {"nextCursor": 7},
+ "wrong hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}},
+ "conflicting true": {"nextCursor": "", "hasMore": true},
+ "conflicting false": {"nextCursor": "cursor-2", "hasMore": false},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, _, err := smartMailPage(data, "mail/page", "", ""); err == nil {
+ t.Fatal("invalid page unexpectedly succeeded")
+ }
+ })
+ }
+ if complete, next, err := smartMailPage(map[string]any{"nextCursor": "$"}, "mail/page", "", ""); err != nil || !complete || next != "" {
+ t.Fatalf("terminal page rejected: %v %q %v", complete, next, err)
+ }
+ if complete, next, err := smartMailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" {
+ t.Fatalf("continuing page rejected: %v %q %v", complete, next, err)
+ }
+ if _, _, err := smartMailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil {
+ t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded")
+ }
+ if value, ok := smartMailBool("true"); !ok || !value {
+ t.Fatal("string true not normalized")
+ }
+ if value, ok := smartMailBool("false"); !ok || value {
+ t.Fatal("string false not normalized")
+ }
+ if _, ok := smartMailBool(7); ok {
+ t.Fatal("invalid boolean unexpectedly normalized")
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailSchemaAndOutputBoundaries(t *testing.T) {
+ assertPanic := func(name string, fn func()) {
+ t.Helper()
+ t.Run(name, func(t *testing.T) {
+ defer func() {
+ if recover() == nil {
+ t.Fatal("unsupported schema did not panic")
+ }
+ }()
+ fn()
+ })
+ }
+ assertPanic("sensitive collection", func() { smartMailSensitivePaths("unknown") })
+ assertPanic("identity collection", func() { smartMailIdentitySchema("unknown") })
+
+ declaration := SearchMail
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ cmd.SetOut(io.Discard)
+ rt := shortcut.RuntimeContextForTest(cmd, declaration)
+ if err := smartMailOutputPage(rt, "messages", nil, true, "unexpected-next"); err == nil {
+ t.Fatal("invalid terminal pagination unexpectedly stored")
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailSenderAndProjectionVariants(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ data map[string]any
+ want string
+ }{
+ {name: "top string", data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}}, want: "mail@example.invalid"},
+ {name: "top object", data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}, want: "mail@example.invalid"},
+ {name: "result string", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"},
+ {name: "result object", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"},
+ {name: "data string", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"},
+ {name: "data object", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"},
+ } {
+ t.Run("mailbox "+tc.name, func(t *testing.T) {
+ got, err := searchMailMailboxAddresses(tc.data)
+ if err != nil || len(got) != 1 || got[0] != tc.want {
+ t.Fatalf("addresses=%#v want=%q err=%v", got, tc.want, err)
+ }
+ })
+ }
+ for name, data := range map[string]map[string]any{
+ "remote failure": {"success": false},
+ "missing": {"success": true},
+ "malformed object": {"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": 7}}}},
+ "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}},
+ "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "data": map[string]any{"emailAccounts": []any{"two@example.invalid"}}},
+ } {
+ t.Run("mailbox "+name, func(t *testing.T) {
+ if _, err := searchMailMailboxAddresses(data); err == nil {
+ t.Fatal("invalid mailbox response unexpectedly succeeded")
+ }
+ })
+ }
+
+ for _, tc := range []struct {
+ message map[string]any
+ want any
+ }{
+ {message: map[string]any{"from": "sender@example.invalid"}, want: "sender@example.invalid"},
+ {message: map[string]any{"from": map[string]any{"name": "Sender", "email": "sender@example.invalid"}}, want: "Sender "},
+ {message: map[string]any{"from": map[string]any{"email": "sender@example.invalid"}}, want: "sender@example.invalid"},
+ {message: map[string]any{"from": map[string]any{"name": "Sender"}}, want: "Sender"},
+ {message: map[string]any{"sender": "sender@example.invalid"}, want: "sender@example.invalid"},
+ {message: map[string]any{"from": " ", "sender": "sender@example.invalid"}, want: "sender@example.invalid"},
+ } {
+ got, err := searchMailFrom(tc.message)
+ if err != nil || got != tc.want {
+ t.Fatalf("sender=%#v want=%#v err=%v", got, tc.want, err)
+ }
+ }
+ for _, message := range []map[string]any{{}, {"from": " "}, {"from": nil}, {"from": map[string]any{}}, {"from": 7}} {
+ if _, err := searchMailFrom(message); err == nil {
+ t.Fatal("malformed sender unexpectedly succeeded")
+ }
+ }
+ if got := searchMailFirstString(map[string]any{"id": " stable-id "}, "id"); got != "stable-id" {
+ t.Fatalf("string normalization=%q", got)
+ }
+ if got := searchMailFirstAny(map[string]any{"first": " ", "second": " value "}, "first", "second"); got != "value" {
+ t.Fatalf("any string normalization=%#v", got)
+ }
+ if got := searchMailFirstAny(map[string]any{"value": 7}, "value"); got != 7 {
+ t.Fatalf("numeric projection=%#v", got)
+ }
+
+ projected, err := findMailUserProjection(map[string]any{
+ "id": " user-1 ", "email": " user@example.invalid ", "name": " User ", "nickname": " Nick ",
+ "employeeNo": " E1 ", "jobTitle": " Engineer ", "workLocation": " HQ ",
+ })
+ if err != nil || projected["id"] != "user-1" || projected["email"] != "user@example.invalid" || projected["name"] != "User" || projected["employeeNo"] != "E1" {
+ t.Fatalf("normalized user projection=%#v err=%v", projected, err)
+ }
+ if got := findMailUserAny(map[string]any{"first": " ", "second": 7}, "first", "second"); got != 7 {
+ t.Fatalf("find user any=%#v", got)
+ }
+ if got := findMailUserAny(map[string]any{}, "missing"); got != nil {
+ t.Fatalf("missing optional value=%#v", got)
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailLeafFailureBoundaries(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ declaration shortcut.Shortcut
+ args []string
+ responses map[string]string
+ errors map[string]error
+ wantCalls int
+ }{
+ {name: "search mailbox malformed", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1},
+ {name: "search mailbox blank string", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[" "]}`}, wantCalls: 1},
+ {name: "search mailbox malformed item", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[7]}`}, wantCalls: 1},
+ {name: "search mailbox empty", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[]}`}, wantCalls: 1},
+ {name: "search rows malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1},
+ {name: "search sender malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1},
+ {name: "search page missing", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1},
+ {name: "find rows malformed", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":{}}`}, wantCalls: 1},
+ {name: "find page missing", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1"}]}`}, wantCalls: 1},
+ {name: "triage inbox folders malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"list_folders": `{"success":true,"folders":{}}`}, wantCalls: 1},
+ {name: "triage rows malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1},
+ {name: "triage sender malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1},
+ {name: "triage page missing", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1},
+ {name: "triage remote failure", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{}, errors: map[string]error{"search_emails": fmt.Errorf("search transport")}, wantCalls: 1},
+ {name: "triage mailbox failure", declaration: TriageMail, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1},
+ {name: "recent rows malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":{}}}`}, wantCalls: 1},
+ {name: "recent sender malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{}]}]}}`}, wantCalls: 1},
+ {name: "recent page malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"nextCursor":7}}`}, wantCalls: 1},
+ {name: "unread rows malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1},
+ {name: "unread sender malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1},
+ {name: "unread page missing", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &smartMailCursorCaller{responses: tc.responses, errors: tc.errors}
+ if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil {
+ t.Fatal("invalid leaf response unexpectedly succeeded")
+ }
+ if len(caller.calls) != tc.wantCalls {
+ t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailInboxAndSenderFallbacks(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ message map[string]any
+ want any
+ wantErr bool
+ }{
+ {name: "name and address", message: map[string]any{"senders": []any{map[string]any{"name": "Sender", "email": "sender@example.invalid"}}}, want: []string{"Sender "}},
+ {name: "address only", message: map[string]any{"senders": []any{map[string]any{"email": "sender@example.invalid"}}}, want: []string{"sender@example.invalid"}},
+ {name: "name only", message: map[string]any{"senders": []any{map[string]any{"name": "Sender"}}}, want: []string{"Sender"}},
+ {name: "wrong sender collection", message: map[string]any{"senders": "bad"}, wantErr: true},
+ {name: "fallback sender", message: map[string]any{"senders": []any{}, "from": "sender@example.invalid"}, want: "sender@example.invalid"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := recentMailSenders(tc.message)
+ if tc.wantErr && err == nil {
+ t.Fatal("malformed senders unexpectedly succeeded")
+ }
+ if !tc.wantErr && err != nil {
+ t.Fatalf("valid senders failed: %v", err)
+ }
+ if !tc.wantErr && !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("senders=%#v want=%#v", got, tc.want)
+ }
+ })
+ }
+
+ for _, tc := range []struct {
+ name string
+ declaration shortcut.Shortcut
+ args []string
+ responses map[string]string
+ wantErr bool
+ wantCalls int
+ }{
+ {
+ name: "triage resolves mailbox and inbox", declaration: TriageMail,
+ responses: map[string]string{
+ "list_user_mailboxes": `{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`,
+ "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`,
+ "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`,
+ }, wantCalls: 3,
+ },
+ {
+ name: "search resolves wrapped string mailbox", declaration: SearchMail, args: []string{"--query", "fixture"},
+ responses: map[string]string{
+ "list_user_mailboxes": `{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`,
+ "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`,
+ }, wantCalls: 2,
+ },
+ {
+ name: "triage resolves string mailbox and inbox", declaration: TriageMail,
+ responses: map[string]string{
+ "list_user_mailboxes": `{"success":true,"emailAccounts":["mail@example.invalid"]}`,
+ "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`,
+ "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`,
+ }, wantCalls: 3,
+ },
+ {
+ name: "recent resolves inbox", declaration: RecentMail, args: []string{"--email", "mail@example.invalid"},
+ responses: map[string]string{
+ "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`,
+ "list_mailbox_threads": `{"success":true,"result":{"conversations":[],"hasMore":false,"nextCursor":""}}`,
+ }, wantCalls: 2,
+ },
+ {
+ name: "inbox not found", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"},
+ responses: map[string]string{"list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Archive"}]}`},
+ wantErr: true, wantCalls: 1,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &smartMailCursorCaller{responses: tc.responses}
+ err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...)
+ if tc.wantErr && err == nil {
+ t.Fatal("invalid inbox resolution unexpectedly succeeded")
+ }
+ if !tc.wantErr && err != nil {
+ t.Fatalf("valid inbox resolution failed: %v", err)
+ }
+ if len(caller.calls) != tc.wantCalls {
+ t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls)
+ }
+ })
+ }
+
+ for _, tc := range []struct {
+ name string
+ response string
+ reason string
+ }{
+ {name: "blank sender", response: `{"success":true,"messages":[{"id":"message-1","from":" "}],"hasMore":false,"nextCursor":""}`, reason: "malformed_sender"},
+ {name: "missing sender", response: `{"success":true,"messages":[{"id":"message-1"}],"hasMore":false,"nextCursor":""}`, reason: "missing_sender"},
+ } {
+ t.Run(tc.name+" typed reason", func(t *testing.T) {
+ caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": tc.response}}
+ err := runSmartMailDeclaration(t, SearchMail, caller, "--email", "mail@example.invalid", "--query", "fixture")
+ typed, ok := err.(*apperrors.Error)
+ if err == nil || !ok || typed.Reason != tc.reason || len(caller.calls) != 1 {
+ reason := ""
+ if ok {
+ reason = typed.Reason
+ }
+ t.Fatalf("err=%v reason=%q calls=%d", err, reason, len(caller.calls))
+ }
+ })
+ }
+}
diff --git a/internal/shortcut/smart/mail_space_todo_projection_test.go b/internal/shortcut/smart/mail_space_todo_projection_test.go
index ba41a90c..fd18632e 100644
--- a/internal/shortcut/smart/mail_space_todo_projection_test.go
+++ b/internal/shortcut/smart/mail_space_todo_projection_test.go
@@ -31,34 +31,6 @@ import (
var errStubTool = errors.New("stub tool error")
-// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with
-// an email field and bare string arrays, each at the top level or one level
-// under a result/data wrapper. The nested bare-string case regressed silently
-// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly
-// demand an explicit --email.
-func TestSearchMailPickMailbox(t *testing.T) {
- cases := []struct {
- name, raw, want string
- }{
- {"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"},
- {"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"},
- {"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"},
- {"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"},
- {"empty", `{"result":{"emailAccounts":[]}}`, ""},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- var data map[string]any
- if err := json.Unmarshal([]byte(tc.raw), &data); err != nil {
- t.Fatal(err)
- }
- if got := searchMailPickMailbox(data); got != tc.want {
- t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want)
- }
- })
- }
-}
-
// TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces
// container is probed — otherwise +resolve-space silently finds no candidates.
func TestResolveSpaceItemsWikiSpaces(t *testing.T) {
@@ -142,7 +114,7 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error
// "provide --email" validation error rather than silently proceeding.
func TestSmartSearchMailNoMailbox(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{
- "list_user_mailboxes": `{"result":{"emailAccounts":[]}}`,
+ "list_user_mailboxes": `{"success":"true","emailAccounts":[]}`,
}}
err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json")
if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") {
@@ -200,8 +172,8 @@ func TestCreatedTodosProjectsCards(t *testing.T) {
// the no-mailbox validation error because emailAccounts was not probed.
func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{
- "list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`,
- "search_emails": `{"result":{"messages":[{"subject":"weekly report","from":"a@x.com"}]}}`,
+ "list_user_mailboxes": `{"success":"true","emailAccounts":[{"email":"me@example.com"}]}`,
+ "search_emails": `{"success":"true","messages":[{"id":"message-1","subject":"weekly report","from":"a@x.com"}],"nextCursor":""}`,
}}
out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:weekly report", "--format", "json")
if strings.Contains(out, "未找到可用邮箱") || out == "" {
diff --git a/internal/shortcut/smart/mail_strict.go b/internal/shortcut/smart/mail_strict.go
new file mode 100644
index 00000000..0240bbff
--- /dev/null
+++ b/internal/shortcut/smart/mail_strict.go
@@ -0,0 +1,340 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package smart
+
+import (
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+func smartMailError(operation, reason, message string) error {
+ return apperrors.NewAPI(message,
+ apperrors.WithOperation(operation),
+ apperrors.WithOrigin("mcp"),
+ apperrors.WithFailureStage("response_validation"),
+ apperrors.WithRetryable(false),
+ apperrors.WithReason(reason),
+ )
+}
+
+func smartMailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error {
+ if !required && !rt.Changed(flag) {
+ return nil
+ }
+ value := rt.Int(flag)
+ if value < 1 || value > 100 {
+ return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag))
+ }
+ return nil
+}
+
+func smartMailStringPageSize(rt *shortcut.RuntimeContext, flag, fallback string) (string, error) {
+ if !rt.Changed(flag) {
+ return fallback, nil
+ }
+ value, err := strconv.Atoi(rt.Str(flag))
+ if err != nil || value < 1 || value > 100 {
+ return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag))
+ }
+ return strconv.Itoa(value), nil
+}
+
+func smartMailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string) error {
+ _, err := smartMailStringPageSize(rt, flag, "")
+ return err
+}
+
+func smartMailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error {
+ if strings.TrimSpace(rt.Str(flag)) == "" {
+ return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag))
+ }
+ return nil
+}
+
+func smartMailSuccess(data map[string]any, operation string) error {
+ if len(data) == 0 {
+ return smartMailError(operation, "empty_tool_response", "服务返回空响应,无法证明结果确实为空")
+ }
+ switch value := data["success"].(type) {
+ case bool:
+ if value {
+ return nil
+ }
+ case string:
+ if value == "true" {
+ return nil
+ }
+ }
+ if _, present := data["success"]; !present {
+ return smartMailError(operation, "missing_success", "响应缺少 success 业务状态")
+ }
+ return smartMailError(operation, "remote_failure", "服务未明确返回成功状态")
+}
+
+func smartMailLookup(data map[string]any, path string) (any, bool) {
+ var current any = data
+ for _, segment := range strings.Split(path, ".") {
+ object, ok := current.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ current, ok = object[segment]
+ if !ok {
+ return nil, false
+ }
+ }
+ return current, true
+}
+
+func smartMailCollection(data map[string]any, operation, path string) ([]map[string]any, error) {
+ if err := smartMailSuccess(data, operation); err != nil {
+ return nil, err
+ }
+ value, present := smartMailLookup(data, path)
+ if !present {
+ return nil, smartMailError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path))
+ }
+ raw, ok := value.([]any)
+ if !ok {
+ return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value))
+ }
+ items := make([]map[string]any, 0, len(raw))
+ for index, item := range raw {
+ object, ok := item.(map[string]any)
+ if !ok || len(object) == 0 {
+ return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index))
+ }
+ items = append(items, object)
+ }
+ return items, nil
+}
+
+func smartMailRows(data map[string]any, operation, path string, identityKeys ...string) ([]map[string]any, error) {
+ items, err := smartMailCollection(data, operation, path)
+ if err != nil {
+ return nil, err
+ }
+ for index, item := range items {
+ if searchMailFirstString(item, identityKeys...) == "" {
+ return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
+ }
+ }
+ return items, nil
+}
+
+// smartMailSearchRows handles the one reviewed Mail-specific zero-hit wire
+// shape observed from search_emails: total="0", terminal nextCursor="$", and
+// exactly one placeholder whose only fields are null recipient metadata. It is
+// deliberately narrower than a general "drop bad item" rule.
+func smartMailSearchRows(data map[string]any, operation string) ([]map[string]any, error) {
+ items, err := smartMailCollection(data, operation, "messages")
+ if err != nil {
+ return nil, err
+ }
+ total, totalOK := smartMailInt(data["total"])
+ next, nextOK := data["nextCursor"].(string)
+ if totalOK && total == 0 && nextOK && (next == "" || next == "$") {
+ if len(items) == 0 {
+ return items, nil
+ }
+ if len(items) == 1 && smartMailEmptySearchSentinel(items[0]) {
+ return []map[string]any{}, nil
+ }
+ return nil, smartMailError(operation, "conflicting_empty_result", "total=0 的搜索响应包含真实或未审核的消息对象")
+ }
+ if totalOK && total < 0 {
+ return nil, smartMailError(operation, "invalid_total", "邮件搜索 total 不能为负数")
+ }
+ for index, item := range items {
+ if searchMailFirstString(item, "id") == "" {
+ return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index))
+ }
+ }
+ return items, nil
+}
+
+func smartMailEmptySearchSentinel(item map[string]any) bool {
+ if len(item) == 0 {
+ return false
+ }
+ allowed := map[string]bool{"toRecipients": true, "ccRecipients": true, "bccRecipients": true, "replyTo": true, "tags": true}
+ for key, value := range item {
+ if !allowed[key] || value != nil {
+ return false
+ }
+ }
+ return true
+}
+
+func smartMailInt(value any) (int, bool) {
+ switch typed := value.(type) {
+ case int:
+ return typed, true
+ case float64:
+ if typed != float64(int(typed)) {
+ return 0, false
+ }
+ return int(typed), true
+ case string:
+ parsed, err := strconv.Atoi(typed)
+ return parsed, err == nil
+ default:
+ return 0, false
+ }
+}
+
+func smartMailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) {
+ container := data
+ if prefix != "" {
+ value, present := smartMailLookup(data, prefix)
+ if !present {
+ return false, "", smartMailError(operation, "missing_pagination", "响应缺少分页对象")
+ }
+ var ok bool
+ container, ok = value.(map[string]any)
+ if !ok {
+ return false, "", smartMailError(operation, "malformed_pagination", "分页对象类型错误")
+ }
+ }
+ raw, present := container["nextCursor"]
+ next, ok := raw.(string)
+ if !present || !ok {
+ return false, "", smartMailError(operation, "missing_pagination", "响应缺少字符串 nextCursor")
+ }
+ if rawMore, present := container["hasMore"]; present {
+ hasMore, ok := smartMailBool(rawMore)
+ if !ok {
+ return false, "", smartMailError(operation, "malformed_pagination", "hasMore 应为布尔值")
+ }
+ terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$"
+ if hasMore == terminal {
+ return false, "", smartMailError(operation, "conflicting_pagination", "hasMore 与 nextCursor 互相矛盾")
+ }
+ if !hasMore {
+ return true, "", nil
+ }
+ if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
+ return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
+ }
+ return false, next, nil
+ }
+ if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" {
+ return true, "", nil
+ }
+ if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) {
+ return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor")
+ }
+ return false, next, nil
+}
+
+func smartMailBool(value any) (bool, bool) {
+ switch typed := value.(type) {
+ case bool:
+ return typed, true
+ case string:
+ if typed == "true" {
+ return true, true
+ }
+ if typed == "false" {
+ return false, true
+ }
+ }
+ return false, false
+}
+
+func smartMailResult(collection, description string) *contract.ResultSpec {
+ identity := smartMailIdentitySchema(collection)
+ return &contract.ResultSpec{
+ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
+ SensitivePaths: smartMailSensitivePaths(collection),
+ DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`,
+ description, collection, description, identity, collection)),
+ }
+}
+
+func smartMailSensitivePaths(collection string) []string {
+ switch collection {
+ case "messages":
+ return []string{"messages.subject", "messages.from", "messages.date"}
+ case "mails":
+ return []string{"mails.subject", "mails.from", "mails.date"}
+ case "users":
+ return []string{"users.name", "users.nickname", "users.email", "users.employeeNo", "users.jobTitle", "users.workLocation"}
+ default:
+ panic("unsupported smart Mail sensitive paths: " + collection)
+ }
+}
+
+func smartMailIdentitySchema(collection string) string {
+ switch collection {
+ case "messages":
+ return `"properties":{"messageId":{"type":"string","minLength":1,"description":"稳定邮件 ID"}},"required":["messageId"]`
+ case "mails":
+ return `"properties":{"threadId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["threadId"]`
+ case "users":
+ return `"properties":{"id":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["id"]},{"required":["email"]}]`
+ default:
+ panic("unsupported smart Mail Result identity: " + collection)
+ }
+}
+
+func smartMailCursorPagination() *contract.PaginationSpec {
+ return &contract.PaginationSpec{
+ Kind: contract.PaginationKindCursor,
+ CursorParameter: "cursor",
+ MetaPath: contract.PaginationMetaPath,
+ EndpointExhaustedPath: contract.PaginationExhaustedPath,
+ NextTokenPath: contract.PaginationNextTokenPath,
+ }
+}
+
+func hardenSmartMail(declaration *shortcut.Shortcut, collection, description string) {
+ declaration.OutputRollout = output.RolloutUnifiedActive
+ declaration.Contract.Result = smartMailResult(collection, description)
+ declaration.Contract.Pagination = smartMailCursorPagination()
+ declaration.Contract.Interface = &contract.InterfaceSpec{
+ Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable,
+ Reason: "Reviewed Mail Shortcut composite: strict success, collection, stable-ID and pagination validation are owned by the executable CLI.",
+ }
+}
+
+func markSmartMailCompatibilityOnly(declaration *shortcut.Shortcut) {
+ declaration.OutputRollout = output.RolloutLegacyOnly
+ declaration.Contract.Result = nil
+ declaration.Contract.Pagination = nil
+ declaration.Contract.Interface = &contract.InterfaceSpec{
+ Mode: contract.InterfaceModeComposite,
+ Availability: contract.InterfaceAvailable,
+ Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data proof gap is closed.",
+ }
+}
+
+func smartMailPayload(collection string, items []map[string]any, complete bool, next string) map[string]any {
+ payload := map[string]any{"count": len(items), collection: items, "complete": complete}
+ if !complete {
+ payload["nextCursor"] = next
+ }
+ return payload
+}
+
+func smartMailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error {
+ payload := smartMailPayload(collection, items, complete, next)
+ if !output.UsesUnifiedResult(rt.Command()) {
+ return rt.Output(payload)
+ }
+ pagination, err := output.NewPagination(complete, next)
+ if err != nil {
+ return smartMailError("mail/pagination", "invalid_pagination", err.Error())
+ }
+ business := map[string]any{"count": len(items), collection: items}
+ meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))}
+ return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
+}
diff --git a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go
new file mode 100644
index 00000000..14022542
--- /dev/null
+++ b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go
@@ -0,0 +1,405 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package smart
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "maps"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+)
+
+type smartMailCursorCall struct {
+ tool string
+ args map[string]any
+}
+
+type smartMailCursorCaller struct {
+ responses map[string]string
+ errors map[string]error
+ calls []smartMailCursorCall
+}
+
+func (caller *smartMailCursorCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
+ caller.calls = append(caller.calls, smartMailCursorCall{tool: tool, args: maps.Clone(args)})
+ if err := caller.errors[tool]; err != nil {
+ return nil, err
+ }
+ text, ok := caller.responses[tool]
+ if !ok {
+ return nil, fmt.Errorf("unexpected smart Mail tool: %s", tool)
+ }
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
+}
+
+func (*smartMailCursorCaller) Format() string { return "json" }
+func (*smartMailCursorCaller) DryRun() bool { return false }
+func (*smartMailCursorCaller) Fields() string { return "" }
+func (*smartMailCursorCaller) JQ() string { return "" }
+
+func runSmartMailDeclaration(t *testing.T, declaration shortcut.Shortcut, caller *smartMailCursorCaller, args ...string) error {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
+ cmd.SetArgs(args)
+ return cmd.Execute()
+}
+
+func TestCrossPlatformCoverageSmartMailContractsAreUnifiedAndTyped(t *testing.T) {
+ for _, declaration := range []struct {
+ name string
+ rollout output.RolloutState
+ result bool
+ }{
+ {"search", SearchMail.OutputRollout, SearchMail.Contract.Result != nil},
+ {"find-user", FindMailUser.OutputRollout, FindMailUser.Contract.Result != nil},
+ {"triage", TriageMail.OutputRollout, TriageMail.Contract.Result != nil},
+ } {
+ if declaration.rollout != output.RolloutUnifiedActive || !declaration.result {
+ t.Fatalf("%s is not unified with Result", declaration.name)
+ }
+ }
+ for _, declaration := range []*shortcut.Shortcut{&SearchMail, &FindMailUser, &TriageMail} {
+ if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" {
+ t.Fatalf("%s missing cursor Pagination", declaration.Command)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) {
+ for _, declaration := range []*shortcut.Shortcut{&UnreadMail, &RecentMail} {
+ if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil {
+ t.Errorf("%s unavailable runtime still publishes unified result/pagination", declaration.Command)
+ }
+ if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason == "" {
+ t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailHistoricalStringSizeAndRuntimeDefault(t *testing.T) {
+ for _, declaration := range []*shortcut.Shortcut{&SearchMail, &UnreadMail} {
+ found := false
+ for _, flag := range declaration.Flags {
+ if flag.Name != "size" {
+ continue
+ }
+ found = true
+ if flag.Type != shortcut.FlagString || flag.Default != "" {
+ t.Fatalf("%s --size type/default=%q/%q, want string/empty", declaration.Command, flag.Type, flag.Default)
+ }
+ }
+ if !found {
+ t.Fatalf("%s missing --size", declaration.Command)
+ }
+ cmd := corecmd.New(shortcut.FromShortcut(*declaration))
+ if flag := cmd.Flags().Lookup("size"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" {
+ t.Fatalf("%s Cobra --size=%#v, want historical string with empty default", declaration.Command, flag)
+ }
+ }
+
+ for _, tc := range []struct {
+ name string
+ declaration shortcut.Shortcut
+ args []string
+ wantSize string
+ }{
+ {name: "search default", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, wantSize: "20"},
+ {name: "search explicit", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "100"}, wantSize: "100"},
+ {name: "unread default", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, wantSize: "20"},
+ {name: "unread explicit", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "1"}, wantSize: "1"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": `{"success":true,"messages":[],"hasMore":false,"nextCursor":""}`}}
+ if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil {
+ t.Fatalf("compatible execution failed: %v", err)
+ }
+ if len(caller.calls) != 1 || caller.calls[0].args["size"] != tc.wantSize {
+ t.Fatalf("calls=%#v, want one call with size=%q", caller.calls, tc.wantSize)
+ }
+ })
+ }
+
+ for _, declaration := range []shortcut.Shortcut{SearchMail, UnreadMail} {
+ caller := &smartMailCursorCaller{responses: map[string]string{}}
+ helpers.InitDepsForTest(t, caller)
+ values := map[string]string{"email": "mail@example.invalid", "size": "bad"}
+ if declaration.Command == "+search-mail" {
+ values["query"] = "fixture"
+ }
+ rt := smartMailRuntimeForCoverage(t, declaration, values)
+ if err := declaration.Execute(rt); err == nil {
+ t.Fatalf("%s direct Execute accepted malformed string size", declaration.Command)
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("%s direct Execute reached %d remote calls", declaration.Command, len(caller.calls))
+ }
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailZeroHitSentinelIsNarrow(t *testing.T) {
+ fixture := map[string]any{
+ "success": "true", "total": "0", "nextCursor": "$",
+ "messages": []any{map[string]any{"ccRecipients": nil, "toRecipients": nil}},
+ }
+ items, err := smartMailSearchRows(fixture, "mail/search_emails")
+ if err != nil || len(items) != 0 {
+ t.Fatalf("reviewed zero-hit sentinel must normalize to empty: items=%v err=%v", items, err)
+ }
+ for name, bad := range map[string]map[string]any{
+ "unexpected field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"subject": nil}}},
+ "non-null field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": "x"}}},
+ "multiple rows": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": nil}, map[string]any{"toRecipients": nil}}},
+ } {
+ if _, err := smartMailSearchRows(bad, "mail/search_emails"); err == nil {
+ t.Fatalf("%s must fail closed", name)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailMalformedCollectionsFail(t *testing.T) {
+ for name, fixture := range map[string]map[string]any{
+ "missing success": {"messages": []any{}},
+ "missing collection": {"success": "true", "total": "0", "nextCursor": "$"},
+ "wrong type": {"success": "true", "messages": map[string]any{}},
+ "bad item": {"success": "true", "messages": []any{"bad"}},
+ } {
+ if _, err := smartMailCollection(fixture, "mail/search_emails", "messages"); err == nil {
+ t.Fatalf("%s must fail closed", name)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailNestedSendersFailClosed(t *testing.T) {
+ if _, err := searchMailFrom(map[string]any{"from": []any{"bad"}}); err == nil {
+ t.Fatal("malformed message from field must fail")
+ }
+ if _, err := recentMailSenders(map[string]any{"senders": []any{"bad"}}); err == nil {
+ t.Fatal("non-object conversation sender must fail")
+ }
+ if _, err := recentMailSenders(map[string]any{"senders": []any{map[string]any{"name": ""}}}); err == nil {
+ t.Fatal("sender without identity must fail")
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailPaginationOutputAndIdentitySchema(t *testing.T) {
+ declaration := SearchMail
+ cmd := corecmd.New(shortcut.FromShortcut(declaration))
+ ctx, _ := output.WithResultStore(context.Background())
+ cmd.SetContext(ctx)
+ var stdout bytes.Buffer
+ cmd.SetOut(&stdout)
+ rt := shortcut.RuntimeContextForTest(cmd, declaration)
+ if err := smartMailOutputPage(rt, "messages", []map[string]any{{"messageId": "message-1"}}, false, "cursor-2"); err != nil {
+ t.Fatal(err)
+ }
+ if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 {
+ t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err)
+ }
+ var envelope struct {
+ Data map[string]any `json:"data"`
+ Meta struct {
+ Pagination *struct {
+ EndpointExhausted bool `json:"endpoint_exhausted"`
+ NextToken string `json:"next_token"`
+ } `json:"pagination"`
+ } `json:"meta"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatal(err)
+ }
+ for _, field := range []string{"complete", "hasMore", "nextCursor"} {
+ if _, exists := envelope.Data[field]; exists {
+ t.Fatalf("pagination field %s leaked into business data", field)
+ }
+ }
+ if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted || envelope.Meta.Pagination.NextToken != "cursor-2" {
+ t.Fatalf("pagination=%+v", envelope.Meta.Pagination)
+ }
+
+ for _, tc := range []struct {
+ declaration *shortcut.Shortcut
+ collection string
+ identity string
+ }{
+ {&SearchMail, "messages", "messageId"},
+ {&TriageMail, "messages", "messageId"},
+ {&FindMailUser, "users", "email"},
+ } {
+ if len(tc.declaration.Contract.Result.SensitivePaths) == 0 {
+ t.Fatalf("%s missing sensitive_paths", tc.declaration.Command)
+ }
+ var schema map[string]any
+ if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil {
+ t.Fatalf("%s schema: %v", tc.declaration.Command, err)
+ }
+ properties := schema["properties"].(map[string]any)
+ if minimum := properties["count"].(map[string]any)["minimum"]; minimum != float64(0) {
+ t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, minimum)
+ }
+ items := properties[tc.collection].(map[string]any)["items"].(map[string]any)
+ identity := items["properties"].(map[string]any)[tc.identity].(map[string]any)
+ if identity["description"] == "" {
+ t.Fatalf("%s missing stable identity", tc.declaration.Command)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailCursorPassthroughAndStall(t *testing.T) {
+ tests := []struct {
+ name string
+ declaration shortcut.Shortcut
+ args []string
+ tool string
+ response string
+ }{
+ {name: "search", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
+ {name: "unread", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
+ {name: "triage", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
+ {name: "find user", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`},
+ {name: "recent", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}}
+ if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil {
+ t.Fatal(err)
+ }
+ if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" {
+ t.Fatalf("cursor not passed through: %#v", caller.calls)
+ }
+ })
+ }
+ caller := &smartMailCursorCaller{responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}}
+ if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture", "--cursor", "cursor-1"); err == nil {
+ t.Fatal("repeated cursor unexpectedly succeeded")
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailLimitValidationHasZeroCalls(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ declaration shortcut.Shortcut
+ args []string
+ }{
+ {name: "search blank", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", " "}},
+ {name: "search nonnumeric", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "bad"}},
+ {name: "search zero", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "0"}},
+ {name: "search negative", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "-1"}},
+ {name: "search high", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "101"}},
+ {name: "unread blank", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", " "}},
+ {name: "unread nonnumeric", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "bad"}},
+ {name: "unread zero", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "0"}},
+ {name: "unread negative", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "-1"}},
+ {name: "unread high", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "101"}},
+ {name: "triage zero", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "0"}},
+ {name: "find high", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "101"}},
+ {name: "recent negative", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "-1"}},
+ {name: "search blank query", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", " "}},
+ {name: "find blank query", declaration: FindMailUser, args: []string{"--query", " "}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &smartMailCursorCaller{responses: map[string]string{}}
+ if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil {
+ t.Fatal("invalid limit unexpectedly succeeded")
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("invalid limit reached remote: %d", len(caller.calls))
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageFindMailUserProjectionMatchesResultIdentity(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ user map[string]any
+ wantKey string
+ absentKey string
+ }{
+ {name: "id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "id", absentKey: "email"},
+ {name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "id"},
+ {name: "numeric id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "id", absentKey: "email"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ projected, err := findMailUserProjection(tc.user)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := projected[tc.wantKey]; !ok {
+ t.Fatalf("projection missing %s: %#v", tc.wantKey, projected)
+ }
+ if _, ok := projected[tc.absentKey]; ok {
+ t.Fatalf("projection retained empty %s: %#v", tc.absentKey, projected)
+ }
+ })
+ }
+
+ caller := &smartMailCursorCaller{responses: map[string]string{
+ "search_mail_users": `{"success":true,"users":[{"id":"","email":""}],"hasMore":false,"nextCursor":""}`,
+ }}
+ if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture"); err == nil {
+ t.Fatal("identity-free user unexpectedly satisfied runtime Result contract")
+ }
+ for name, user := range map[string]map[string]any{
+ "malformed email with valid id": {"id": "user-1", "email": 7},
+ "malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := findMailUserProjection(user); err == nil {
+ t.Fatal("malformed optional identity unexpectedly passed projection")
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageSmartMailWhitespaceIdentityFailsClosed(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ declaration shortcut.Shortcut
+ args []string
+ tool string
+ response string
+ }{
+ {name: "search message id", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`},
+ {name: "triage message id", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`},
+ {name: "find user identity", declaration: FindMailUser, args: []string{"--query", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":" ","email":" "}],"hasMore":false,"nextCursor":""}`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}}
+ if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil {
+ t.Fatal("whitespace stable identity unexpectedly succeeded")
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("calls=%d, want one response validation call", len(caller.calls))
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageFindMailUserKeepsStableQueryProperty(t *testing.T) {
+ for _, parameter := range FindMailUser.Contract.Parameters {
+ if parameter.Name == "query" {
+ if parameter.Property != "query" {
+ t.Fatalf("query property = %q, want stable composite property query", parameter.Property)
+ }
+ return
+ }
+ }
+ t.Fatal("FindMailUser query parameter declaration is missing")
+}
diff --git a/internal/shortcut/smart/my_attendance.go b/internal/shortcut/smart/my_attendance.go
index 1c445d14..1cbc99a5 100644
--- a/internal/shortcut/smart/my_attendance.go
+++ b/internal/shortcut/smart/my_attendance.go
@@ -14,12 +14,14 @@
package smart
import (
+ "strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -41,10 +43,11 @@ import (
//
// dws attendance +my-attendance
var MyAttendance = shortcut.Shortcut{
- Service: "attendance",
- Command: "+my-attendance",
- Product: "attendance",
- Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)",
+ OutputRollout: output.RolloutLegacyOnly,
+ Service: "attendance",
+ Command: "+my-attendance",
+ Product: "attendance",
+ Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)",
Intent: "当你想快速看自己今天的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " +
"再手动填写今天的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出今天 00:00 到次日 00:00 的时间窗," +
"最后查询你今天的打卡流水记录。只读操作,不会修改任何考勤数据;今天若还没有任何打卡则返回空结果。",
@@ -63,9 +66,9 @@ var MyAttendance = shortcut.Shortcut{
},
Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)",
Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
+ Mode: contract.InterfaceModeComposite,
+ Availability: contract.InterfaceAvailable,
+ Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-day fixture closes the false-empty proof gap.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)",
@@ -84,7 +87,7 @@ var MyAttendance = shortcut.Shortcut{
if err != nil {
return err
}
- userID := myAttendanceCurrentUserID(profile)
+ userID := strictAttendanceCurrentUserID(profile)
if userID == "" {
return apperrors.NewValidation(
"没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。")
@@ -110,15 +113,89 @@ var MyAttendance = shortcut.Shortcut{
if err != nil {
return err
}
- return rt.Output(data)
+ return outputStrictAttendanceRecords(rt, data)
},
}
-// myAttendanceCurrentUserID extracts the current user's userId from a
+// strictAttendanceCurrentUserID extracts the current user's userId from a
// get_current_user_profile response. The gateway returns one of several shapes,
// so probe them defensively (mirrors helpers.getCurrentUserID):
// - {"result": [ {"orgEmployeeModel": {"userId": ...}} ]}
// - {"result": {"userId": ...}}
+func strictAttendanceCurrentUserID(data map[string]any) string {
+ if data == nil {
+ return ""
+ }
+ success, ok := data["success"].(bool)
+ if !ok || !success {
+ return ""
+ }
+ identities := map[string]struct{}{}
+ collect := func(item map[string]any) bool {
+ var direct, nested string
+ if value, present := item["userId"]; present {
+ text, valid := value.(string)
+ if !valid || strings.TrimSpace(text) == "" {
+ return false
+ }
+ direct = strings.TrimSpace(text)
+ }
+ if value, present := item["orgEmployeeModel"]; present {
+ object, valid := value.(map[string]any)
+ if !valid || object == nil {
+ return false
+ }
+ text, valid := object["userId"].(string)
+ if !valid || strings.TrimSpace(text) == "" {
+ return false
+ }
+ nested = strings.TrimSpace(text)
+ }
+ if direct == "" && nested == "" {
+ return false
+ }
+ if direct != "" && nested != "" && direct != nested {
+ return false
+ }
+ if direct != "" {
+ identities[direct] = struct{}{}
+ } else {
+ identities[nested] = struct{}{}
+ }
+ return true
+ }
+ switch result := data["result"].(type) {
+ case []any:
+ if len(result) == 0 {
+ return ""
+ }
+ for _, item := range result {
+ m, ok := item.(map[string]any)
+ if !ok || !collect(m) {
+ return ""
+ }
+ }
+ case map[string]any:
+ if !collect(result) {
+ return ""
+ }
+ default:
+ return ""
+ }
+ if len(identities) != 1 {
+ return ""
+ }
+ var identity string
+ for candidate := range identities {
+ identity = candidate
+ }
+ return identity
+}
+
+// myAttendanceCurrentUserID preserves the compatibility resolver used by
+// older non-Attendance smart workflows. New Attendance shortcuts must use the
+// strict resolver above so a missing business success flag cannot consume a
+// stale result.
func myAttendanceCurrentUserID(data map[string]any) string {
if data == nil {
return ""
diff --git a/internal/shortcut/smart/my_initiated.go b/internal/shortcut/smart/my_initiated.go
deleted file mode 100644
index 195fb6a9..00000000
--- a/internal/shortcut/smart/my_initiated.go
+++ /dev/null
@@ -1,247 +0,0 @@
-// Copyright 2026 Alibaba Group
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package smart
-
-import (
- "strconv"
- "strings"
-
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
-
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
-)
-
-// MyInitiated: list the OA approval instances *I* have initiated (submitted), in
-// one step.
-//
-// Steps:
-//
-// 1. call get_submitted_instances on the OA server — the exact tool + parameter
-// names (pageNumber / pageSize as float64, optional query) used by
-// helpers.approvalSubmittedListCmd ("dws oa approval list-submitted"). page
-// and limit default to the same first-page values (page 1, limit 20).
-//
-// 2. defensively locate the instance list inside the response (probing common
-// container keys, incl. a nested result/data object) and project each item
-// down to {title, businessId, status, processInstanceId} with multiple
-// candidate keys per field. When no recognisable list is found we print the
-// raw payload so nothing is silently lost.
-//
-// 3. print via rt.Output so it honours --format / --jq / --fields.
-//
-// Read-only: it only lists and reshapes my submitted approvals, it never
-// approves, rejects, revokes or mutates anything.
-//
-// dws oa +my-initiated
-// dws oa +my-initiated --query 报销
-// dws oa +my-initiated --page 2 --limit 50
-var MyInitiated = shortcut.Shortcut{
- Service: "oa",
- Command: "+my-initiated",
- Product: "oa",
- Description: "列出我发起(提交)的审批单据",
- Intent: "当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;" +
- "内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted)," +
- "再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。" +
- "可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。" +
- "这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。",
- Risk: shortcut.RiskRead,
- Safety: contract.SafetySpec{
- Effect: "read", Risk: "low",
- Confirmation: "not_required", Idempotency: "idempotent",
- },
- Contract: corecmd.ContractDecl{
- Identity: contract.ToolIdentitySpec{
- ProductID: "oa",
- Name: "shortcut_my_initiated",
- CanonicalPath: "oa.shortcut_my_initiated",
- CLIPath: "oa +my-initiated",
- PrimaryCLIPath: "oa +my-initiated",
- },
- Description: "列出我发起(提交)的审批单据",
- Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
- },
- Selection: contract.SelectionSpec{
- AgentSummary: "列出我发起(提交)的审批单据",
- UseWhen: []string{"当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted),再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。"},
- AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
- Examples: []string{
- "dws oa +my-initiated",
- "dws oa +my-initiated --query 报销",
- },
- },
- },
- Flags: []shortcut.Flag{
- {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)", Required: false},
- {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1", Required: false},
- {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20", Required: false},
- },
- Tips: []string{
- `dws oa +my-initiated`,
- `dws oa +my-initiated --query 报销`,
- `dws oa +my-initiated --page 2 --limit 50`,
- },
- Execute: func(rt *shortcut.RuntimeContext) error {
- // Step 1 — list my submitted approval instances. pageNumber/pageSize are
- // float64 and query is optional, mirroring helpers.approvalSubmittedListCmd.
- page := rt.Int("page")
- if page <= 0 {
- page = 1
- }
- limit := rt.Int("limit")
- if limit <= 0 {
- limit = 20
- }
- params := map[string]any{
- "pageNumber": float64(page),
- "pageSize": float64(limit),
- }
- if q := strings.TrimSpace(rt.Str("query")); q != "" {
- params["query"] = q
- }
-
- data, err := rt.CallMCPData("oa", "get_submitted_instances", params)
- if err != nil {
- return err
- }
-
- // Step 2 — project the instance list; fall back to the raw payload when we
- // cannot locate a recognisable list.
- items := myInitiatedItems(data)
- if len(items) == 0 {
- return rt.Output(data)
- }
- results := make([]map[string]any, 0, len(items))
- for _, m := range items {
- results = append(results, map[string]any{
- "title": myInitiatedTitle(m),
- "businessId": myInitiatedBusinessID(m),
- "status": myInitiatedStatus(m),
- "processInstanceId": myInitiatedInstanceID(m),
- })
- }
-
- // Step 3 — print the projected list.
- return rt.Output(map[string]any{"initiated": results})
- },
-}
-
-// myInitiatedItems locates the instance list inside a get_submitted_instances
-// response, probing common container keys at the top level and nested under a
-// result/data object. Returns nil when no list is found.
-func myInitiatedItems(data map[string]any) []map[string]any {
- if data == nil {
- return nil
- }
- keys := []string{"list", "instances", "processInstances", "items", "data", "records", "result", "rows"}
- for _, key := range keys {
- if arr, ok := data[key].([]any); ok {
- return myInitiatedToMaps(arr)
- }
- if inner, ok := data[key].(map[string]any); ok {
- for _, k2 := range []string{"list", "instances", "processInstances", "items", "data", "records", "rows"} {
- if arr, ok := inner[k2].([]any); ok {
- return myInitiatedToMaps(arr)
- }
- }
- }
- }
- return nil
-}
-
-func myInitiatedToMaps(arr []any) []map[string]any {
- out := make([]map[string]any, 0, len(arr))
- for _, it := range arr {
- if m, ok := it.(map[string]any); ok {
- out = append(out, m)
- }
- }
- return out
-}
-
-// myInitiatedTitle reads an instance's human-readable title, tolerating the
-// common title keys the gateway may use.
-func myInitiatedTitle(m map[string]any) any {
- for _, key := range []string{"title", "subject", "name", "processName", "formName", "instanceTitle"} {
- if v := myInitiatedString(m[key]); v != "" {
- return v
- }
- }
- return nil
-}
-
-// myInitiatedBusinessID reads an instance's business / order number, preferring
-// the customer-visible business id and falling back to related identifiers.
-func myInitiatedBusinessID(m map[string]any) any {
- for _, key := range []string{"businessId", "bizId", "orderNo", "orderNumber", "number", "serialNumber"} {
- if v := myInitiatedString(m[key]); v != "" {
- return v
- }
- }
- return nil
-}
-
-// myInitiatedStatus reads an instance's current status/result, tolerating both
-// the running status and the final result keys.
-func myInitiatedStatus(m map[string]any) any {
- for _, key := range []string{"status", "statusText", "processStatus", "instanceStatus", "result", "approvalResult"} {
- if v := myInitiatedString(m[key]); v != "" {
- return v
- }
- }
- return nil
-}
-
-// myInitiatedInstanceID reads an instance's processInstanceId, tolerating the
-// common id keys.
-func myInitiatedInstanceID(m map[string]any) any {
- for _, key := range []string{"processInstanceId", "instanceId", "processInstanceID", "id", "bizId"} {
- if v := myInitiatedString(m[key]); v != "" {
- return v
- }
- }
- return nil
-}
-
-// myInitiatedString coerces a scalar JSON value to a trimmed string, returning
-// "" for nil / non-scalar / empty values.
-func myInitiatedString(v any) string {
- switch typed := v.(type) {
- case string:
- return strings.TrimSpace(typed)
- case float64:
- // approval ids/numbers may arrive as JSON numbers; render without a
- // trailing decimal point when integral.
- return strconv.FormatFloat(typed, 'f', -1, 64)
- case int64:
- return strconv.FormatInt(typed, 10)
- case int:
- return strconv.Itoa(typed)
- case bool:
- if typed {
- return "true"
- }
- return "false"
- default:
- return ""
- }
-}
-
-func init() {
- shortcut.Register(MyInitiated)
-}
diff --git a/internal/shortcut/smart/pending_approvals.go b/internal/shortcut/smart/pending_approvals.go
deleted file mode 100644
index 3c4e34bd..00000000
--- a/internal/shortcut/smart/pending_approvals.go
+++ /dev/null
@@ -1,128 +0,0 @@
-// Copyright 2026 Alibaba Group
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package smart
-
-import (
- "time"
-
- apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
-)
-
-// PendingApprovals: READ-ONLY list of the approvals waiting for ME, projected to
-// clean fields. Unlike `oa +approve-by` (which actually agrees to an approval),
-// this shortcut only looks — it never approves / rejects / mutates anything.
-//
-// Steps (tool name + param keys copied verbatim from helpers/oa.go):
-//
-// 1. list my pending approvals via list_pending_approvals with starTime /
-// endTime as float64 milliseconds over a recent (~90 day) window, mirroring
-// `dws oa approval list-pending`; optional --limit maps to pageSize (float64).
-//
-// 2. defensively unwrap the returned instance list (multiple candidate
-// container keys, one nested level) and project each entry to a readable
-// shape {title, originatorName, processInstanceId, createTime} — every field
-// probed across several candidate keys.
-//
-// 3. if nothing is pending, report "当前没有待我审批的任务" instead of an empty
-// raw dump.
-//
-// dws oa +pending
-// dws oa +pending --limit 10
-var PendingApprovals = shortcut.Shortcut{
- Service: "oa",
- Command: "+pending",
- Product: "oa",
- Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)",
- Intent: "当你只想快速看一眼「待我审批」的审批任务清单——每条的标题、发起人、审批实例 ID 和创建时间——" +
- "而不想拿到一大坨原始字段时使用;内部拉取你近三个月待处理的审批单,再在本地投影出可读字段。" +
- "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改审批(要一键通过请改用 `dws oa +approve-by`);" +
- "若当前没有待你审批的任务则提示「当前没有待我审批的任务」。",
- Risk: shortcut.RiskRead,
- Flags: []shortcut.Flag{
- {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false},
- },
- Tips: []string{
- `dws oa +pending`,
- `dws oa +pending --limit 10`,
- },
- Execute: func(rt *shortcut.RuntimeContext) error {
- // Step 1 — list my pending approvals. starTime/endTime are float64
- // milliseconds; pageSize is optional. Keys copied verbatim from
- // helpers.list-pending (list_pending_approvals). Window: last ~90 days.
- now := time.Now()
- params := map[string]any{
- "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()),
- "endTime": float64(now.UnixMilli()),
- }
- if rt.Changed("limit") {
- if n := rt.Int("limit"); n > 0 {
- params["pageSize"] = float64(n)
- }
- }
- data, err := rt.CallMCPData("oa", "list_pending_approvals", params)
- if err != nil {
- return err
- }
-
- // Step 2 — project entries. shortcutApproveItems (approve.go) defensively
- // unwraps the list container (result/list/instances/items/data/records/
- // processList, incl. one nested level).
- items := shortcutApproveItems(data)
- results := make([]map[string]any, 0, len(items))
- for _, m := range items {
- results = append(results, map[string]any{
- // shortcutApproveTitle probes title/processTitle/name/subject/…
- "title": shortcutApproveTitle(m),
- // shortcutApproveInstanceID probes processInstanceId/instanceId/…
- "processInstanceId": shortcutApproveInstanceID(m),
- "originatorName": pendingApprovalsOriginator(m),
- "createTime": pendingApprovalsCreateTime(m),
- })
- }
-
- // Step 3 — empty result guard.
- if len(results) == 0 {
- return apperrors.NewValidation("当前没有待我审批的任务")
- }
-
- return rt.Output(map[string]any{"count": len(results), "pending": results})
- },
-}
-
-// pendingApprovalsOriginator reads the initiator's display name, tolerating the
-// common field names the gateway may use.
-func pendingApprovalsOriginator(m map[string]any) string {
- for _, key := range []string{"originatorName", "originatorUserName", "creatorName", "creator", "applicantName", "userName"} {
- if s := shortcutApproveStr(m[key]); s != "" {
- return s
- }
- }
- return ""
-}
-
-// pendingApprovalsCreateTime reads a pending approval's create time, returning
-// the raw value (usually epoch millis) under whichever candidate key is present.
-func pendingApprovalsCreateTime(m map[string]any) any {
- for _, key := range []string{"createTime", "gmtCreate", "createTimeStr", "startTime", "createdAt"} {
- if v, ok := m[key]; ok && v != nil {
- return v
- }
- }
- return nil
-}
-
-func init() {
- shortcut.Register(PendingApprovals)
-}
diff --git a/internal/shortcut/smart/recent_mail.go b/internal/shortcut/smart/recent_mail.go
index c5b920ed..5651fdc8 100644
--- a/internal/shortcut/smart/recent_mail.go
+++ b/internal/shortcut/smart/recent_mail.go
@@ -14,6 +14,9 @@
package smart
import (
+ "fmt"
+ "strings"
+
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
@@ -71,6 +74,12 @@ var RecentMail = shortcut.Shortcut{
PrimaryCLIPath: "mail +recent-mail",
},
Description: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)",
+ Parameters: []contract.ParamDecl{
+ {Name: "limit", Property: "limit"},
+ {Name: "email", Property: "email"},
+ {Name: "folder", Property: "folder"},
+ {Name: "cursor", Property: "cursor"},
+ },
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -90,6 +99,11 @@ var RecentMail = shortcut.Shortcut{
{Name: "limit", Type: shortcut.FlagInt, Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false},
{Name: "email", Type: shortcut.FlagString, Desc: "要查看的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false},
{Name: "folder", Type: shortcut.FlagString, Desc: "文件夹 ID(可选,默认定位收件箱)", Required: false},
+ {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100 之间"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ return smartMailValidatePageSize(rt, "limit", false)
},
Tips: []string{
`dws mail +recent-mail`,
@@ -119,40 +133,47 @@ var RecentMail = shortcut.Shortcut{
// Step 3 — list threads. email/folderId/size mirror the helpers.mail
// thread-list call; size is an int in 1..100.
- size := rt.Int("limit")
- if size <= 0 {
- size = 20
+ limit := 20
+ if rt.Changed("limit") {
+ limit = rt.Int("limit")
}
- if size > 100 {
- size = 100
- }
- data, err := rt.CallMCPData("mail", "list_mailbox_threads", map[string]any{
+ args := map[string]any{
"email": email,
"folderId": folderID,
- "size": size,
- })
+ "size": limit,
+ }
+ if rt.Changed("cursor") {
+ args["cursor"] = rt.Str("cursor")
+ }
+ data, err := rt.CallMCPData("mail", "list_mailbox_threads", args)
if err != nil {
return err
}
// Step 4 — project each conversation to a compact record.
- threads := searchMailUnwrapList(data, "conversations", "threads", "result", "data", "list", "items", "records")
+ threads, err := smartMailRows(data, "mail/list_mailbox_threads", "result.conversations", "id")
+ if err != nil {
+ return err
+ }
results := make([]map[string]any, 0, len(threads))
for _, t := range threads {
+ from, err := recentMailSenders(t)
+ if err != nil {
+ return err
+ }
results = append(results, map[string]any{
"subject": searchMailFirstString(t, "subject", "title", "topic"),
- "from": recentMailSenders(t),
+ "from": from,
"date": searchMailFirstAny(t, "lastModifiedDateTime", "date", "sentTime", "sentDate", "receivedDate", "createTime"),
"threadId": searchMailFirstString(t, "threadId", "id", "conversationId"),
})
}
- // Empty result guard.
- if len(results) == 0 {
- return apperrors.NewValidation("最近没有邮件")
+ complete, next, err := smartMailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor"))
+ if err != nil {
+ return err
}
-
- return rt.Output(map[string]any{"count": len(results), "mails": results})
+ return smartMailOutputPage(rt, "mails", results, complete, next)
},
}
@@ -166,12 +187,15 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e
if err != nil {
return "", err
}
- folders := searchMailUnwrapList(data, "folders", "list", "items", "data", "result", "records")
+ folders, err := smartMailRows(data, "mail/list_folders", "folders", "id")
+ if err != nil {
+ return "", err
+ }
inboxNames := map[string]bool{
"收件箱": true, "inbox": true,
}
for _, f := range folders {
- name := searchMailFirstString(f, "displayName", "name", "folderName")
+ name := strings.ToLower(strings.TrimSpace(searchMailFirstString(f, "displayName", "name", "folderName")))
if inboxNames[name] {
if id := searchMailFirstString(f, "id", "folderId"); id != "" {
return id, nil
@@ -184,16 +208,14 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e
// recentMailSenders reads a conversation's sender list. The helper documents a
// "senders" array of {email, name}; we tolerate a plain string, a single object,
// and fall back to the shared searchMailFrom logic.
-func recentMailSenders(t map[string]any) any {
- if arr, ok := t["senders"].([]any); ok && len(arr) > 0 {
+func recentMailSenders(t map[string]any) (any, error) {
+ raw, present := t["senders"]
+ if arr, ok := raw.([]any); ok && len(arr) > 0 {
names := make([]string, 0, len(arr))
- for _, it := range arr {
+ for index, it := range arr {
m, ok := it.(map[string]any)
- if !ok {
- if s, ok := it.(string); ok && s != "" {
- names = append(names, s)
- }
- continue
+ if !ok || len(m) == 0 {
+ return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 不是非空对象", index))
}
name := searchMailFirstString(m, "name", "displayName")
addr := searchMailFirstString(m, "email", "emailAddress", "address")
@@ -204,15 +226,23 @@ func recentMailSenders(t map[string]any) any {
names = append(names, addr)
case name != "":
names = append(names, name)
+ default:
+ return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 缺少姓名或邮箱", index))
}
}
if len(names) > 0 {
- return names
+ return names, nil
+ }
+ } else if present {
+ if _, ok := raw.([]any); !ok {
+ return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", "senders 应为数组")
}
}
return searchMailFrom(t)
}
func init() {
+ hardenSmartMail(&RecentMail, "mails", "严格校验的近期邮件会话摘要")
+ markSmartMailCompatibilityOnly(&RecentMail)
shortcut.Register(RecentMail)
}
diff --git a/internal/shortcut/smart/report_latest.go b/internal/shortcut/smart/report_latest.go
deleted file mode 100644
index c756573a..00000000
--- a/internal/shortcut/smart/report_latest.go
+++ /dev/null
@@ -1,220 +0,0 @@
-// Copyright 2026 Alibaba Group
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package smart
-
-import (
- "strconv"
- "strings"
- "time"
-
- apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
- "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
-)
-
-// ReportLatest: fetch the detail of MY most recently submitted report (日志/汇报)
-// in one step (DingTalk-native).
-//
-// Steps:
-//
-// 1. list the reports I sent via get_send_report_list. cursor/size/startTime/
-// endTime mirror helpers.runReportSent — the server caps a single query span
-// at 20 days, so we default to the last-20-days window just like the helper.
-// An optional --keyword is passed through as report_template_name (the same
-// filter the helper exposes as --template-name).
-//
-// 2. locate the newest entry: the item with the largest create time
-// (createTime/gmtCreate/sendTime), falling back to the first item that
-// carries a reportId (lists come back newest-first).
-//
-// 3. print that report's body via get_report_entry_details (report_id param
-// mirrors helpers.runReportDetail). If nothing carries a reportId we fall
-// back to printing the picked list row via rt.Output.
-//
-// If I have not sent any report in the window it reports "暂无日志" instead of
-// failing obscurely.
-//
-// dws report +report-latest
-// dws report +report-latest --keyword 日报
-var ReportLatest = shortcut.Shortcut{
- Service: "report",
- Command: "+report-latest",
- Product: "report",
- Description: "取我最新提交的一篇日志/汇报详情(钉钉原生)",
- Intent: "当你只想快速看回自己最近发出的一篇日志/汇报,却不想先翻发件箱列表、复制 reportId 再查详情时使用;" +
- "内部先列出你近期发出的日志(默认最近 20 天,服务端单次查询跨度上限 20 天;可用 --keyword 按日志模板名过滤)," +
- "自动挑出创建时间最新的一条,再拉取它的正文详情(字段明细、发送人、时间、钉钉跳转链接等)。" +
- "这是只读操作,不会创建或修改任何日志;若这段时间内你没有发出过日志则提示「暂无日志」。",
- Risk: shortcut.RiskRead,
- Flags: []shortcut.Flag{
- {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名过滤(可选,对应 report outbox list 的 --template-name)", Required: false},
- },
- Tips: []string{
- `dws report +report-latest`,
- `dws report +report-latest --keyword 日报`,
- },
- Execute: func(rt *shortcut.RuntimeContext) error {
- // Step 1 — list the reports I sent. Mirror helpers.runReportSent: the
- // server caps a single get_send_report_list query at a 20-day span, so
- // default startTime/endTime to the last-20-days window. cursor/size are
- // floats and report_template_name is the optional keyword filter.
- now := time.Now()
- startMs := now.AddDate(0, 0, -20).Truncate(24 * time.Hour).UnixMilli()
- endMs := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
-
- listArgs := map[string]any{
- "cursor": float64(0),
- "size": float64(20),
- "startTime": float64(startMs),
- "endTime": float64(endMs),
- }
- if kw := strings.TrimSpace(rt.Str("keyword")); kw != "" {
- listArgs["report_template_name"] = kw
- }
-
- data, err := rt.CallMCPData("report", "get_send_report_list", listArgs)
- if err != nil {
- return err
- }
-
- // Step 2 — pick the newest sent report.
- row, reportID := shortcutReportLatestPick(data)
- if row == nil {
- return apperrors.NewValidation("暂无日志")
- }
-
- // Step 3 — print its detail (report_id mirrors helpers.runReportDetail).
- // If we somehow could not read a reportId, fall back to the list row.
- if reportID == "" {
- return rt.Output(row)
- }
- return rt.CallMCP("get_report_entry_details", map[string]any{
- "report_id": reportID,
- })
- },
-}
-
-// shortcutReportLatestPick walks a get_send_report_list response, finds the
-// report entries, and returns the newest one (largest create time, falling back
-// to the first entry that carries a reportId) together with its reportId.
-// Returns (nil, "") when there are no entries.
-func shortcutReportLatestPick(data map[string]any) (map[string]any, string) {
- items := shortcutReportLatestItems(data)
- if len(items) == 0 {
- return nil, ""
- }
-
- var firstItem map[string]any
- firstID := ""
- var bestItem map[string]any
- bestID := ""
- var bestTime int64
- haveTime := false
-
- for _, m := range items {
- id := shortcutReportLatestID(m)
- if id == "" {
- continue
- }
- if firstItem == nil {
- firstItem = m
- firstID = id
- }
- if t, ok := shortcutReportLatestCreateMillis(m); ok {
- if !haveTime || t > bestTime {
- haveTime = true
- bestTime = t
- bestItem = m
- bestID = id
- }
- }
- }
-
- if haveTime && bestItem != nil {
- return bestItem, bestID
- }
- if firstItem != nil {
- return firstItem, firstID
- }
- // No entry carried a reportId; still surface the first raw item so the
- // caller can print something rather than mis-report "暂无日志".
- return items[0], ""
-}
-
-// shortcutReportLatestItems pulls the report entries out of a
-// get_send_report_list response, probing the same container keys the helper's
-// findReportListItems uses (list/items/data/reports/records/report_list/
-// reportList nested under the top level or under result).
-func shortcutReportLatestItems(data map[string]any) []map[string]any {
- keys := []string{"list", "items", "data", "reports", "records", "report_list", "reportList", "result"}
- for _, key := range keys {
- if arr, ok := data[key].([]any); ok {
- return shortcutReportLatestToMaps(arr)
- }
- if inner, ok := data[key].(map[string]any); ok {
- for _, k2 := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} {
- if arr, ok := inner[k2].([]any); ok {
- return shortcutReportLatestToMaps(arr)
- }
- }
- }
- }
- return nil
-}
-
-func shortcutReportLatestToMaps(arr []any) []map[string]any {
- out := make([]map[string]any, 0, len(arr))
- for _, it := range arr {
- if m, ok := it.(map[string]any); ok {
- out = append(out, m)
- }
- }
- return out
-}
-
-// shortcutReportLatestID reads an entry's reportId, mirroring the key aliases in
-// helpers.reportIDFromMap.
-func shortcutReportLatestID(m map[string]any) string {
- for _, key := range []string{"reportId", "reportID", "report_id", "report_Id", "report-id"} {
- if id, ok := m[key].(string); ok {
- if id = strings.TrimSpace(id); id != "" {
- return id
- }
- }
- }
- return ""
-}
-
-// shortcutReportLatestCreateMillis reads an entry's create time in millis,
-// mirroring the create-time keys helpers.reportReadableCreateTimeMillis probes.
-func shortcutReportLatestCreateMillis(m map[string]any) (int64, bool) {
- for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime"} {
- switch v := m[key].(type) {
- case float64:
- if v > 0 {
- return int64(v), true
- }
- case string:
- if s := strings.TrimSpace(v); s != "" {
- if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
- return n, true
- }
- }
- }
- }
- return 0, false
-}
-
-func init() {
- shortcut.Register(ReportLatest)
-}
diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go
index a6d824e2..d0d2a22b 100644
--- a/internal/shortcut/smart/search_mail.go
+++ b/internal/shortcut/smart/search_mail.go
@@ -14,6 +14,9 @@
package smart
import (
+ "fmt"
+ "strings"
+
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
@@ -79,9 +82,20 @@ var SearchMail = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
- {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2)", Required: true},
+ {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2),不能为空", Required: true},
{Name: "email", Type: shortcut.FlagString, Desc: "要搜索的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false},
- {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false},
+ {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false},
+ {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false},
+ },
+ Constraints: []shortcut.Constraint{
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"},
+ {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "1-100"},
+ },
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ if err := smartMailValidateRequiredText(rt, "query"); err != nil {
+ return err
+ }
+ return smartMailValidateStringPageSize(rt, "size")
},
Tips: []string{
`dws mail +search-mail --query "subject:周报"`,
@@ -92,6 +106,10 @@ var SearchMail = shortcut.Shortcut{
if err := rt.RequireAll("query"); err != nil {
return err
}
+ size, err := smartMailStringPageSize(rt, "size", "20")
+ if err != nil {
+ return err
+ }
// Step 1 — resolve the mailbox address.
email := rt.Str("email")
@@ -105,31 +123,42 @@ var SearchMail = shortcut.Shortcut{
// Step 2 — search that mailbox. email/query/size mirror the search_emails
// call in helpers.messageSearch; size is passed as a string.
- size := rt.Str("size")
- if size == "" {
- size = "20"
- }
- data, err := rt.CallMCPData("mail", "search_emails", map[string]any{
+ args := map[string]any{
"email": email,
"query": rt.Str("query"),
"size": size,
- })
+ }
+ if rt.Changed("cursor") {
+ args["cursor"] = rt.Str("cursor")
+ }
+ data, err := rt.CallMCPData("mail", "search_emails", args)
if err != nil {
return err
}
// Step 3 — project each message to a compact record.
- messages := searchMailMessages(data)
+ messages, err := smartMailSearchRows(data, "mail/search_emails")
+ if err != nil {
+ return err
+ }
out := make([]map[string]any, 0, len(messages))
for _, m := range messages {
+ from, err := searchMailFrom(m)
+ if err != nil {
+ return err
+ }
out = append(out, map[string]any{
"subject": searchMailFirstString(m, "subject", "title", "topic"),
- "from": searchMailFrom(m),
+ "from": from,
"date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"),
"messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"),
})
}
- return rt.Output(map[string]any{"messages": out, "email": email})
+ complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor"))
+ if err != nil {
+ return err
+ }
+ return smartMailOutputPage(rt, "messages", out, complete, next)
},
}
@@ -142,115 +171,106 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) {
if err != nil {
return "", err
}
- if addr := searchMailPickMailbox(data); addr != "" {
- return addr, nil
+ mailboxes, err := searchMailMailboxAddresses(data)
+ if err != nil {
+ return "", err
+ }
+ if len(mailboxes) > 0 {
+ return mailboxes[0], nil
}
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址")
}
-// searchMailPickMailbox resolves the first usable mailbox address from a
-// list_user_mailboxes response. It tolerates every observed shape: objects with
-// an email field (result.emailAccounts[].email) and bare string arrays
-// (result.emailAccounts[]), each at the top level or one level under a
-// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string
-// case is scanned separately — otherwise all three smart mail shortcuts wrongly
-// report that no mailbox exists.
-func searchMailPickMailbox(data map[string]any) string {
- containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"}
- for _, m := range searchMailUnwrapList(data, containers...) {
- if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" {
- return addr
- }
+// searchMailMailboxAddresses accepts the two reviewed mailbox wire shapes:
+// non-empty address strings and non-empty objects carrying an email field.
+// The collection itself and every item remain fail-closed, so malformed rows
+// cannot be skipped and misreported as an empty mailbox list.
+func searchMailMailboxAddresses(data map[string]any) ([]string, error) {
+ const operation = "mail/list_user_mailboxes"
+ if err := smartMailSuccess(data, operation); err != nil {
+ return nil, err
}
- scanStrings := func(m map[string]any) string {
- for _, key := range containers {
- if arr, ok := m[key].([]any); ok {
- for _, it := range arr {
- if s, ok := it.(string); ok && s != "" {
- return s
- }
- }
- }
+ paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"}
+ var value any
+ selectedPath := ""
+ for _, path := range paths {
+ candidate, present := smartMailLookup(data, path)
+ if !present {
+ continue
}
- return ""
- }
- if s := scanStrings(data); s != "" {
- return s
- }
- for _, wrap := range []string{"result", "data"} {
- if inner, ok := data[wrap].(map[string]any); ok {
- if s := scanStrings(inner); s != "" {
- return s
- }
+ if selectedPath != "" {
+ return nil, smartMailError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path))
}
+ value = candidate
+ selectedPath = path
}
- return ""
-}
-
-// searchMailMessages extracts the message list from a search_emails response.
-// The helper documents the list under "messages".
-func searchMailMessages(data map[string]any) []map[string]any {
- return searchMailUnwrapList(data, "messages", "emails", "list", "items", "data", "result", "records")
-}
-
-// searchMailUnwrapList probes the given container keys at the top level, and one
-// level deep, returning the first list found as []map[string]any.
-func searchMailUnwrapList(data map[string]any, keys ...string) []map[string]any {
- for _, key := range keys {
- if arr, ok := data[key].([]any); ok {
- return searchMailToMaps(arr)
- }
- if inner, ok := data[key].(map[string]any); ok {
- for _, k2 := range keys {
- if arr, ok := inner[k2].([]any); ok {
- return searchMailToMaps(arr)
- }
- }
- }
+ if selectedPath == "" {
+ return nil, smartMailError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果")
}
- return nil
-}
-
-func searchMailToMaps(arr []any) []map[string]any {
- out := make([]map[string]any, 0, len(arr))
- for _, it := range arr {
- if m, ok := it.(map[string]any); ok {
- out = append(out, m)
- }
+ raw, ok := value.([]any)
+ if !ok {
+ return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value))
}
- return out
+ addresses := make([]string, 0, len(raw))
+ for index, item := range raw {
+ var address string
+ switch typed := item.(type) {
+ case string:
+ address = strings.TrimSpace(typed)
+ case map[string]any:
+ address = searchMailFirstString(typed, "email")
+ default:
+ return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index))
+ }
+ if address == "" {
+ return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index))
+ }
+ addresses = append(addresses, address)
+ }
+ return addresses, nil
}
// searchMailFrom reads a message's sender, tolerating both a plain string and a
// nested object ({name, email/address}).
-func searchMailFrom(m map[string]any) any {
- switch v := m["from"].(type) {
+func searchMailFrom(m map[string]any) (any, error) {
+ raw, present := m["from"]
+ switch v := raw.(type) {
case string:
- if v != "" {
- return v
+ if normalized := strings.TrimSpace(v); normalized != "" {
+ return normalized, nil
}
case map[string]any:
name := searchMailFirstString(v, "name", "displayName")
addr := searchMailFirstString(v, "email", "emailAddress", "address")
switch {
case name != "" && addr != "":
- return name + " <" + addr + ">"
+ return name + " <" + addr + ">", nil
case addr != "":
- return addr
+ return addr, nil
case name != "":
- return name
+ return name, nil
}
+ return nil, smartMailError("mail/search_emails", "malformed_sender", "from 对象缺少姓名或邮箱")
+ case nil:
+ if present {
+ return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为 null")
+ }
+ default:
+ return nil, smartMailError("mail/search_emails", "malformed_sender", fmt.Sprintf("from 字段类型错误:%T", raw))
}
if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" {
- return s
+ return s, nil
}
- return ""
+ if present {
+ return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为空")
+ }
+ return nil, smartMailError("mail/search_emails", "missing_sender", "消息缺少可验证的发件人字段")
}
func searchMailFirstString(m map[string]any, keys ...string) string {
for _, key := range keys {
- if s, ok := m[key].(string); ok && s != "" {
- return s
+ if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" {
+ return strings.TrimSpace(s)
}
}
return ""
@@ -259,8 +279,11 @@ func searchMailFirstString(m map[string]any, keys ...string) string {
func searchMailFirstAny(m map[string]any, keys ...string) any {
for _, key := range keys {
if v, ok := m[key]; ok && v != nil {
- if s, isStr := v.(string); isStr && s == "" {
- continue
+ if s, isStr := v.(string); isStr {
+ if strings.TrimSpace(s) == "" {
+ continue
+ }
+ return strings.TrimSpace(s)
}
return v
}
@@ -269,5 +292,6 @@ func searchMailFirstAny(m map[string]any, keys ...string) any {
}
func init() {
+ hardenSmartMail(&SearchMail, "messages", "严格校验的邮件搜索摘要")
shortcut.Register(SearchMail)
}
diff --git a/internal/shortcut/smart/this_month_attendance.go b/internal/shortcut/smart/this_month_attendance.go
index 3363e6f8..641c5576 100644
--- a/internal/shortcut/smart/this_month_attendance.go
+++ b/internal/shortcut/smart/this_month_attendance.go
@@ -20,6 +20,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -41,10 +42,11 @@ import (
//
// dws attendance +this-month
var ThisMonthAttendance = shortcut.Shortcut{
- Service: "attendance",
- Command: "+this-month",
- Product: "attendance",
- Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)",
+ OutputRollout: output.RolloutLegacyOnly,
+ Service: "attendance",
+ Command: "+this-month",
+ Product: "attendance",
+ Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)",
Intent: "当你想快速看自己本月的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " +
"再手动填写本月的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出本月 1 号 00:00 到下月 1 号 00:00 的时间窗," +
"最后查询你本月的打卡流水记录。只读操作,不会修改任何考勤数据;本月若还没有任何打卡则返回空结果。",
@@ -63,9 +65,9 @@ var ThisMonthAttendance = shortcut.Shortcut{
},
Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)",
Interface: &contract.InterfaceSpec{
- Mode: "composite",
- Availability: "available",
- Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
+ Mode: contract.InterfaceModeComposite,
+ Availability: contract.InterfaceAvailable,
+ Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-month fixture closes the false-empty proof gap.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)",
@@ -84,7 +86,7 @@ var ThisMonthAttendance = shortcut.Shortcut{
if err != nil {
return err
}
- userID := myAttendanceCurrentUserID(profile)
+ userID := strictAttendanceCurrentUserID(profile)
if userID == "" {
return apperrors.NewValidation(
"没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。")
@@ -110,7 +112,7 @@ var ThisMonthAttendance = shortcut.Shortcut{
if err != nil {
return err
}
- return rt.Output(data)
+ return outputStrictAttendanceRecords(rt, data)
},
}
diff --git a/internal/shortcut/smart/triage_mail.go b/internal/shortcut/smart/triage_mail.go
new file mode 100644
index 00000000..7f32ee13
--- /dev/null
+++ b/internal/shortcut/smart/triage_mail.go
@@ -0,0 +1,102 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package smart
+
+import (
+ "strconv"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+)
+
+// TriageMail aligns the Lark task-level triage entry while preserving DWS's
+// easier mailbox and inbox resolution. It returns summaries only; callers use
+// +message or +messages for full bodies.
+var TriageMail = shortcut.Shortcut{
+ Service: "mail", Command: "+triage", Product: "mail",
+ Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", Intent: "快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。",
+ Risk: shortcut.RiskRead,
+ Safety: contract.SafetySpec{
+ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
+ },
+ Contract: corecmd.ContractDecl{
+ Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱",
+ Identity: contract.ToolIdentitySpec{
+ ProductID: "mail", Name: "shortcut_triage", CanonicalPath: "mail.shortcut_triage",
+ CLIPath: "mail +triage", PrimaryCLIPath: "mail +triage",
+ },
+ Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Resolves the current mailbox and inbox locally, then strictly validates search_emails summaries and pagination."},
+ Selection: contract.SelectionSpec{
+ AgentSummary: "列出或筛选邮件摘要,自动解析邮箱与收件箱",
+ UseWhen: []string{"快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。"},
+ AvoidWhen: []string{"已知单个 messageId 并要读正文时用 mail +message;需要多封完整正文时用 mail +messages"},
+ Examples: []string{`dws mail +triage --limit 20 --format json`, `dws mail +triage --query "isRead:false" --format json`},
+ },
+ Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}, {Name: "cursor", Property: "cursor"}, {Name: "query", Property: "query"}},
+ },
+ Flags: []shortcut.Flag{
+ {Name: "query", Type: shortcut.FlagString, Desc: "可选 KQL 条件;不传时列出收件箱"},
+ {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"},
+ {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页摘要数量,必须在 1-100 之间"},
+ {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ return smartMailValidatePageSize(rt, "limit", true)
+ },
+ Execute: func(rt *shortcut.RuntimeContext) error {
+ email := rt.Str("email")
+ if email == "" {
+ resolved, err := searchMailFirstMailbox(rt)
+ if err != nil {
+ return err
+ }
+ email = resolved
+ }
+ query := rt.Str("query")
+ if query == "" {
+ folderID, err := recentMailInboxFolder(rt, email)
+ if err != nil {
+ return err
+ }
+ query = "folderId:" + folderID
+ }
+ args := map[string]any{"email": email, "query": query, "size": strconv.Itoa(rt.Int("limit"))}
+ if rt.Changed("cursor") {
+ args["cursor"] = rt.Str("cursor")
+ }
+ data, err := rt.CallMCPData("mail", "search_emails", args)
+ if err != nil {
+ return err
+ }
+ messages, err := smartMailSearchRows(data, "mail/search_emails")
+ if err != nil {
+ return err
+ }
+ rows := make([]map[string]any, 0, len(messages))
+ for _, message := range messages {
+ from, err := searchMailFrom(message)
+ if err != nil {
+ return err
+ }
+ rows = append(rows, map[string]any{
+ "messageId": searchMailFirstString(message, "id"),
+ "subject": searchMailFirstString(message, "subject"),
+ "from": from,
+ "date": searchMailFirstAny(message, "receivedDateTime", "date", "sentDateTime"),
+ })
+ }
+ complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor"))
+ if err != nil {
+ return err
+ }
+ return smartMailOutputPage(rt, "messages", rows, complete, next)
+ },
+}
+
+func init() {
+ hardenSmartMail(&TriageMail, "messages", "严格校验的邮件摘要与分页证据")
+ shortcut.Register(TriageMail)
+}
diff --git a/internal/shortcut/smart/unread_mail.go b/internal/shortcut/smart/unread_mail.go
index e72a91fa..8299663d 100644
--- a/internal/shortcut/smart/unread_mail.go
+++ b/internal/shortcut/smart/unread_mail.go
@@ -80,7 +80,12 @@ var UnreadMail = shortcut.Shortcut{
},
Flags: []shortcut.Flag{
{Name: "email", Type: shortcut.FlagString, Desc: "要查询的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false},
- {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false},
+ {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false},
+ {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false},
+ },
+ Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "显式 --size 必须在 1-100 之间"}},
+ Validate: func(rt *shortcut.RuntimeContext) error {
+ return smartMailValidateStringPageSize(rt, "size")
},
Tips: []string{
`dws mail +unread-mail`,
@@ -88,6 +93,10 @@ var UnreadMail = shortcut.Shortcut{
`dws mail +unread-mail --size 50`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
+ size, err := smartMailStringPageSize(rt, "size", "20")
+ if err != nil {
+ return err
+ }
// Step 1 — resolve the mailbox address.
email := rt.Str("email")
if email == "" {
@@ -101,34 +110,47 @@ var UnreadMail = shortcut.Shortcut{
// Step 2 — search unread mail. email/query/size mirror the search_emails
// call in helpers.messageSearch; the isRead:false KQL filter matches the
// isRead field documented there; size is passed as a string.
- size := rt.Str("size")
- if size == "" {
- size = "20"
- }
- data, err := rt.CallMCPData("mail", "search_emails", map[string]any{
+ args := map[string]any{
"email": email,
"query": "isRead:false",
"size": size,
- })
+ }
+ if rt.Changed("cursor") {
+ args["cursor"] = rt.Str("cursor")
+ }
+ data, err := rt.CallMCPData("mail", "search_emails", args)
if err != nil {
return err
}
// Step 3 — project each message to a compact record (shared helpers).
- messages := searchMailMessages(data)
+ messages, err := smartMailSearchRows(data, "mail/search_emails")
+ if err != nil {
+ return err
+ }
out := make([]map[string]any, 0, len(messages))
for _, m := range messages {
+ from, err := searchMailFrom(m)
+ if err != nil {
+ return err
+ }
out = append(out, map[string]any{
"subject": searchMailFirstString(m, "subject", "title", "topic"),
- "from": searchMailFrom(m),
+ "from": from,
"date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"),
"messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"),
})
}
- return rt.Output(map[string]any{"messages": out, "email": email})
+ complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor"))
+ if err != nil {
+ return err
+ }
+ return smartMailOutputPage(rt, "messages", out, complete, next)
},
}
func init() {
+ hardenSmartMail(&UnreadMail, "messages", "严格校验的未读邮件摘要")
+ markSmartMailCompatibilityOnly(&UnreadMail)
shortcut.Register(UnreadMail)
}
diff --git a/internal/shortcut/todo/lifecycle.go b/internal/shortcut/todo/lifecycle.go
index 164aede6..2e2937a7 100644
--- a/internal/shortcut/todo/lifecycle.go
+++ b/internal/shortcut/todo/lifecycle.go
@@ -6,6 +6,7 @@ package todo
import (
"encoding/json"
"fmt"
+ "math"
"strconv"
"strings"
"time"
@@ -164,13 +165,55 @@ func updateTodo(rt *shortcut.RuntimeContext) error {
if key == "taskId" {
continue
}
- if fmt.Sprint(detail[key]) != fmt.Sprint(expected) {
+ if !todoUpdateFieldMatches(key, detail[key], expected) {
return todoWriteResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致")
}
}
return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail})
}
+func todoUpdateFieldMatches(key string, actual, expected any) bool {
+ switch key {
+ case "dueTime", "priority":
+ actualNumber, actualOK := todoExactInteger(actual)
+ expectedNumber, expectedOK := todoExactInteger(expected)
+ return actualOK && expectedOK && actualNumber == expectedNumber
+ case "subject":
+ actualText, actualOK := actual.(string)
+ expectedText, expectedOK := expected.(string)
+ return actualOK && expectedOK && actualText == expectedText
+ default:
+ return false
+ }
+}
+
+func todoExactInteger(value any) (int64, bool) {
+ switch typed := value.(type) {
+ case int:
+ return int64(typed), true
+ case int64:
+ return typed, true
+ case float64:
+ return todoExactFloat(typed)
+ case json.Number:
+ parsed, err := typed.Int64()
+ return parsed, err == nil
+ case string:
+ parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
+ return parsed, err == nil
+ default:
+ return 0, false
+ }
+}
+
+func todoExactFloat(value float64) (int64, bool) {
+ if math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value ||
+ value < float64(math.MinInt64) || value >= float64(math.MaxInt64) {
+ return 0, false
+ }
+ return int64(value), true
+}
+
var Update = shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "todo", Command: "+update", Product: "todo",
diff --git a/internal/shortcut/todo/page_size_coverage_test.go b/internal/shortcut/todo/page_size_coverage_test.go
new file mode 100644
index 00000000..c2deb5ba
--- /dev/null
+++ b/internal/shortcut/todo/page_size_coverage_test.go
@@ -0,0 +1,51 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package todo
+
+import (
+ "context"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+type todoPageSizeCaller struct {
+ calls int
+}
+
+func (c *todoPageSizeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
+ c.calls++
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":{"todoCards":[]}}`}}}, nil
+}
+
+func (*todoPageSizeCaller) Format() string { return "json" }
+func (*todoPageSizeCaller) DryRun() bool { return false }
+func (*todoPageSizeCaller) Fields() string { return "" }
+func (*todoPageSizeCaller) JQ() string { return "" }
+
+func TestCrossPlatformCoverageGetMyTasksRejectsUnsupportedPageSize(t *testing.T) {
+ caller := &todoPageSizeCaller{}
+ helpers.InitDepsForTest(t, caller)
+ root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
+ root.PersistentFlags().Bool("yes", false, "")
+ root.PersistentFlags().Bool("dry-run", false, "")
+ root.PersistentFlags().String("format", "json", "")
+ root.SetOut(io.Discard)
+ root.SetErr(io.Discard)
+ root.AddCommand(shortcut.Commands()...)
+ root.SetArgs([]string{"todo", "+get-my-tasks", "--size", "21"})
+
+ err := root.Execute()
+ if err == nil || !strings.Contains(err.Error(), "--size") {
+ t.Fatalf("size 21 error = %v, want local --size validation", err)
+ }
+ if caller.calls != 0 {
+ t.Fatalf("size 21 made %d remote calls, want 0", caller.calls)
+ }
+}
diff --git a/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go b/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go
index f04f7c85..85ae973a 100644
--- a/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go
+++ b/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"io"
+ "math"
"strconv"
"strings"
"testing"
@@ -313,11 +314,44 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) {
}
updateSuccess := &todoCoverageCaller{responses: map[string][]string{
"update_todo_task": {`{"success":true}`},
- "get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40}}}`},
+ "get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40,"dueTime":1786932000000}}}`},
}}
- if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--yes"); err != nil {
+ if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--due", todoCoverageTime, "--yes"); err != nil {
t.Fatal(err)
}
+ if todoUpdateFieldMatches("dueTime", float64(1786932000000), int64(1786932000001)) {
+ t.Fatal("different dueTime milliseconds matched")
+ }
+ for _, tc := range []struct {
+ value any
+ want int64
+ ok bool
+ }{
+ {value: int(40), want: 40, ok: true},
+ {value: int64(1786932000000), want: 1786932000000, ok: true},
+ {value: float64(40), want: 40, ok: true},
+ {value: json.Number("40"), want: 40, ok: true},
+ {value: " 40 ", want: 40, ok: true},
+ {value: float64(math.MinInt64), want: math.MinInt64, ok: true},
+ {value: 1786932000000.5},
+ {value: math.NaN()},
+ {value: math.Inf(1)},
+ {value: math.Inf(-1)},
+ {value: float64(math.MaxInt64)},
+ {value: 1e300},
+ {value: -1e300},
+ {value: json.Number("not-a-number")},
+ {value: "not-a-number"},
+ {value: true},
+ } {
+ got, ok := todoExactInteger(tc.value)
+ if got != tc.want || ok != tc.ok {
+ t.Fatalf("todoExactInteger(%#v) = %d/%v, want %d/%v", tc.value, got, ok, tc.want, tc.ok)
+ }
+ }
+ if todoUpdateFieldMatches("unknown", 1, 1) {
+ t.Fatal("unknown update field matched")
+ }
}
func TestCrossPlatformCoverageTodoDoneBranches(t *testing.T) {
diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go
index b3545f36..602baf39 100644
--- a/internal/shortcut/types.go
+++ b/internal/shortcut/types.go
@@ -195,6 +195,11 @@ type Shortcut struct {
Tips []string
// Hidden hides the command from listings while keeping it invocable.
Hidden bool
+ // CompatibilityVisible preserves a historically visible CLI command while
+ // keeping it out of the Agent/public Shortcut catalog. Such a command is
+ // shown only by `dws shortcut list --all`; Availability independently says
+ // whether the historical execution path remains callable.
+ CompatibilityVisible bool
// Disposition is the reviewed semantic relation to the Runtime Schema leaf
// surface. It determines default Agent discovery independently from live
// fixture evidence.
diff --git a/internal/shortcut/usage/command.go b/internal/shortcut/usage/command.go
index cd59dd51..f4677bcf 100644
--- a/internal/shortcut/usage/command.go
+++ b/internal/shortcut/usage/command.go
@@ -56,7 +56,7 @@ func newListCommand() *cobra.Command {
if svc != "" && s.Service != svc {
continue
}
- if s.Hidden && !includeHidden {
+ if (s.Hidden || s.CompatibilityVisible) && !includeHidden {
continue
}
rows = append(rows, newShortcutListRow(s))
@@ -75,23 +75,24 @@ func newListCommand() *cobra.Command {
}
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"`
- Public bool `json:"public"`
- Disposition string `json:"disposition,omitempty"`
- SemanticDelta string `json:"semantic_delta,omitempty"`
- Availability string `json:"availability,omitempty"`
- Primary string `json:"primary,omitempty"`
- Reviewed bool `json:"reviewed"`
+ 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"`
+ Public bool `json:"public"`
+ Disposition string `json:"disposition,omitempty"`
+ SemanticDelta string `json:"semantic_delta,omitempty"`
+ Availability string `json:"availability,omitempty"`
+ Primary string `json:"primary,omitempty"`
+ Reviewed bool `json:"reviewed"`
+ CompatibilityVisible bool `json:"compatibility_visible,omitempty"`
}
func newShortcutListRow(s shortcut.Shortcut) shortcutListRow {
@@ -120,23 +121,24 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow {
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,
- Public: !s.Hidden,
- Disposition: string(s.Disposition),
- SemanticDelta: s.SemanticDelta,
- Availability: string(s.Availability),
- Primary: s.PrimaryCommand,
- Reviewed: s.SemanticReviewed,
+ 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,
+ Public: !s.Hidden && !s.CompatibilityVisible,
+ Disposition: string(s.Disposition),
+ SemanticDelta: s.SemanticDelta,
+ Availability: string(s.Availability),
+ Primary: s.PrimaryCommand,
+ Reviewed: s.SemanticReviewed,
+ CompatibilityVisible: s.CompatibilityVisible,
}
}
diff --git a/internal/shortcut/usage/usage_test.go b/internal/shortcut/usage/usage_test.go
index 3d1a103c..45040408 100644
--- a/internal/shortcut/usage/usage_test.go
+++ b/internal/shortcut/usage/usage_test.go
@@ -47,6 +47,11 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T)
Service: "coverage-usage",
Command: "+hidden",
})
+ shortcut.Register(shortcut.Shortcut{
+ Service: "coverage-usage",
+ Command: "+compatibility-visible",
+ CompatibilityVisible: true,
+ })
execute := func(args ...string) map[string]any {
t.Helper()
@@ -66,9 +71,20 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T)
publicRows := execute("--service", "coverage-usage")
allRows := execute("--service", "coverage-usage", "--all")
- if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 1 {
+ if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 2 {
t.Fatalf("hidden shortcuts were not filtered: public=%v all=%v", publicRows["count"], allRows["count"])
}
+ rows := allRows["shortcuts"].([]any)
+ foundCompatibilityVisible := false
+ for _, value := range rows {
+ row := value.(map[string]any)
+ if row["command"] == "+compatibility-visible" {
+ foundCompatibilityVisible = row["compatibility_visible"] == true && row["public"] == false
+ }
+ }
+ if !foundCompatibilityVisible {
+ t.Fatalf("compatibility-visible shortcut row lost its non-public marker: %#v", rows)
+ }
missing := execute("--service", "__missing__")
if missing["count"].(float64) != 0 {
t.Fatalf("missing service returned shortcuts: %#v", missing)
diff --git a/internal/shortcut/wiki/common.go b/internal/shortcut/wiki/common.go
index ab57746d..94603853 100644
--- a/internal/shortcut/wiki/common.go
+++ b/internal/shortcut/wiki/common.go
@@ -154,6 +154,30 @@ func projectWikiRows(items []any, aliases map[string][]string) []map[string]any
return rows
}
+func projectWikiRowsPreservingSource(items []any, aliases map[string][]string) []map[string]any {
+ rows := make([]map[string]any, 0, len(items))
+ for _, item := range items {
+ source := item.(map[string]any)
+ row := make(map[string]any, len(source)+len(aliases))
+ for key, value := range source {
+ row[key] = value
+ }
+ for canonical, candidates := range aliases {
+ if value, ok := row[canonical]; ok && value != nil {
+ continue
+ }
+ for _, candidate := range candidates {
+ if value, ok := source[candidate]; ok && value != nil {
+ row[canonical] = value
+ break
+ }
+ }
+ }
+ rows = append(rows, row)
+ }
+ return rows
+}
+
func addWikiPagination(out, page map[string]any) {
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} {
if value, ok := page[pair[0]]; ok && value != nil {
diff --git a/internal/shortcut/wiki/nodes.go b/internal/shortcut/wiki/nodes.go
index aa893c87..7011e00a 100644
--- a/internal/shortcut/wiki/nodes.go
+++ b/internal/shortcut/wiki/nodes.go
@@ -237,7 +237,7 @@ var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "
if err != nil {
return err
}
- feeds := projectWikiRows(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
+ feeds := projectWikiRowsPreservingSource(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
out := map[string]any{"count": len(feeds), "feeds": feeds}
addWikiPagination(out, page)
return rt.Output(out)
diff --git a/internal/shortcut/wiki/workflow_coverage_test.go b/internal/shortcut/wiki/workflow_coverage_test.go
index 457ceec9..b66490cc 100644
--- a/internal/shortcut/wiki/workflow_coverage_test.go
+++ b/internal/shortcut/wiki/workflow_coverage_test.go
@@ -294,6 +294,29 @@ func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) {
})
}
+func TestCrossPlatformCoverageWikiFeedListPreservesBusinessPayload(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "wiki/list_workspace_feeds": {`{"success":true,"feeds":[{"type":1,"time":1750067400000,"content":{"doc":{"name":"Important","docKey":"dk1"}},"users":[{"nick":"Alice","userId":"u1"}],"futureField":{"value":"keep"}}],"hasMore":false}`},
+ }}
+ out, err := runWikiCoverageCLI(t, caller, "+feed-list", "--workspace", "w")
+ if err != nil {
+ t.Fatal(err)
+ }
+ feeds := out["feeds"].([]any)
+ feed := feeds[0].(map[string]any)
+ content, contentOK := feed["content"].(map[string]any)
+ users, usersOK := feed["users"].([]any)
+ if !contentOK || content["doc"].(map[string]any)["name"] != "Important" {
+ t.Fatalf("content was lost or changed: %#v", feed)
+ }
+ if !usersOK || users[0].(map[string]any)["nick"] != "Alice" {
+ t.Fatalf("users were lost or changed: %#v", feed)
+ }
+ if _, ok := feed["futureField"]; !ok {
+ t.Fatalf("unknown feed business fields must be preserved: %#v", feed)
+ }
+}
+
func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) {
t.Run("confirmation gates precede every remote call", func(t *testing.T) {
for _, tc := range []struct {
diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go
new file mode 100644
index 00000000..9b019a89
--- /dev/null
+++ b/internal/upgrade/canonical_skills_test.go
@@ -0,0 +1,521 @@
+package upgrade
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
+)
+
+func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) {
+ home := withFakeHome(t)
+ testseam.Swap(t, &knownSkillDirs, []string{
+ ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
+ })
+
+ for _, parent := range []string{".codex", ".claude", ".openclaw"} {
+ if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // beta.6 wrote a physical copy into Codex. The canonical migration must
+ // remove it without treating a matching user/market name as DWS-owned.
+ oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
+ if err := os.MkdirAll(oldCodex, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
+ result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
+ if err != nil || len(result.Failed()) != 0 {
+ t.Fatalf("upgrade = %#v, %v", result, err)
+ }
+
+ canonical := filepath.Join(home, ".agents", "skills")
+ for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
+ if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
+ t.Fatalf("canonical %s missing: %v", name, err)
+ }
+ if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", name)); !os.IsNotExist(err) {
+ t.Fatalf("universal Codex duplicate remains for %s: %v", name, err)
+ }
+ for _, agent := range []string{".claude", ".openclaw"} {
+ link := filepath.Join(home, agent, "skills", name)
+ info, err := os.Lstat(link)
+ if err != nil || info.Mode()&os.ModeSymlink == 0 {
+ t.Fatalf("%s link = %#v, %v", link, info, err)
+ }
+ if _, err := os.Stat(filepath.Join(link, "SKILL.md")); err != nil {
+ t.Fatalf("%s does not resolve canonical content: %v", link, err)
+ }
+ }
+ }
+ result, err = UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
+ if err != nil || len(result.Failed()) != 0 {
+ t.Fatalf("idempotent upgrade = %#v, %v", result, err)
+ }
+ for _, agent := range []string{".claude", ".openclaw"} {
+ info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
+ if err != nil || info.Mode()&os.ModeSymlink == 0 {
+ t.Fatalf("idempotent upgrade replaced %s link: %#v, %v", agent, info, err)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageCanonicalCustomRootsSymlinkedParentsAndLexicalVictims(t *testing.T) {
+ home := withFakeHome(t)
+ customCodex := filepath.Join(t.TempDir(), "codex")
+ customHermes := filepath.Join(t.TempDir(), "hermes")
+ externalClaude := filepath.Join(t.TempDir(), "claude")
+ for _, root := range []string{customCodex, customHermes, externalClaude, filepath.Join(home, ".clawdbot"), filepath.Join(home, ".codeium", "windsurf")} {
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.Symlink(externalClaude, filepath.Join(home, ".claude")); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &upgradeGetenv, func(name string) string {
+ switch name {
+ case "CODEX_HOME":
+ return customCodex
+ case "HERMES_HOME":
+ return customHermes
+ default:
+ return ""
+ }
+ })
+
+ oldCodex := filepath.Join(customCodex, "skills", "dingtalk-chat")
+ if err := os.MkdirAll(oldCodex, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ claudeBase := filepath.Join(home, ".claude", "skills")
+ if err := os.MkdirAll(claudeBase, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(claudeBase, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("missing-target", filepath.Join(claudeBase, "dingtalk-shared")); err != nil {
+ t.Fatal(err)
+ }
+
+ multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
+ result, err := UpgradeSkillLocations(multiRoot)
+ if err != nil || len(result.Failed()) != 0 {
+ t.Fatalf("upgrade = %#v, %v", result, err)
+ }
+ if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
+ t.Fatalf("custom CODEX_HOME duplicate remains: %v", err)
+ }
+ for _, base := range []string{
+ claudeBase,
+ filepath.Join(customHermes, "skills"),
+ filepath.Join(home, ".clawdbot", "skills"),
+ filepath.Join(home, ".codeium", "windsurf", "skills"),
+ } {
+ for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
+ path := filepath.Join(base, name)
+ info, statErr := os.Lstat(path)
+ if statErr != nil || info.Mode()&os.ModeSymlink == 0 {
+ t.Fatalf("expected canonical link %s: %#v, %v", path, info, statErr)
+ }
+ body, readErr := os.ReadFile(filepath.Join(path, "SKILL.md"))
+ if readErr != nil || !strings.Contains(string(body), name) {
+ t.Fatalf("link %s does not resolve: %q, %v", path, body, readErr)
+ }
+ }
+ }
+}
+
+func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) {
+ home := withFakeHome(t)
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
+ if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("links unavailable") })
+
+ multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat")
+ result, err := UpgradeSkillLocations(multiRoot)
+ if err != nil || len(result.Failed()) != 0 {
+ t.Fatalf("upgrade = %#v, %v", result, err)
+ }
+ dest := filepath.Join(home, ".claude", "skills", "dingtalk-chat")
+ info, err := os.Lstat(dest)
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ t.Fatalf("copy fallback = %#v, %v", info, err)
+ }
+ if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
+ t.Fatalf("copy fallback content missing: %v", err)
+ }
+}
+
+func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) {
+ // Keep the destination HOME synthetic: app-bundle detection is deliberately
+ // machine-scoped and must not depend on the selected installation HOME.
+ home := t.TempDir()
+ testseam.Swap(t, &upgradeGetenv, func(string) string { return "" })
+ for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ sentinel := filepath.Join(home, "app-sentinel")
+ if err := os.MkdirAll(sentinel, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ appInfo, err := os.Stat(sentinel)
+ if err != nil {
+ t.Fatal(err)
+ }
+ zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")
+ minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")
+ originalStat := upgradeStat
+ testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
+ if path == zcodeApp || path == minimaxApp {
+ return appInfo, nil
+ }
+ return originalStat(path)
+ })
+ want := map[string]bool{
+ ".config/kimchi/harness/skills": false,
+ ".tabnine/agent/skills": false,
+ ".zcode/skills": false,
+ ".minimax/skills": false,
+ }
+ for _, root := range configuredSkillRoots(home) {
+ label := filepath.ToSlash(root.label)
+ if _, ok := want[label]; ok {
+ want[label] = skillRootDetectedBase(root)
+ }
+ }
+ for label, detected := range want {
+ if !detected {
+ t.Errorf("root %s was not detected", label)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) {
+ home := t.TempDir()
+ custom := map[string]string{
+ "AUTOHAND_HOME": filepath.Join(home, "custom-autohand"),
+ "CLAUDE_CONFIG_DIR": filepath.Join(home, "custom-claude"),
+ "CODEX_HOME": filepath.Join(home, "custom-codex"),
+ "GROK_HOME": filepath.Join(home, "custom-grok"),
+ "HERMES_HOME": filepath.Join(home, "custom-hermes"),
+ "VIBE_HOME": filepath.Join(home, "custom-vibe"),
+ "XDG_CONFIG_HOME": filepath.Join(home, "custom-xdg"),
+ }
+ testseam.Swap(t, &upgradeGetenv, func(name string) string { return custom[name] })
+ roots := configuredSkillRoots(home)
+ if len(roots) != len(knownSkillDirs) {
+ t.Fatalf("configured unique roots = %d, want %d", len(roots), len(knownSkillDirs))
+ }
+ byLabel := make(map[string]resolvedSkillRoot, len(roots))
+ for _, root := range roots {
+ if _, duplicate := byLabel[root.label]; duplicate {
+ t.Fatalf("duplicate configured label %s", root.label)
+ }
+ byLabel[root.label] = root
+ }
+ wants := map[string]string{
+ ".autohand/skills": filepath.Join(custom["AUTOHAND_HOME"], "skills"),
+ ".claude/skills": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"),
+ ".codex/skills": filepath.Join(custom["CODEX_HOME"], "skills"),
+ ".grok/skills": filepath.Join(custom["GROK_HOME"], "skills"),
+ ".hermes/skills": filepath.Join(custom["HERMES_HOME"], "skills"),
+ ".vibe/skills": filepath.Join(custom["VIBE_HOME"], "skills"),
+ ".config/agents/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
+ ".config/crush/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"),
+ ".config/devin/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"),
+ ".config/goose/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"),
+ ".config/kimchi/harness/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"),
+ ".config/opencode/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"),
+ }
+ for label, want := range wants {
+ if got := byLabel[label].base; filepath.Clean(got) != filepath.Clean(want) {
+ t.Errorf("configured %s = %q, want %q", label, got, want)
+ }
+ }
+ for _, label := range []string{
+ ".config/agents/skills", ".gemini/antigravity/skills", ".gemini/antigravity-cli/skills",
+ ".codex/skills", ".cursor/skills", ".deepagents/agent/skills", ".firebender/skills",
+ ".gemini/skills", ".copilot/skills", ".config/opencode/skills",
+ } {
+ if !byLabel[label].universal {
+ t.Errorf("%s not classified universal", label)
+ }
+ }
+ if !byLabel[".agents/skills"].canonical || byLabel[".claude/skills"].universal {
+ t.Fatal("canonical/non-universal classification drift")
+ }
+}
+
+func TestCrossPlatformCoverageUpgradeCanonicalFailureFailsFastUnconditionally(t *testing.T) {
+ for _, mode := range []string{"mono", "multi"} {
+ t.Run(mode, func(t *testing.T) {
+ withFakeHome(t)
+ // Canonical-only configuration (no non-universal dependents): a failed
+ // canonical publish must still fail the whole upgrade. Universal agents
+ // read canonical directly, so silently succeeding here would leave the
+ // machine without any usable Skill while reporting success.
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"})
+ testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") })
+
+ src := t.TempDir()
+ if mode == "mono" {
+ if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ src = writeMultiBundle(t, src, "dingtalk-chat")
+ }
+ result, err := UpgradeSkillLocations(src)
+ if err == nil || !strings.Contains(err.Error(), "canonical Skill 安装失败") {
+ t.Fatalf("canonical publish failure must propagate an error, got (%v)", err)
+ }
+ if result == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
+ t.Fatalf("result = %#v", result)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageUpgradeOpenClawAliasPriority(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ dirs []string
+ want string
+ }{
+ {name: "default", want: ".openclaw"},
+ {name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"},
+ {name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"},
+ {name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ home := t.TempDir()
+ for _, dir := range tc.dirs {
+ if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := resolveOpenClawSkillBase(home); got != filepath.Join(home, tc.want, "skills") {
+ t.Fatalf("OpenClaw root = %q", got)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageUpgradeLinkStagingFailureEdges(t *testing.T) {
+ home := t.TempDir()
+ canonical := filepath.Join(home, ".agents", "skills")
+ dest := filepath.Join(home, ".claude", "skills")
+ if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("mkdir", func(t *testing.T) {
+ testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") })
+ if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "创建 Agent Skill 目录") {
+ t.Fatalf("mkdir failure = %v", err)
+ }
+ })
+ t.Run("temp", func(t *testing.T) {
+ testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", errors.New("temp denied") })
+ if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "链接 staging") {
+ t.Fatalf("temp failure = %v", err)
+ }
+ })
+ t.Run("physical-parent", func(t *testing.T) {
+ testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) {
+ if path == dest {
+ return "", errors.New("physical denied")
+ }
+ return filepath.EvalSymlinks(path)
+ })
+ if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "物理目录") {
+ t.Fatalf("physical failure = %v", err)
+ }
+ })
+ t.Run("canonical-target", func(t *testing.T) {
+ target := filepath.Join(canonical, "dingtalk-chat")
+ testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) {
+ if path == target {
+ return "", errors.New("canonical denied")
+ }
+ return filepath.EvalSymlinks(path)
+ })
+ if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "解析 canonical") {
+ t.Fatalf("canonical failure = %v", err)
+ }
+ })
+ t.Run("relative-path", func(t *testing.T) {
+ testseam.Swap(t, &upgradeRel, func(string, string) (string, error) { return "", errors.New("relative denied") })
+ if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "相对链接") {
+ t.Fatalf("relative failure = %v", err)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) {
+ for _, mode := range []string{"mono", "multi"} {
+ t.Run(mode, func(t *testing.T) {
+ makeSource := func(t *testing.T) string {
+ t.Helper()
+ src := t.TempDir()
+ if mode == "mono" {
+ if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return src
+ }
+ return writeMultiBundle(t, src, "dingtalk-chat")
+ }
+
+ t.Run("undetected-and-physical-alias", func(t *testing.T) {
+ home := withFakeHome(t)
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".qoder/skills"})
+ if err := os.MkdirAll(filepath.Join(home, ".qoder"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(home, ".agents", "skills"), filepath.Join(home, ".qoder", "skills")); err != nil {
+ t.Fatal(err)
+ }
+ result, err := UpgradeSkillLocations(makeSource(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(result.Results) < 3 || result.Results[1].Status != SkillDirSkipped || result.Results[2].Status != SkillDirSkipped {
+ t.Fatalf("skip branches = %#v", result.Results)
+ }
+ })
+
+ t.Run("universal-retire-failure", func(t *testing.T) {
+ home := withFakeHome(t)
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"})
+ victimBase := filepath.Join(home, ".cursor", "skills")
+ victim := filepath.Join(victimBase, "dws")
+ if mode == "multi" {
+ victim = filepath.Join(victimBase, "dingtalk-chat")
+ }
+ if err := os.MkdirAll(victim, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if mode == "multi" {
+ useUpgradeManagedNames(t, "dingtalk-chat")
+ }
+ origRename := skillPathRenameNoReplace
+ testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
+ if src == victim {
+ return "", errors.New("retire denied")
+ }
+ return origRename(src, dst)
+ })
+ result, err := UpgradeSkillLocations(makeSource(t))
+ if err != nil {
+ t.Fatalf("retire failure must not fail the upgrade: %v", err)
+ }
+ if len(result.Failed()) != 0 {
+ t.Fatalf("retire failure must not count as an install failure: %#v", result.Failed())
+ }
+ warnings := result.RetireWarnings()
+ if len(warnings) != 1 || warnings[0].Err == nil {
+ t.Fatalf("retire warnings = %#v", warnings)
+ }
+ if len(result.Succeeded()) == 0 {
+ t.Fatalf("canonical must still be published: %#v", result)
+ }
+ if _, statErr := os.Lstat(victim); statErr != nil {
+ t.Fatalf("unretired copy must be preserved: %v", statErr)
+ }
+ })
+
+ t.Run("universal-retire-success", func(t *testing.T) {
+ home := withFakeHome(t)
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"})
+ victimBase := filepath.Join(home, ".cursor", "skills")
+ victim := filepath.Join(victimBase, "dws")
+ if mode == "multi" {
+ victim = filepath.Join(victimBase, "dingtalk-chat")
+ useUpgradeManagedNames(t, "dingtalk-chat")
+ }
+ if err := os.MkdirAll(victim, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ result, err := UpgradeSkillLocations(makeSource(t))
+ if err != nil || len(result.Failed()) != 0 {
+ t.Fatalf("retire success = %#v, %v", result, err)
+ }
+ if _, err := os.Lstat(victim); !os.IsNotExist(err) {
+ t.Fatalf("universal victim remains: %v", err)
+ }
+ })
+
+ t.Run("victim-scan-and-fallback-copy-failures", func(t *testing.T) {
+ t.Run("victim-scan", func(t *testing.T) {
+ home := withFakeHome(t)
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
+ base := filepath.Join(home, ".claude", "skills")
+ if err := os.MkdirAll(base, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ origReadDir := upgradeReadDir
+ testseam.Swap(t, &upgradeReadDir, func(path string) ([]os.DirEntry, error) {
+ if path == base {
+ return nil, errors.New("scan denied")
+ }
+ return origReadDir(path)
+ })
+ result, err := UpgradeSkillLocations(makeSource(t))
+ if err != nil || len(result.Failed()) != 1 {
+ t.Fatalf("victim scan = %#v, %v", result, err)
+ }
+ })
+
+ t.Run("fallback-copy", func(t *testing.T) {
+ home := withFakeHome(t)
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
+ base := filepath.Join(home, ".claude", "skills")
+ if err := os.MkdirAll(base, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("link denied") })
+ origCopy := upgradeCopyDir
+ testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
+ if strings.HasPrefix(filepath.Clean(dst), filepath.Clean(base)+string(filepath.Separator)) {
+ return errors.New("copy denied")
+ }
+ return origCopy(src, dst)
+ })
+ result, err := UpgradeSkillLocations(makeSource(t))
+ if err != nil || len(result.Failed()) != 1 {
+ t.Fatalf("fallback copy = %#v, %v", result, err)
+ }
+ })
+ })
+ })
+ }
+}
+
+func TestCrossPlatformCoverageUpgradeWindowsPathNormalizationAndRootDedup(t *testing.T) {
+ testseam.Swap(t, &upgradeFoldPathCase, true)
+ if got := skillRootPathKey(filepath.Join("Root", "Skills")); got != strings.ToLower(got) {
+ t.Fatalf("case-folded path key = %q", got)
+ }
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".claude/skills"})
+ if roots := configuredSkillRoots(t.TempDir()); len(roots) != 2 {
+ t.Fatalf("deduplicated roots = %#v", roots)
+ }
+}
diff --git a/internal/upgrade/coverage_edges_test.go b/internal/upgrade/coverage_edges_test.go
index 960d81f7..061d7d68 100644
--- a/internal/upgrade/coverage_edges_test.go
+++ b/internal/upgrade/coverage_edges_test.go
@@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) {
}
knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"}
result, err := UpgradeSkillLocations(source)
- if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 {
+ if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 {
t.Fatalf("skill upgrade = %#v, %v", result, err)
}
knownSkillDirs = []string{".real/skills"}
@@ -579,7 +579,10 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
})
home := t.TempDir()
upgradeUserHomeDir = func() (string, error) { return home, nil }
- knownSkillDirs = []string{".agents/skills"}
+ knownSkillDirs = []string{".agents/skills", ".claude/skills"}
+ if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
+ t.Fatal(err)
+ }
calls := 0
upgradeCopyDir = func(src, dst string) error {
calls++
@@ -593,8 +596,8 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(source)
- if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 {
- t.Fatalf("replaced primary fallback = %#v, %v", result, err)
+ if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
+ t.Fatalf("canonical failure = %#v, %v", result, err)
}
failure := errors.New("ensure failed")
upgradeEnsureDir = func(string, os.FileMode) error { return failure }
diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go
index a83309d3..3261cde1 100644
--- a/internal/upgrade/paths.go
+++ b/internal/upgrade/paths.go
@@ -8,6 +8,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "regexp"
"runtime"
"sort"
"strings"
@@ -35,46 +36,133 @@ const (
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
//
-// The first entry (.agents/skills) is a generic fallback. It is used only
-// when no concrete Agent home is detected; otherwise publishing there would
-// duplicate every Skill for Agents (including Codex) that scan both roots.
-// Subsequent entries are updated when their parent directory already exists.
+// The first entry (.agents/skills) is the canonical global Skill directory,
+// following the universal .agents/skills convention.
+// Agents that understand the universal .agents convention read it directly;
+// detected non-universal Agents receive relative links to this canonical
+// copy (with a direct-copy fallback when links are unavailable).
var knownSkillDirs = []string{
".agents/skills",
+ ".config/agents/skills",
+ ".gemini/antigravity/skills",
+ ".gemini/antigravity-cli/skills",
+ ".deepagents/agent/skills",
+ ".firebender/skills",
+ ".copilot/skills",
+ ".config/opencode/skills",
+ ".aider-desk/skills",
+ ".astrbot/data/skills",
+ ".autohand/skills",
+ ".augment/skills",
+ ".bob/skills",
".claude/skills",
- ".cursor/skills",
+ ".openclaw/skills",
+ ".codeartsdoer/skills",
+ ".codebuddy/skills",
+ ".codemaker/skills",
+ ".codestudio/skills",
+ ".commandcode/skills",
+ ".continue/skills",
+ ".snowflake/cortex/skills",
+ ".config/crush/skills",
+ ".config/devin/skills",
+ ".factory/skills",
+ ".forge/skills",
+ ".config/goose/skills",
+ ".grok/skills",
+ ".hermes/skills",
+ ".inferencesh/skills",
+ ".jazz/skills",
+ ".junie/skills",
+ ".iflow/skills",
+ ".kilocode/skills",
+ ".config/kimchi/harness/skills",
+ ".kiro/skills",
+ ".kode/skills",
+ ".lingma/skills",
+ ".mcpjam/skills",
+ ".minimax/skills",
+ ".vibe/skills",
+ ".moxby/skills",
+ ".mux/skills",
+ ".openhands/skills",
+ ".ona/skills",
+ ".pi/agent/skills",
".qoder/skills",
+ ".qoder-cn/skills",
+ ".qwen/skills",
+ ".reasonix/skills",
+ ".rovodev/skills",
+ ".roo/skills",
+ ".tabnine/agent/skills",
+ ".terramind/skills",
+ ".tinycloud/skills",
+ ".trae/skills",
+ ".trae-cn/skills",
+ ".codeium/windsurf/skills",
+ ".zcode/skills",
+ ".zencoder/skills",
+ ".neovate/skills",
+ ".pochi/skills",
+ ".adal/skills",
+ // DWS-only target retained for product compatibility.
".qoderwork/skills",
+ // beta.6 compatibility roots: cleanup only, never publish new copies.
+ ".cursor/skills",
".gemini/skills",
".codex/skills",
- ".zcode/skills",
".github/skills",
".windsurf/skills",
- ".augment/skills",
".cline/skills",
".amp/skills",
- ".kiro/skills",
- ".trae/skills",
- ".openclaw/skills",
- ".hermes/skills",
+}
+
+// universalSkillDirs are concrete Agent directories whose current clients
+// natively scan ~/.agents/skills. Publishing another copy or link below these
+// roots makes the same Skill appear twice. Keep this classification aligned
+// with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally
+// non-universal because it is a DWS-specific integration absent upstream.
+var universalSkillDirs = map[string]bool{
+ ".config/agents/skills": true,
+ ".gemini/antigravity/skills": true,
+ ".gemini/antigravity-cli/skills": true,
+ ".codex/skills": true,
+ ".cursor/skills": true,
+ ".deepagents/agent/skills": true,
+ ".firebender/skills": true,
+ ".gemini/skills": true,
+ ".copilot/skills": true,
+ ".config/opencode/skills": true,
+ // beta.6 compatibility roots are cleanup-only as well.
+ ".github/skills": true,
+ ".windsurf/skills": true,
+ ".cline/skills": true,
+ ".amp/skills": true,
}
var (
- upgradeUserHomeDir = os.UserHomeDir
- upgradeExecutable = os.Executable
- upgradeEvalSymlinks = filepath.EvalSymlinks
- upgradeCopyDir = copyDir
- upgradeEnsureDir = ensureDir
- upgradeRemoveAll = os.RemoveAll
- upgradeMkdirAll = os.MkdirAll
- upgradeMkdirTemp = os.MkdirTemp
- upgradeReadDir = os.ReadDir
- upgradeStat = os.Stat
- upgradeBuildProvenance = skillprovenance.Build
- upgradeReadSkillState = skillstate.Read
- upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
- upgradeWriteSkillState = skillstate.Write
- upgradeNow = time.Now
+ upgradeUserHomeDir = os.UserHomeDir
+ upgradeExecutable = os.Executable
+ upgradeEvalSymlinks = filepath.EvalSymlinks
+ upgradeCopyDir = copyDir
+ upgradeEnsureDir = ensureDir
+ upgradeRemoveAll = os.RemoveAll
+ upgradeMkdirAll = os.MkdirAll
+ upgradeMkdirTemp = os.MkdirTemp
+ upgradeReadDir = os.ReadDir
+ upgradeStat = os.Stat
+ upgradeLstat = os.Lstat
+ upgradeSymlink = os.Symlink
+ upgradeRel = filepath.Rel
+ upgradeGetenv = os.Getenv
+ upgradeBuildProvenance = skillprovenance.Build
+ upgradeReadSkillState = skillstate.Read
+ upgradePublishSkillPath = PublishSkillPathNoReplace
+ upgradeRollbackPublishedSkillPaths = RollbackSkillPathPublications
+ upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
+ upgradeWriteSkillState = skillstate.Write
+ upgradeNow = time.Now
+ upgradeFoldPathCase = runtime.GOOS == "windows"
)
// skillBackupSubdir is the user-level directory where skill directories are
@@ -85,43 +173,64 @@ const skillBackupSubdir = ".dws/skill-backups"
// backupAndRemoveSkillDir moves dir into /.dws/skill-backups/
// / instead of destroying it, and returns the backup path. It is
-// fail-safe: a directory that cannot be backed up is NOT removed and the
+// fail-safe: a path that cannot be backed up is NOT removed and the
// error is returned so the caller can surface it (and never install the
-// opposite layout next to it silently). Missing paths and regular files are
-// no-ops ("", nil).
+// opposite layout next to it silently). Missing paths are no-ops. Directories,
+// links (including dangling links), and ordinary files are all preserved:
+// an unexpected file at a managed destination must never be overwritten.
func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
- info, err := upgradeStat(dir)
+ _, err := upgradeLstat(dir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err)
}
- if !info.IsDir() {
- return "", nil
- }
rel, relErr := filepath.Rel(homeDir, dir)
if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") {
rel = filepath.Base(dir)
}
name := strings.NewReplacer(string(filepath.Separator), "-", "/", "-").Replace(rel)
stamp := upgradeBackupStamp()
- backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp)
- target := filepath.Join(backupRoot, name)
- for i := 1; ; i++ {
- if _, err := os.Stat(target); os.IsNotExist(err) {
- break
+ if err := upgradeMkdirAll(filepath.Join(homeDir, skillBackupSubdir), dirPermShared); err != nil {
+ return "", fmt.Errorf("创建备份根目录失败 %s: %w", filepath.Join(homeDir, skillBackupSubdir), err)
+ }
+ var backupRoot, target string
+ for i := 0; ; i++ {
+ candidate := stamp
+ if i > 0 {
+ candidate = fmt.Sprintf("%s-%d", stamp, i)
}
- backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i))
- target = filepath.Join(backupRoot, name)
if i > 1000 {
- return "", fmt.Errorf("备份目录冲突无法解决: %s", target)
+ return "", fmt.Errorf("备份目录冲突无法解决: %s", filepath.Join(homeDir, skillBackupSubdir, candidate))
}
+ backupRoot = filepath.Join(homeDir, skillBackupSubdir, candidate)
+ target = filepath.Join(backupRoot, name)
+ if _, err := upgradeLstat(target); err == nil {
+ continue // payload name already taken in this stamp root
+ } else if !os.IsNotExist(err) {
+ return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err)
+ }
+ if err := skillPathMkdir(backupRoot, dirPermShared); err == nil {
+ // We created the root: stamp ownership before any payload moves
+ // in, so an interrupted backup never leaves an unmarked
+ // (never-prunable) stamp behind.
+ if err := skillBackupWriteMarker(backupRoot); err != nil {
+ // Bounded cleanup: the root is empty and ours alone.
+ _ = skillPathRemove(backupRoot)
+ return "", fmt.Errorf("写入备份所有权标记失败 %s: %w", backupRoot, err)
+ }
+ break
+ } else if !os.IsExist(err) {
+ return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
+ } else if skillBackupMarkerValid(backupRoot) {
+ break // proven ours: same-stamp payloads from this run reuse it
+ }
+ // A stamp-shaped directory we cannot prove ours must never be
+ // adopted — writing the marker or the payload into it would turn
+ // its foreign contents into prunable DWS backups.
}
- if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil {
- return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
- }
- if err := upgradeRename(dir, target); err != nil {
+ if err := moveSkillPathRecoverably(dir, target); err != nil {
return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err)
}
// Keep the backup directory bounded; a prune failure must not fail the
@@ -151,13 +260,17 @@ const (
SkillDirSkipped // agent not detected, directory skipped
SkillDirBlacklisted // blacklisted, never touched
SkillDirFailed // installation attempted but failed
+ // SkillDirRetireWarning marks a universal Agent whose obsolete private copy
+ // could not be retired. Nothing is installed below such a root, so the
+ // leftover is reported without counting as an install failure.
+ SkillDirRetireWarning
)
// SkillDirResult holds the per-directory install result.
type SkillDirResult struct {
Dir string // destination directory (e.g. ~/.claude/skills/dws)
Status SkillDirStatus // outcome
- Err error // non-nil when Status == SkillDirFailed
+ Err error // non-nil when Status == SkillDirFailed or SkillDirRetireWarning
}
// SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call.
@@ -187,6 +300,18 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
return out
}
+// RetireWarnings returns universal Agent roots that still hold an obsolete
+// private copy because retiring it failed.
+func (r *SkillUpgradeResult) RetireWarnings() []SkillDirResult {
+ var out []SkillDirResult
+ for _, d := range r.Results {
+ if d.Status == SkillDirRetireWarning {
+ out = append(out, d)
+ }
+ }
+ return out
+}
+
// UpgradeSkillLocations refreshes skills from extractedDir into agent homes.
// extractedDir may be a multi-skill bundle root (subdirectories each containing
// SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that
@@ -204,11 +329,13 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
// `dws skill setup --mode mono` flow.
//
// Strategy (matches npm install.js installSkillsToHomes):
-// - Concrete agent dirs (claude, cursor, codex, ...) are updated when their
-// parent directory exists (e.g. ~/.codex/ exists => user has Codex)
-// - ~/.agents/skills/ is used only when no concrete Agent is detected
+// - ~/.agents/skills/ is always the canonical global store
+// - universal Agents (Codex, Cursor, Gemini, Cline, Amp, Copilot) read the
+// canonical store directly, so old agent-specific copies are retired
+// - detected non-universal Agents receive relative links to canonical;
+// filesystems that reject links receive a direct-copy fallback
// - ~/.real/ and other blacklisted paths are NEVER touched
-// - If no location was updated at all, fall back to ~/.agents/skills/
+// - canonical publication is mandatory and fails the upgrade loudly
//
// Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then
// removed so mono and multi never co-exist after an upgrade; a leftover that
@@ -277,9 +404,54 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string)
// backups are kept.
const skillBackupKeep = 5
+// The ownership marker inside a stamp root. Every DWS surface writes the
+// exact same bytes (install.sh, install-skills.sh, install-event.sh,
+// install-devapp.sh, the PowerShell installers, and build/npm/install.js):
+// a stamp-shaped directory name alone is not ownership proof, because users
+// and unrelated tools can create 20260819-120000-shaped names. Counting or
+// deleting a backup root requires the marker to verify, and adopting an
+// existing root for a new backup requires it first.
+const (
+ skillBackupMarkerName = ".dws-skill-backup"
+ skillBackupMarkerContent = "dws skill backup v1\n"
+)
+
+var (
+ skillBackupReadMarker = func(root string) (string, error) {
+ data, err := os.ReadFile(filepath.Join(root, skillBackupMarkerName))
+ return string(data), err
+ }
+ skillBackupWriteMarker = func(root string) error {
+ return os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644)
+ }
+)
+
+// skillBackupMarkerValid reports whether root carries the ownership marker
+// with exactly the expected bytes; a missing, unreadable, or differently
+// worded marker means the directory is foreign data.
+func skillBackupMarkerValid(root string) bool {
+ body, err := skillBackupReadMarker(root)
+ return err == nil && body == skillBackupMarkerContent
+}
+
+// skillBackupStampRegexp matches only directory names DWS itself writes:
+// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry
+// in the backup root is foreign (user data, unrelated tooling) and must be
+// left untouched, so pruning is restricted to names it can prove DWS created.
+var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`)
+
+// isSkillBackupStamp reports whether name is a DWS-created backup stamp.
+func isSkillBackupStamp(name string) bool {
+ return skillBackupStampRegexp.MatchString(name)
+}
+
// pruneSkillBackups removes the oldest backup directories when more than
-// skillBackupKeep remain. Best-effort: a removal failure never aborts, but
-// pruning failures are reported so callers can warn the user.
+// skillBackupKeep remain. Only directories whose names match the DWS backup
+// stamp format AND whose ownership marker verifies are candidates; unknown
+// or unmarked directories — including stamp roots created before the
+// ownership marker existed — are foreign data and preserved. Best-effort:
+// a removal failure never aborts, but pruning failures are reported so
+// callers can warn the user.
func pruneSkillBackups(homeDir string) error {
root := filepath.Join(homeDir, skillBackupSubdir)
entries, err := upgradeReadDir(root)
@@ -291,7 +463,12 @@ func pruneSkillBackups(homeDir string) error {
}
names := make([]string, 0, len(entries))
for _, e := range entries {
- if e.IsDir() {
+ if !e.IsDir() || !isSkillBackupStamp(e.Name()) {
+ continue
+ }
+ // A stamp-shaped name alone is not ownership proof: only roots
+ // whose marker verifies are counted or deleted.
+ if skillBackupMarkerValid(filepath.Join(root, e.Name())) {
names = append(names, e.Name())
}
}
@@ -404,20 +581,15 @@ func uniqueSkillDirs(paths []string) []string {
// restoreSkillSet removes any newly published directories, then restores all
// original directories in reverse backup order.
-func restoreSkillSet(published []string, backups []backedUpSkillDir) error {
- var restoreErr error
- for i := len(published) - 1; i >= 0; i-- {
- if err := upgradeRemoveAll(published[i]); err != nil {
- restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
- }
- }
+func restoreSkillSet(published []SkillPathPublication, backups []backedUpSkillDir) error {
+ restoreErr := upgradeRollbackPublishedSkillPaths(published)
for i := len(backups) - 1; i >= 0; i-- {
backup := backups[i]
if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err))
continue
}
- if err := upgradeRename(backup.backup, backup.original); err != nil {
+ if err := moveSkillPathRecoverably(backup.backup, backup.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err))
}
}
@@ -450,16 +622,17 @@ func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []st
if err != nil {
return err
}
- published := make([]string, 0, len(staged))
+ published := make([]SkillPathPublication, 0, len(staged))
for _, skill := range staged {
- if err := upgradeRename(skill.staged, skill.dest); err != nil {
- publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err)
+ publication, publishErr := upgradePublishSkillPath(skill.staged, skill.dest)
+ if publishErr != nil {
+ publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, publishErr)
if restoreErr := restoreSkillSet(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr))
}
return publishErr
}
- published = append(published, skill.dest)
+ published = append(published, publication)
}
return nil
}
@@ -517,100 +690,274 @@ func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []str
return publishStagedSkillSet(homeDir, staged, victims)
}
-func hasDetectedSpecificSkillRoot(homeDir string) bool {
- for _, agentDir := range knownSkillDirs {
- if isGenericSkillRoot(agentDir) || isBlacklisted(agentDir) {
- continue
- }
- parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
- if info, err := upgradeStat(parentGate); err == nil && info.IsDir() {
- return true
- }
- }
- return false
-}
-
func isGenericSkillRoot(agentDir string) bool {
return filepath.Clean(agentDir) == filepath.Clean(".agents/skills")
}
-func retireGenericSkillRoot(homeDir string, managed map[string]bool) error {
- base := filepath.Join(homeDir, ".agents", "skills")
+func isUniversalSkillRoot(agentDir string) bool {
+ return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))]
+}
+
+type resolvedSkillRoot struct {
+ base string
+ label string
+ universal bool
+ canonical bool
+}
+
+func resolveOpenClawSkillBase(homeDir string) string {
+ for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} {
+ base := filepath.Join(homeDir, name)
+ if info, err := upgradeStat(base); err == nil && info.IsDir() {
+ return filepath.Join(base, "skills")
+ }
+ }
+ return filepath.Join(homeDir, ".openclaw", "skills")
+}
+
+func configuredSkillRoots(homeDir string) []resolvedSkillRoot {
+ var roots []resolvedSkillRoot
+ seen := map[string]bool{}
+ configHome := strings.TrimSpace(upgradeGetenv("XDG_CONFIG_HOME"))
+ if configHome == "" {
+ configHome = filepath.Join(homeDir, ".config")
+ }
+ add := func(base, label string, universal, canonical bool) {
+ key := skillRootPathKey(base)
+ if seen[key] {
+ return
+ }
+ seen[key] = true
+ roots = append(roots, resolvedSkillRoot{base: base, label: label, universal: universal, canonical: canonical})
+ }
+ for _, agentDir := range knownSkillDirs {
+ base := filepath.Join(homeDir, agentDir)
+ switch filepath.ToSlash(filepath.Clean(agentDir)) {
+ case ".claude/skills":
+ if custom := strings.TrimSpace(upgradeGetenv("CLAUDE_CONFIG_DIR")); custom != "" {
+ base = filepath.Join(custom, "skills")
+ }
+ case ".codex/skills":
+ if custom := strings.TrimSpace(upgradeGetenv("CODEX_HOME")); custom != "" {
+ base = filepath.Join(custom, "skills")
+ }
+ case ".hermes/skills":
+ if custom := strings.TrimSpace(upgradeGetenv("HERMES_HOME")); custom != "" {
+ base = filepath.Join(custom, "skills")
+ }
+ case ".autohand/skills":
+ if custom := strings.TrimSpace(upgradeGetenv("AUTOHAND_HOME")); custom != "" {
+ base = filepath.Join(custom, "skills")
+ }
+ case ".grok/skills":
+ if custom := strings.TrimSpace(upgradeGetenv("GROK_HOME")); custom != "" {
+ base = filepath.Join(custom, "skills")
+ }
+ case ".vibe/skills":
+ if custom := strings.TrimSpace(upgradeGetenv("VIBE_HOME")); custom != "" {
+ base = filepath.Join(custom, "skills")
+ }
+ case ".openclaw/skills":
+ base = resolveOpenClawSkillBase(homeDir)
+ case ".config/agents/skills":
+ base = filepath.Join(configHome, "agents", "skills")
+ case ".config/opencode/skills":
+ base = filepath.Join(configHome, "opencode", "skills")
+ case ".config/crush/skills":
+ base = filepath.Join(configHome, "crush", "skills")
+ case ".config/devin/skills":
+ base = filepath.Join(configHome, "devin", "skills")
+ case ".config/goose/skills":
+ base = filepath.Join(configHome, "goose", "skills")
+ case ".config/kimchi/harness/skills":
+ base = filepath.Join(configHome, "kimchi", "harness", "skills")
+ }
+ add(base, agentDir, isUniversalSkillRoot(agentDir), isGenericSkillRoot(agentDir))
+ }
+ return roots
+}
+
+func skillRootDetectedBase(root resolvedSkillRoot) bool {
+ detectedDir := filepath.Dir(root.base)
+ switch filepath.ToSlash(filepath.Clean(root.label)) {
+ case ".config/kimchi/harness/skills":
+ detectedDir = filepath.Dir(filepath.Dir(root.base))
+ case ".tabnine/agent/skills":
+ detectedDir = filepath.Dir(filepath.Dir(root.base))
+ case ".zcode/skills":
+ // Application bundles are machine-scoped detection signals. Keep this
+ // independent of HOME so upgrade matches npm, Shell, and PowerShell.
+ if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() {
+ return true
+ }
+ case ".minimax/skills":
+ if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() {
+ return true
+ }
+ }
+ info, err := upgradeStat(detectedDir)
+ return err == nil && info.IsDir()
+}
+
+func samePhysicalSkillRoot(left, right string) bool {
+ leftReal, leftErr := upgradeEvalSymlinks(left)
+ rightReal, rightErr := upgradeEvalSymlinks(right)
+ if leftErr != nil || rightErr != nil {
+ return false
+ }
+ return skillRootPathKey(leftReal) == skillRootPathKey(rightReal)
+}
+
+func skillRootPathKey(path string) string {
+ clean := filepath.Clean(path)
+ if upgradeFoldPathCase {
+ clean = strings.ToLower(clean)
+ }
+ return clean
+}
+
+func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error {
victims, err := managedMultiSkillVictims(base, managed)
if err != nil {
return err
}
victims = append(victims, filepath.Join(base, "dws"))
if _, err := backupSkillSet(homeDir, victims); err != nil {
- return fmt.Errorf("迁移通用 Skill 根目录失败: %w", err)
+ return fmt.Errorf("迁移 Agent Skill 旧副本失败: %w", err)
}
return nil
}
+func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) (stageRoot string, staged []stagedSkillDir, err error) {
+ if err := upgradeMkdirAll(destBase, dirPermShared); err != nil {
+ return "", nil, fmt.Errorf("创建 Agent Skill 目录失败 %s: %w", destBase, err)
+ }
+ stageRoot, err = upgradeMkdirTemp(destBase, prefix)
+ if err != nil {
+ return "", nil, fmt.Errorf("创建 Skill 链接 staging 失败 %s: %w", destBase, err)
+ }
+ defer func() {
+ if err != nil {
+ _ = upgradeRemoveAll(stageRoot)
+ }
+ }()
+ realDestBase, realErr := upgradeEvalSymlinks(destBase)
+ if realErr != nil {
+ return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", destBase, realErr)
+ }
+ for _, name := range names {
+ target := filepath.Join(canonicalBase, name)
+ realTarget, targetErr := upgradeEvalSymlinks(target)
+ if targetErr != nil {
+ return stageRoot, nil, fmt.Errorf("解析 canonical Skill 失败 %s: %w", target, targetErr)
+ }
+ relTarget, relErr := upgradeRel(realDestBase, realTarget)
+ if relErr != nil {
+ return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr)
+ }
+ stagedPath := filepath.Join(stageRoot, name)
+ if linkErr := upgradeSymlink(relTarget, stagedPath); linkErr != nil {
+ return stageRoot, nil, fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedPath, relTarget, linkErr)
+ }
+ staged = append(staged, stagedSkillDir{staged: stagedPath, dest: filepath.Join(destBase, name)})
+ }
+ return stageRoot, staged, nil
+}
+
+func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names []string, victims []string) error {
+ needed := make([]string, 0, len(names))
+ correct := make(map[string]bool, len(names))
+ for _, name := range names {
+ dest := filepath.Join(destBase, name)
+ canonical := filepath.Join(canonicalBase, name)
+ if samePhysicalSkillRoot(dest, canonical) {
+ correct[filepath.Clean(dest)] = true
+ continue
+ }
+ needed = append(needed, name)
+ }
+ filteredVictims := make([]string, 0, len(victims))
+ for _, victim := range victims {
+ if !correct[filepath.Clean(victim)] {
+ filteredVictims = append(filteredVictims, victim)
+ }
+ }
+ stageRoot, staged, err := stageLinkedSkillSet(destBase, canonicalBase, ".dws-link-set-", needed)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = upgradeRemoveAll(stageRoot) }()
+ return publishStagedSkillSet(homeDir, staged, filteredVictims)
+}
+
// upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory
// per agent home.
func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) {
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
- hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir)
+ canonicalBase := filepath.Join(homeDir, ".agents", "skills")
+ canonicalDest := filepath.Join(canonicalBase, "dws")
+ if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil {
+ result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err})
+ // Canonical is the mandatory store: universal agents read it directly, so a
+ // failed canonical publish means nobody received the upgrade. Always fail
+ // loud — including universal-only machines — instead of reporting success
+ // with nothing installed.
+ return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
+ }
+ result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK})
- for _, agentDir := range knownSkillDirs {
- destDir := filepath.Join(homeDir, agentDir, "dws")
-
- if isBlacklisted(agentDir) {
+ for _, root := range configuredSkillRoots(homeDir) {
+ if root.canonical {
+ continue
+ }
+ destBase := root.base
+ destDir := filepath.Join(destBase, "dws")
+ if isBlacklisted(root.label) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
continue
}
- if isGenericSkillRoot(agentDir) && hasSpecificRoot {
+ if !skillRootDetectedBase(root) {
+ result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
-
- if !isGenericSkillRoot(agentDir) {
- parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
- if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
+ if samePhysicalSkillRoot(destBase, canonicalBase) {
+ result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
+ continue
+ }
+ if root.universal {
+ if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
+ // Nothing is installed below a universal root, so a stale copy that
+ // resists retirement is a warning, not an install failure.
+ result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirRetireWarning, Err: err})
+ } else {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
+ }
+ continue
+ }
+ victims, victimErr := monoUpgradeVictims(destBase, destDir, managedNames)
+ if victimErr != nil {
+ result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: victimErr})
+ continue
+ }
+ if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, []string{"dws"}, victims); err != nil {
+ // An uncertain destination may belong to a concurrent writer.
+ // The copy fallback below would back that object aside and
+ // replace it — exactly what the sentinel forbids — so surface
+ // the uncertain state instead of retrying over it.
+ if errors.Is(err, ErrSkillPathPublicationUncertain) {
+ result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
+ continue
+ }
+ // Match the upstream installer: platforms/filesystems that reject
+ // links receive a direct copy rather than losing Agent support.
+ if copyErr := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); copyErr != nil {
+ result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: errors.Join(err, copyErr)})
continue
}
}
-
- if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); err != nil {
- result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
- continue
- }
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
}
- if hasSpecificRoot && len(result.Succeeded()) > 0 {
- genericBase := filepath.Join(homeDir, ".agents", "skills")
- if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
- result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
- } else {
- result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
- }
- }
-
- // Fallback: if nothing succeeded, force the primary location. The multi
- // leftovers under the primary base are the usual reason the primary
- // install failed, so clean them first — failing loud like the multi
- // fallback — instead of letting mono and multi co-exist marked OK.
- if len(result.Succeeded()) == 0 && !hasSpecificRoot {
- destBase := filepath.Join(homeDir, ".agents", "skills")
- dest := filepath.Join(destBase, "dws")
- if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil {
- return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
- }
- // Replace the earlier failed entry for this dir (if any) or append a new one
- replaced := false
- for idx, r := range result.Results {
- if r.Dir == dest {
- result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK}
- replaced = true
- break
- }
- }
- if !replaced {
- result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK})
- }
- }
// Best-effort: refresh the user-level mono cache so that
// `dws skill setup --mode mono` fallbacks stay on the upgraded version
@@ -633,61 +980,62 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
- hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir)
+ canonicalBase := filepath.Join(homeDir, ".agents", "skills")
+ if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil {
+ result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err})
+ // Canonical is the mandatory store (see the mono branch): fail loud even
+ // on universal-only machines instead of reporting success with nothing
+ // installed.
+ return result, fmt.Errorf("canonical Skill 安装失败: %w", err)
+ }
+ result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK})
- for _, agentDir := range knownSkillDirs {
- destBase := filepath.Join(homeDir, agentDir)
-
- if isBlacklisted(agentDir) {
+ for _, root := range configuredSkillRoots(homeDir) {
+ if root.canonical {
+ continue
+ }
+ destBase := root.base
+ if isBlacklisted(root.label) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
continue
}
- if isGenericSkillRoot(agentDir) && hasSpecificRoot {
+ if !skillRootDetectedBase(root) {
+ result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
-
- if !isGenericSkillRoot(agentDir) {
- parentGate := filepath.Dir(destBase)
- if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
+ if samePhysicalSkillRoot(destBase, canonicalBase) {
+ result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
+ continue
+ }
+ if root.universal {
+ if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil {
+ // Nothing is installed below a universal root, so a stale copy that
+ // resists retirement is a warning, not an install failure.
+ result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirRetireWarning, Err: err})
+ } else {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
+ }
+ continue
+ }
+ victims, victimErr := multiUpgradeVictims(destBase, skillSet, skills, managedNames)
+ if victimErr != nil {
+ result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: victimErr})
+ continue
+ }
+ if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, skills, victims); err != nil {
+ // An uncertain destination may belong to a concurrent writer;
+ // the copy fallback must not displace it (see the mono branch).
+ if errors.Is(err, ErrSkillPathPublicationUncertain) {
+ result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
+ continue
+ }
+ if copyErr := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); copyErr != nil {
+ result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: errors.Join(err, copyErr)})
continue
}
}
-
- if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
- result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
- continue
- }
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
- if hasSpecificRoot && len(result.Succeeded()) > 0 {
- genericBase := filepath.Join(homeDir, ".agents", "skills")
- if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
- result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
- } else {
- result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
- }
- }
-
- // Fallback: if nothing succeeded, force the primary location
- if len(result.Succeeded()) == 0 && !hasSpecificRoot {
- destBase := filepath.Join(homeDir, ".agents", "skills")
- if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
- return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
- }
- // Replace the earlier failed entry for this dir (if any) or append a new one
- replaced := false
- for idx, r := range result.Results {
- if r.Dir == destBase {
- result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK}
- replaced = true
- break
- }
- }
- if !replaced {
- result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
- }
- }
// Best-effort: refresh the user-level caches so that `dws skill setup`
// fallbacks stay on the upgraded version. The release zip ships both
@@ -762,23 +1110,6 @@ func skillTreeHasRoot(dir string) bool {
return err == nil && !info.IsDir()
}
-// cleanupMultiLeftovers backs up + removes every proven DWS-managed
-// multi-mode skill directory inside one agent home before mono is installed.
-// A missing base directory simply means no leftovers; any other read failure
-// is reported so mono never silently co-exists with multi. Removal is
-// reversible: each leftover is preserved under ~/.dws/skill-backups/ and a
-// backup failure aborts the removal for that home.
-func cleanupMultiLeftovers(homeDir, baseDir string) error {
- victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
- if err != nil {
- return err
- }
- if _, err := backupSkillSet(homeDir, victims); err != nil {
- return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
- }
- return nil
-}
-
func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) {
entries, err := upgradeReadDir(baseDir)
if err != nil {
@@ -789,7 +1120,7 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str
}
victims := make([]string, 0)
for _, e := range entries {
- if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
+ if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(baseDir, e.Name()))
@@ -797,24 +1128,6 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str
return victims, nil
}
-// cleanupOppositeModeLeftovers backs up + removes, inside one agent home, the
-// legacy mono directory (dws/) and every proven DWS-managed multi skill
-// directory that is not part of the new bundle. A dingtalk-* prefix alone is
-// not proof of ownership because market/user skills may use the same prefix.
-// Removal is reversible: each
-// directory is preserved under ~/.dws/skill-backups/ and a backup failure
-// aborts the removal for that home.
-func cleanupOppositeModeLeftovers(homeDir, destBase string, skillSet map[string]bool) error {
- victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
- if err != nil {
- return err
- }
- if _, err := backupSkillSet(homeDir, victims); err != nil {
- return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
- }
- return nil
-}
-
func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) {
victims := []string{filepath.Join(destBase, "dws")}
entries, err := upgradeReadDir(destBase)
@@ -825,7 +1138,7 @@ func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed
return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err)
}
for _, e := range entries {
- if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
+ if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(destBase, e.Name()))
diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go
index ec55c21d..a6f2f7a9 100644
--- a/internal/upgrade/paths_multi_test.go
+++ b/internal/upgrade/paths_multi_test.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"reflect"
+ "runtime"
"strings"
"testing"
@@ -22,6 +23,16 @@ func withFakeHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil })
+ // Host-installed macOS apps must not make a fake HOME appear to have extra
+ // detected Agents. Tests that exercise application-based detection can
+ // override this seam explicitly.
+ testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
+ applications := filepath.Join(string(filepath.Separator), "Applications")
+ if path == filepath.Join(applications, "ZCode.app") || path == filepath.Join(applications, "MiniMax Code.app") {
+ return nil, os.ErrNotExist
+ }
+ return os.Stat(path)
+ })
return home
}
@@ -124,8 +135,7 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) {
func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
home := withFakeHome(t)
- // A concrete Agent root wins over the generic .agents fallback; .claude
- // installs and .cursor is skipped.
+ // .agents is canonical; detected Claude receives links and Cursor is skipped.
agentsBase := filepath.Join(home, ".agents", "skills")
claudeBase := filepath.Join(home, ".claude", "skills")
for _, base := range []string{agentsBase, claudeBase} {
@@ -193,17 +203,19 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
// Succeeded entries report the agent home base in multi mode.
succeeded := result.Succeeded()
- if len(succeeded) != 1 {
- t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results)
+ if len(succeeded) != 2 {
+ t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results)
}
- wantDirs := map[string]bool{claudeBase: true}
+ wantDirs := map[string]bool{agentsBase: true, claudeBase: true}
for _, d := range succeeded {
if !wantDirs[d.Dir] {
t.Errorf("unexpected succeeded dir %q", d.Dir)
}
}
- if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
- t.Fatalf("generic mono duplicate still visible: %v", err)
+ for _, name := range []string{"dingtalk-chat", "dws-shared"} {
+ if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
+ t.Fatalf("canonical Skill missing: %v", err)
+ }
}
// Multi cache refreshed under the fake home.
@@ -216,7 +228,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
}
}
-func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) {
+func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"})
@@ -241,12 +253,12 @@ func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicat
t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err)
}
- want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md")
+ want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md")
if _, err := os.Stat(want); err != nil {
- t.Fatalf("canonical Codex Skill missing at %s: %v", want, err)
+ t.Fatalf("canonical Skill missing at %s: %v", want, err)
}
for _, duplicate := range []string{
- filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"),
+ filepath.Join(codexBase, "dingtalk-chat"),
filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"),
} {
if _, err := os.Stat(duplicate); !os.IsNotExist(err) {
@@ -502,6 +514,9 @@ func TestUpgradeMonoCleansPreStateOfficialAndPreservesCustom(t *testing.T) {
// mono-only package path).
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) {
home := withFakeHome(t)
+ if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
+ t.Fatal(err)
+ }
agentsBase := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
t.Fatal(err)
@@ -526,21 +541,15 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(
}
result, err := UpgradeSkillLocations(mono)
- if err != nil {
- t.Fatalf("UpgradeSkillLocations() error = %v", err)
- }
- if failed := result.Failed(); len(failed) != 0 {
- t.Fatalf("fallback should replace the failed entry with OK, got failed=%v", failed)
- }
- if got := len(result.Succeeded()); got != 1 {
- t.Fatalf("Succeeded() len = %d, want 1 (%v)", got, result.Results)
+ if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
+ t.Fatalf("canonical copy failure = %#v, %v", result, err)
}
data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md"))
if err != nil {
t.Fatalf("mono fallback install missing: %v", err)
}
- if string(data) != "# mono" {
- t.Errorf("mono content = %q, want refreshed package", data)
+ if string(data) != "old" {
+ t.Errorf("mono content = %q, want original preserved", data)
}
}
@@ -574,22 +583,22 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
}
result, err := UpgradeSkillLocations(mono)
- if err != nil {
- t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err)
+ if err == nil {
+ t.Fatal("canonical read failure must fail the upgrade")
}
failed := result.Failed()
if len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
}
- wantDir := agentsBase
+ wantDir := filepath.Join(agentsBase, "dws")
if failed[0].Dir != wantDir || failed[0].Err == nil {
t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir)
}
if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") {
t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err)
}
- if got := len(result.Succeeded()); got != 1 {
- t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
+ if got := len(result.Succeeded()); got != 0 {
+ t.Fatalf("Succeeded() len = %d, want 0", got)
}
// Mono must NOT have been laid down next to the unreadable multi state.
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
@@ -603,6 +612,9 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
// installing multi next to the stale skills.
func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) {
home := withFakeHome(t)
+ if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
+ t.Fatal(err)
+ }
agentsBase := filepath.Join(home, ".agents", "skills")
staleDir := filepath.Join(agentsBase, "dingtalk-stale")
if err := os.MkdirAll(staleDir, 0o755); err != nil {
@@ -613,10 +625,10 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
}
useUpgradeManagedNames(t, filepath.Base(staleDir))
- origRename := upgradeRename
- testseam.Swap(t, &upgradeRename, func(src, dst string) error {
+ origRename := skillPathRenameNoReplace
+ testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if strings.Contains(src, "dingtalk-stale") {
- return errors.New("injected backup failure")
+ return "", errors.New("injected backup failure")
}
return origRename(src, dst)
})
@@ -628,8 +640,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
if err == nil {
t.Fatal("multi fallback cleanup failure must return an error")
}
- if !strings.Contains(err.Error(), "回退到主目录也失败") {
- t.Fatalf("error should mention the fallback cleanup failure, got %v", err)
+ if !strings.Contains(err.Error(), "canonical Skill 安装失败") {
+ t.Fatalf("error should mention the canonical cleanup failure, got %v", err)
}
if failed := result.Failed(); len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
@@ -643,8 +655,17 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t
}
}
+// seedSkillBackupMarker stamps a backup root as DWS-owned, the way every
+// install surface does before any payload moves in.
+func seedSkillBackupMarker(t *testing.T, root string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
// TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe
-// contract of the backup helper: non-dir paths are no-ops, a colliding backup
+// contract of the backup helper: all lexical paths are preserved, a colliding backup
// target gets a numbered stamp, and any failure (mkdir / rename / unresolvable
// collision) leaves the original directory untouched with an error.
func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
@@ -654,8 +675,8 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
// backup. Inject it through the path seam so this branch is portable to
// Windows, where chmod-based permission failures are not reliable.
statFailurePath := filepath.Join(home, "stat-failure")
- origStat := upgradeStat
- testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
+ origStat := upgradeLstat
+ testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
if path == statFailurePath {
return nil, errors.New("injected stat failure")
}
@@ -665,16 +686,16 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatalf("stat failure = (%q, %v), want wrapped error", got, err)
}
- // Regular file: no-op, no backup.
+ // Regular file: preserve it instead of allowing a later rename to overwrite it.
filePath := filepath.Join(home, "not-a-dir")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
- if got, err := backupAndRemoveSkillDir(home, filePath); got != "" || err != nil {
- t.Fatalf("regular file = (%q, %v), want no-op", got, err)
+ if got, err := backupAndRemoveSkillDir(home, filePath); got == "" || err != nil {
+ t.Fatalf("regular file = (%q, %v), want backup", got, err)
}
- if _, err := os.Stat(filePath); err != nil {
- t.Fatalf("regular file must survive: %v", err)
+ if _, err := os.Stat(filePath); !os.IsNotExist(err) {
+ t.Fatalf("regular file must move into backup: %v", err)
}
// A skill directory outside home falls back to its basename instead of
@@ -722,6 +743,46 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatalf("victim must be gone after backup, stat err=%v", err)
}
+ // A dangling link at the first backup target is still an occupied lexical
+ // path and must select a numbered target rather than being overwritten.
+ danglingVictim := filepath.Join(home, ".agents", "skills", "dangling-collision")
+ if err := os.MkdirAll(danglingVictim, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ danglingTaken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dangling-collision")
+ if err := os.Symlink("missing-backup", danglingTaken); err == nil {
+ got, err = backupAndRemoveSkillDir(home, danglingVictim)
+ if err != nil {
+ t.Fatalf("backup with dangling collision error = %v", err)
+ }
+ want = filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dangling-collision")
+ if got != want {
+ t.Fatalf("dangling collision backup path = %q, want %q", got, want)
+ }
+ } else if runtime.GOOS != "windows" {
+ t.Fatal(err)
+ }
+
+ // An unreadable target probe is not equivalent to a free path.
+ probeVictim := filepath.Join(home, "probe-victim")
+ if err := os.MkdirAll(probeVictim, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ probeTarget := filepath.Join(home, skillBackupSubdir, "20260810-000000", "probe-victim")
+ testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
+ if path == probeTarget {
+ return nil, errors.New("target probe denied")
+ }
+ return os.Lstat(path)
+ })
+ if _, err := backupAndRemoveSkillDir(home, probeVictim); err == nil || !strings.Contains(err.Error(), "检查备份目标失败") {
+ t.Fatalf("target probe error = %v", err)
+ }
+ if _, err := os.Stat(probeVictim); err != nil {
+ t.Fatalf("probe victim must survive target check failure: %v", err)
+ }
+ testseam.Swap(t, &upgradeLstat, os.Lstat)
+
// Unresolvable collision (>1000 numbered stamps taken) fails and keeps dir.
victim2 := filepath.Join(home, "victim2")
if err := os.MkdirAll(victim2, 0o755); err != nil {
@@ -749,7 +810,7 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
t.Fatal(err)
}
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") })
- if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份目录失败") {
+ if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份根目录失败") {
t.Fatalf("mkdir error = %v", err)
}
if _, err := os.Stat(victim3); err != nil {
@@ -762,14 +823,14 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
if err := os.MkdirAll(victim4, 0o755); err != nil {
t.Fatal(err)
}
- testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("rename denied") })
+ testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("rename denied") })
if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") {
t.Fatalf("rename error = %v", err)
}
if _, err := os.Stat(victim4); err != nil {
t.Fatalf("victim4 must survive rename failure: %v", err)
}
- testseam.Swap(t, &upgradeRename, os.Rename)
+ testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
}
// TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention:
@@ -779,9 +840,11 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
home := t.TempDir()
root := filepath.Join(home, skillBackupSubdir)
for i := 0; i < skillBackupKeep+2; i++ {
- if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260810-00000%d", i)), 0o755); err != nil {
+ stampDir := filepath.Join(root, fmt.Sprintf("20260810-00000%d", i))
+ if err := os.MkdirAll(stampDir, 0o755); err != nil {
t.Fatal(err)
}
+ seedSkillBackupMarker(t, stampDir)
}
if err := pruneSkillBackups(home); err != nil {
t.Fatalf("pruneSkillBackups() error = %v", err)
@@ -814,9 +877,11 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
// Removal failure is reported as the first error. Seed more than
// skillBackupKeep dirs again so the prune loop actually runs.
for i := 0; i < skillBackupKeep+2; i++ {
- if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260811-00000%d", i)), 0o755); err != nil {
+ stampDir := filepath.Join(root, fmt.Sprintf("20260811-00000%d", i))
+ if err := os.MkdirAll(stampDir, 0o755); err != nil {
t.Fatal(err)
}
+ seedSkillBackupMarker(t, stampDir)
}
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return errors.New("remove denied") })
if err := pruneSkillBackups(home); err == nil {
@@ -825,6 +890,86 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll)
}
+// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that
+// pruneSkillBackups only retires directories whose names match the DWS backup
+// stamp format. Any foreign directory in the backup root must survive even when
+// more than skillBackupKeep stamped backups exist.
+func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) {
+ home := t.TempDir()
+ root := filepath.Join(home, skillBackupSubdir)
+ for i := 0; i < skillBackupKeep+2; i++ {
+ stampDir := filepath.Join(root, fmt.Sprintf("20260910-00000%d", i))
+ if err := os.MkdirAll(stampDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ seedSkillBackupMarker(t, stampDir)
+ }
+ // A collision-suffixed stamp is still a valid DWS backup and is eligible.
+ suffixed := filepath.Join(root, "20260910-000000-7")
+ if err := os.MkdirAll(suffixed, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ seedSkillBackupMarker(t, suffixed)
+ // Stamp-shaped names without a verifying marker are foreign data too:
+ // the name format alone proves nothing.
+ unmarked := filepath.Join(root, "20260901-000000")
+ if err := os.MkdirAll(unmarked, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(unmarked, "user-data.txt"), []byte("must survive"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ wrongMarker := filepath.Join(root, "20260902-000000")
+ if err := os.MkdirAll(wrongMarker, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(wrongMarker, skillBackupMarkerName), []byte("not dws"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ unknown := []string{
+ "user-personal-backup",
+ "20260910-000000-abc", // stamp-shaped but non-numeric suffix
+ "20260910-00000", // too short
+ "notes",
+ "20260901-000000", // stamp-shaped but unmarked
+ "20260902-000000", // stamp-shaped but wrong marker content
+ }
+ for _, name := range unknown {
+ if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if err := pruneSkillBackups(home); err != nil {
+ t.Fatalf("pruneSkillBackups() error = %v", err)
+ }
+
+ for _, name := range unknown {
+ if _, err := os.Stat(filepath.Join(root, name)); err != nil {
+ t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err)
+ }
+ }
+ // Only marker-proven backups beyond skillBackupKeep are pruned; foreign
+ // entries (including unmarked stamp-shaped ones) are excluded from the
+ // count, so the proven total stays at skillBackupKeep.
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ proven := 0
+ for _, e := range entries {
+ if isSkillBackupStamp(e.Name()) && skillBackupMarkerValid(filepath.Join(root, e.Name())) {
+ proven++
+ }
+ }
+ if proven != skillBackupKeep {
+ t.Errorf("marker-proven backups after prune = %d, want %d", proven, skillBackupKeep)
+ }
+ if data, err := os.ReadFile(filepath.Join(unmarked, "user-data.txt")); err != nil || string(data) != "must survive" {
+ t.Errorf("unmarked stamp-shaped data must survive, got %q, %v", data, err)
+ }
+}
+
// TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of
// the mono-source resolver and the multi-bundle child lookup.
func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
@@ -888,88 +1033,27 @@ func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
// TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono
// path's fail-loud backup semantics and every primary-fallback outcome.
func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
- originalDirs := append([]string(nil), knownSkillDirs...)
- t.Cleanup(func() { knownSkillDirs = originalDirs })
-
mono := t.TempDir()
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
-
- // Per-home backup failure marks that home failed; another home still wins.
home := withFakeHome(t)
- knownSkillDirs = []string{".agents/skills", ".claude/skills"}
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755)
os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644)
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
- testseam.Swap(t, &upgradeRename, func(src, dst string) error {
+ testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if strings.Contains(src, ".agents") {
- return errors.New("backup denied")
+ return "", errors.New("backup denied")
}
- return os.Rename(src, dst)
+ return renameSkillPathNoReplace(src, dst)
})
result, err := UpgradeSkillLocations(mono)
- if err != nil {
- t.Fatalf("UpgradeSkillLocations() error = %v", err)
- }
- if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
- t.Fatalf("failed = %v, want exactly 1 backup-failed home", failed)
+ if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
+ t.Fatalf("canonical backup failure = %#v, %v", result, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
- t.Fatalf("failed home must keep its original dws/: %v", err)
- }
- if got := len(result.Succeeded()); got != 1 {
- t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got)
- }
- testseam.Swap(t, &upgradeRename, os.Rename)
-
- // Fallback cleanup failure: only a blacklisted home in the main loop, then
- // the primary cleanup hits a stale dir whose backup fails.
- home2 := t.TempDir()
- testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
- knownSkillDirs = []string{".real/skills"}
- stale := filepath.Join(home2, ".agents", "skills", "dingtalk-stale")
- os.MkdirAll(stale, 0o755)
- useUpgradeManagedNames(t, filepath.Base(stale))
- testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") })
- if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
- t.Fatalf("fallback cleanup error = %v", err)
- }
- if _, err := os.Stat(stale); err != nil {
- t.Fatalf("stale dir must survive failed cleanup: %v", err)
- }
-
- // Fallback backup failure of the primary dws/ itself.
- home3 := t.TempDir()
- testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
- os.MkdirAll(filepath.Join(home3, ".agents", "skills", "dws"), 0o755)
- if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
- t.Fatalf("fallback backup error = %v", err)
- }
- testseam.Swap(t, &upgradeRename, os.Rename)
-
- // Fallback copy failure: everything fails loud.
- home4 := t.TempDir()
- testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
- testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
- if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
- t.Fatalf("fallback copy error = %v", err)
- }
- testseam.Swap(t, &upgradeCopyDir, copyDir)
-
- // Fallback append (no prior entry for the primary dir): a blacklisted-only
- // main loop leaves no primary entry, so the fallback appends a fresh OK.
- home5 := t.TempDir()
- testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
- result, err = UpgradeSkillLocations(mono)
- if err != nil {
- t.Fatalf("fallback append error = %v", err)
- }
- if got := len(result.Succeeded()); got != 1 {
- t.Fatalf("Succeeded() len = %d, want 1 appended primary", got)
- }
- if _, err := os.Stat(filepath.Join(home5, ".agents", "skills", "dws", "SKILL.md")); err != nil {
- t.Fatalf("fallback append install missing: %v", err)
+ t.Fatalf("failed canonical home must keep its original dws/: %v", err)
}
}
@@ -977,15 +1061,10 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
// path's blacklisted branch, per-skill backup/copy failures, and the primary
// fallback outcomes (cleanup OK + backup/copy failure, fresh append).
func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
- originalDirs := append([]string(nil), knownSkillDirs...)
- t.Cleanup(func() { knownSkillDirs = originalDirs })
-
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
-
- // Blacklisted entry is reported, non-blacklisted installs.
home := withFakeHome(t)
- knownSkillDirs = []string{".real/skills", ".agents/skills"}
+ testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"})
os.MkdirAll(filepath.Join(home, ".agents"), 0o755)
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
@@ -1003,119 +1082,33 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) {
t.Fatalf("blacklisted home must never be touched, stat err=%v", err)
}
+ if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
+ t.Fatalf("canonical install missing: %v", err)
+ }
+}
- // A detected concrete Agent root wins over .agents. A failure while retiring
- // the old generic copy is surfaced after the concrete root succeeds.
- home2 := t.TempDir()
- testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
- knownSkillDirs = []string{".agents/skills", ".claude/skills"}
- os.MkdirAll(filepath.Join(home2, ".agents", "skills", "dingtalk-chat"), 0o755)
- os.MkdirAll(filepath.Join(home2, ".claude"), 0o755)
- testseam.Swap(t, &upgradeRename, func(src, dst string) error {
- if strings.Contains(src, ".agents") {
- return errors.New("backup denied")
- }
- return os.Rename(src, dst)
- })
- result, err = UpgradeSkillLocations(multiRoot)
+// The production wrappers that used to bundle these two steps were removed as
+// dead code; the test keeps exercising the live building blocks they composed.
+func cleanupMultiLeftoversForTest(homeDir, baseDir string) error {
+ victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
if err != nil {
- t.Fatalf("UpgradeSkillLocations() error = %v", err)
+ return err
}
- if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
- t.Fatalf("failed = %v, want 1 backup-failed home", failed)
+ if _, err := backupSkillSet(homeDir, victims); err != nil {
+ return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
}
- if _, err := os.Stat(filepath.Join(home2, ".agents", "skills", "dingtalk-chat")); err != nil {
- t.Fatalf("failed home must keep its original skill: %v", err)
- }
- testseam.Swap(t, &upgradeRename, os.Rename)
+ return nil
+}
- // Per-skill copy failure on the concrete home fails that home; with no
- // successful concrete target, the generic copy is left untouched.
- home3 := t.TempDir()
- testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
- os.MkdirAll(filepath.Join(home3, ".claude"), 0o755)
- testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
- if strings.Contains(dst, ".claude") {
- return errors.New("copy denied")
- }
- return copyDir(src, dst)
- })
- result, err = UpgradeSkillLocations(multiRoot)
+func cleanupOppositeModeLeftoversForTest(homeDir, destBase string, skillSet map[string]bool) error {
+ victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
if err != nil {
- t.Fatalf("UpgradeSkillLocations() error = %v", err)
+ return err
}
- if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil {
- t.Fatalf("failed = %v, want 1 copy-failed home", failed)
- }
- testseam.Swap(t, &upgradeCopyDir, copyDir)
-
- // Fallback backup failure: the same-name bundle skill refresh is what
- // fails (cleanup succeeds, the per-skill backup does not).
- home4 := t.TempDir()
- testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil })
- knownSkillDirs = []string{".real/skills"}
- os.MkdirAll(filepath.Join(home4, ".agents", "skills", "dingtalk-chat"), 0o755)
- testseam.Swap(t, &upgradeRename, func(src, dst string) error {
- if strings.Contains(src, "dingtalk-chat") {
- return errors.New("backup denied")
- }
- return os.Rename(src, dst)
- })
- if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
- t.Fatalf("fallback backup error = %v", err)
- }
- testseam.Swap(t, &upgradeRename, os.Rename)
-
- // Fallback copy failure.
- home5 := t.TempDir()
- testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil })
- testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") })
- if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") {
- t.Fatalf("fallback copy error = %v", err)
- }
- testseam.Swap(t, &upgradeCopyDir, copyDir)
-
- // Fallback append: blacklisted-only main loop → fresh OK entry appended.
- home6 := t.TempDir()
- testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home6, nil })
- result, err = UpgradeSkillLocations(multiRoot)
- if err != nil {
- t.Fatalf("fallback append error = %v", err)
- }
- if got := len(result.Succeeded()); got != 1 {
- t.Fatalf("Succeeded() len = %d, want 1 appended primary (%v)", got, result.Results)
- }
- if _, err := os.Stat(filepath.Join(home6, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
- t.Fatalf("fallback append install missing: %v", err)
- }
-
- // Fallback replace: the main loop fails the primary home on a transient
- // copy error, the fallback retry succeeds and replaces the failed entry.
- home7 := t.TempDir()
- testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home7, nil })
- knownSkillDirs = []string{".agents/skills"}
- origCopy := upgradeCopyDir
- attempts := 0
- testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
- attempts++
- if attempts == 1 {
- return errors.New("transient copy failure")
- }
- return origCopy(src, dst)
- })
- result, err = UpgradeSkillLocations(multiRoot)
- if err != nil {
- t.Fatalf("fallback replace error = %v", err)
- }
- if got := len(result.Succeeded()); got != 1 {
- t.Fatalf("Succeeded() len = %d, want 1 replaced primary (%v)", got, result.Results)
- }
- if failed := result.Failed(); len(failed) != 0 {
- t.Fatalf("failed entry must be replaced by OK, got %v", failed)
- }
- if _, err := os.Stat(filepath.Join(home7, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
- t.Fatalf("fallback replace install missing: %v", err)
+ if _, err := backupSkillSet(homeDir, victims); err != nil {
+ return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
}
+ return nil
}
// TestCrossPlatformCoverageCleanupLeftoversEdges pins the cleanup helpers:
@@ -1127,40 +1120,40 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
// Read failure (non-ENOENT) surfaces from both cleanups.
testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("read denied") })
- if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
- t.Fatalf("cleanupMultiLeftovers read error = %v", err)
+ if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
+ t.Fatalf("cleanupMultiLeftoversForTest read error = %v", err)
}
- if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
- t.Fatalf("cleanupOppositeModeLeftovers read error = %v", err)
+ if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
+ t.Fatalf("cleanupOppositeModeLeftoversForTest read error = %v", err)
}
testseam.Swap(t, &upgradeReadDir, os.ReadDir)
// Backup failure of a multi leftover aborts cleanupMultiLeftovers.
os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755)
useUpgradeManagedNames(t, "dingtalk-stale")
- testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") })
- if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") {
+ testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("backup denied") })
+ if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") {
t.Fatalf("cleanupMultiLeftovers backup error = %v", err)
}
// Backup failure of the mono leftover aborts the opposite-mode cleanup.
os.MkdirAll(filepath.Join(base, "dws"), 0o755)
- if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
+ if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup mono backup error = %v", err)
}
- testseam.Swap(t, &upgradeRename, os.Rename)
+ testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
// Backup failure of a stale (non-mono) skill aborts with its own message.
- testseam.Swap(t, &upgradeRename, func(src, dst string) error {
+ testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if strings.Contains(src, "dingtalk-stale") {
- return errors.New("backup denied")
+ return "", errors.New("backup denied")
}
- return os.Rename(src, dst)
+ return renameSkillPathNoReplace(src, dst)
})
- if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
+ if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup stale backup error = %v", err)
}
- testseam.Swap(t, &upgradeRename, os.Rename)
+ testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
// Success matrix: mono leftover + stale skill removed into backups, bundle
// skill and regular file preserved.
@@ -1168,7 +1161,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
skillSet := map[string]bool{"dingtalk-keep": true}
os.MkdirAll(filepath.Join(base, "dingtalk-keep"), 0o755)
os.WriteFile(filepath.Join(base, "regular-file"), []byte("x"), 0o644)
- if err := cleanupOppositeModeLeftovers(home, base, skillSet); err != nil {
+ if err := cleanupOppositeModeLeftoversForTest(home, base, skillSet); err != nil {
t.Fatalf("cleanupOppositeModeLeftovers() error = %v", err)
}
for _, gone := range []string{"dws", "dingtalk-stale"} {
@@ -1188,7 +1181,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
}
// cleanupMultiLeftovers on a missing base is a no-op.
- if err := cleanupMultiLeftovers(home, filepath.Join(home, "missing")); err != nil {
+ if err := cleanupMultiLeftoversForTest(home, filepath.Join(home, "missing")); err != nil {
t.Fatalf("missing base must be a no-op, got %v", err)
}
}
diff --git a/internal/upgrade/skill_backup_ownership_test.go b/internal/upgrade/skill_backup_ownership_test.go
new file mode 100644
index 00000000..a9098806
--- /dev/null
+++ b/internal/upgrade/skill_backup_ownership_test.go
@@ -0,0 +1,113 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package upgrade
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
+)
+
+// TestCrossPlatformCoverageSkillBackupOwnership pins the backup ownership
+// contract: a stamp-shaped directory name alone proves nothing, so a backup
+// root this transaction writes into is either created fresh (mkdir claim,
+// marker stamped before any payload moves in) or an existing root whose
+// marker already verifies. A same-stamp foreign directory is never adopted —
+// writing the marker or the payload into it would turn its contents into
+// prunable DWS backups.
+func TestCrossPlatformCoverageSkillBackupOwnership(t *testing.T) {
+
+ t.Run("unproven stamp root is never adopted", func(t *testing.T) {
+ home := t.TempDir()
+ testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" })
+ foreign := filepath.Join(home, skillBackupSubdir, "20260101-120000")
+ if err := os.MkdirAll(foreign, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(foreign, "user-data.txt"), []byte("must survive"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ victim := filepath.Join(home, "skills", "dws")
+ seedUpgradeSkill(t, victim, "payload", false)
+ backup, err := backupAndRemoveSkillDir(home, victim)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := filepath.Join(home, skillBackupSubdir, "20260101-120000-1", "skills-dws"); backup != want {
+ t.Fatalf("backup = %s, want suffixed %s", backup, want)
+ }
+ assertUpgradeSkillContent(t, backup, "payload")
+ // The foreign root keeps its data and never receives our marker.
+ if data, readErr := os.ReadFile(filepath.Join(foreign, "user-data.txt")); readErr != nil || string(data) != "must survive" {
+ t.Fatalf("foreign data = %q, %v", data, readErr)
+ }
+ if _, statErr := os.Lstat(filepath.Join(foreign, skillBackupMarkerName)); !os.IsNotExist(statErr) {
+ t.Fatalf("foreign root must not receive the ownership marker, stat err=%v", statErr)
+ }
+ if !skillBackupMarkerValid(filepath.Dir(backup)) {
+ t.Fatal("the freshly claimed root must carry the ownership marker")
+ }
+ })
+
+ t.Run("same-stamp payloads reuse the proven root", func(t *testing.T) {
+ home := t.TempDir()
+ testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" })
+ first := filepath.Join(home, "skills", "dws")
+ second := filepath.Join(home, "agents", "claude", "dws")
+ seedUpgradeSkill(t, first, "first", false)
+ seedUpgradeSkill(t, second, "second", false)
+ backupA, err := backupAndRemoveSkillDir(home, first)
+ if err != nil {
+ t.Fatal(err)
+ }
+ backupB, err := backupAndRemoveSkillDir(home, second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if filepath.Dir(backupA) != filepath.Dir(backupB) {
+ t.Fatalf("same-stamp backups split roots: %s vs %s", backupA, backupB)
+ }
+ root := filepath.Dir(backupA)
+ if !skillBackupMarkerValid(root) {
+ t.Fatal("reused root must keep a verifying marker")
+ }
+ assertUpgradeSkillContent(t, backupA, "first")
+ assertUpgradeSkillContent(t, backupB, "second")
+ })
+
+ t.Run("marker write failure refuses the backup and cleans the empty root", func(t *testing.T) {
+ home := t.TempDir()
+ victim := filepath.Join(home, "skills", "dws")
+ seedUpgradeSkill(t, victim, "payload", false)
+ testseam.Swap(t, &skillBackupWriteMarker, func(string) error { return errors.New("marker denied") })
+ _, err := backupAndRemoveSkillDir(home, victim)
+ if err == nil || !strings.Contains(err.Error(), "备份所有权标记失败") {
+ t.Fatalf("marker write failure = %v", err)
+ }
+ assertUpgradeSkillContent(t, victim, "payload")
+ entries, readErr := os.ReadDir(filepath.Join(home, skillBackupSubdir))
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ if len(entries) != 0 {
+ t.Fatalf("empty unowned stamp root must be cleaned, left %v", entries)
+ }
+ })
+
+ t.Run("backup root creation failure surfaces", func(t *testing.T) {
+ home := t.TempDir()
+ victim := filepath.Join(home, "skills", "dws")
+ seedUpgradeSkill(t, victim, "payload", false)
+ testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission })
+ _, err := backupAndRemoveSkillDir(home, victim)
+ if !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("root creation failure = %v", err)
+ }
+ assertUpgradeSkillContent(t, victim, "payload")
+ })
+}
diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go
new file mode 100644
index 00000000..3f41e6c2
--- /dev/null
+++ b/internal/upgrade/skill_path_move.go
@@ -0,0 +1,363 @@
+package upgrade
+
+import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+)
+
+var (
+ skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) }
+ skillPathRenameNoReplace = renameSkillPathNoReplace
+ skillPathCopy = copySkillPathLexically
+ skillPathVerify = verifySkillPathCopy
+ skillPathRemoveAll = os.RemoveAll
+ skillPathMkdirAll = os.MkdirAll
+ skillPathMkdirTemp = os.MkdirTemp
+ skillPathChmod = os.Chmod
+ skillPathLstat = os.Lstat
+ skillPathMkdir = os.Mkdir
+ skillPathLink = os.Link
+ skillPathRemove = os.Remove
+ skillPathReadDir = os.ReadDir
+ skillPathFileIdentity = skillPathFileIdentityImpl
+ skillPathFileIncarnation = skillPathFileIncarnationImpl
+ skillPathSameFileIdentity = skillPathSameFileIdentityImpl
+ skillPathReadlink = os.Readlink
+ skillPathSymlink = os.Symlink
+ skillPathOpen = os.Open
+ skillPathOpenFile = os.OpenFile
+ skillPathCopyBytes = io.Copy
+ skillPathSync = func(file *os.File) error { return file.Sync() }
+ skillPathWalkDir = filepath.WalkDir
+)
+
+// moveSkillPathRecoverably moves one managed Skill path without weakening the
+// backup contract when source and destination are on different filesystems.
+// The cross-device path publishes a fully copied and verified sibling staging
+// path before removing the source. Any failure before source removal leaves the
+// source intact; a removal failure deliberately leaves both verified copies.
+func moveSkillPathRecoverably(src, dst string) (err error) {
+ if _, statErr := skillPathLstat(dst); statErr == nil {
+ return fmt.Errorf("目标已存在: %s", dst)
+ } else if !os.IsNotExist(statErr) {
+ return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr)
+ }
+ if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil {
+ return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err)
+ }
+ // moveSkillPathRecoverably is not a publication path; ignore any
+ // claim identity returned by the rename — no ownership check follows
+ // on this destination.
+ if _, renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil {
+ return nil
+ } else if !isCrossDeviceError(renameErr) {
+ return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr)
+ }
+
+ stageRoot, err := skillPathMkdirTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".cross-device-")
+ if err != nil {
+ return fmt.Errorf("创建跨设备 Skill staging 失败 %s: %w", dst, err)
+ }
+ stage := filepath.Join(stageRoot, "payload")
+ stageCleaned := false
+ defer func() {
+ if stageCleaned {
+ return
+ }
+ makeSkillPathTreeWritable(stageRoot)
+ if cleanupErr := skillPathRemoveAll(stageRoot); cleanupErr != nil {
+ err = errors.Join(err, fmt.Errorf("清理跨设备 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
+ }
+ }()
+
+ if err := skillPathCopy(src, stage); err != nil {
+ return fmt.Errorf("复制跨设备 Skill staging 失败 %s: %w", stage, err)
+ }
+ if err := skillPathVerify(src, stage); err != nil {
+ return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err)
+ }
+ stageInfo, err := skillPathLstat(stage)
+ if err != nil {
+ return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err)
+ }
+ if stageInfo.IsDir() {
+ // Darwin refuses to rename a read-only directory even when both parent
+ // directories are writable. The exact source mode was verified above;
+ // temporarily add owner access for publication, then restore it before
+ // the final verification and before the source is removed.
+ if err := skillPathChmod(stage, stageInfo.Mode().Perm()|0o700); err != nil {
+ return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err)
+ }
+ }
+ // Cross-device staging publishes via a self-owned staging directory,
+ // so the claim identity is not consulted; discard it.
+ if _, err := skillPathRenameNoReplace(stage, dst); err != nil {
+ return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err)
+ }
+ if stageInfo.IsDir() {
+ if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil {
+ return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err)
+ }
+ }
+ if err := skillPathVerify(src, dst); err != nil {
+ return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err)
+ }
+ if err := skillPathRemoveAll(stageRoot); err != nil {
+ return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err)
+ }
+ stageCleaned = true
+ if err := removePublishedSkillSource(src); err != nil {
+ return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, err)
+ }
+ return nil
+}
+
+type skillPathDirMode struct {
+ path string
+ mode os.FileMode
+}
+
+func removePublishedSkillSource(src string) error {
+ dirModes, err := prepareSkillPathTreeRemoval(src)
+ if err != nil {
+ return err
+ }
+ removeErr := skillPathRemoveAll(src)
+ if removeErr == nil {
+ if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) {
+ return nil
+ } else if statErr == nil {
+ removeErr = errors.New("源路径仍存在")
+ } else {
+ removeErr = fmt.Errorf("无法确认源路径已删除: %w", statErr)
+ }
+ }
+ return errors.Join(removeErr, restoreSkillPathDirModes(dirModes))
+}
+
+func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) {
+ var modes []skillPathDirMode
+ err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if !entry.IsDir() {
+ return nil
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ mode := info.Mode().Perm()
+ if mode&0o700 == 0o700 {
+ return nil
+ }
+ if err := skillPathChmod(path, mode|0o700); err != nil {
+ return err
+ }
+ modes = append(modes, skillPathDirMode{path: path, mode: mode})
+ return nil
+ })
+ if err != nil {
+ return nil, errors.Join(err, restoreSkillPathDirModes(modes))
+ }
+ return modes, nil
+}
+
+func restoreSkillPathDirModes(modes []skillPathDirMode) error {
+ var restoreErr error
+ for i := len(modes) - 1; i >= 0; i-- {
+ item := modes[i]
+ if _, err := skillPathLstat(item.path); os.IsNotExist(err) {
+ continue
+ } else if err != nil {
+ restoreErr = errors.Join(restoreErr, err)
+ continue
+ }
+ if err := skillPathChmod(item.path, item.mode); err != nil {
+ restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复源目录权限失败 %s: %w", item.path, err))
+ }
+ }
+ return restoreErr
+}
+
+func makeSkillPathTreeWritable(root string) {
+ // Best effort only: RemoveAll below remains the source of truth and its
+ // error is returned. This preparation lets it traverse read-only staging
+ // directories left by a copy or verification failure.
+ _ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
+ if walkErr == nil && entry.IsDir() {
+ _ = skillPathChmod(path, 0o700)
+ }
+ return nil
+ })
+}
+
+// RestoreSkillPath restores a previously recorded backup using the same
+// rename-first, verified cross-device fallback as backup publication.
+func RestoreSkillPath(backup, original string) error {
+ return moveSkillPathRecoverably(backup, original)
+}
+
+func copySkillPathLexically(src, dst string) error {
+ info, err := skillPathLstat(src)
+ if err != nil {
+ return err
+ }
+ switch {
+ case info.IsDir():
+ // Staging must remain writable while children are copied. Restore the
+ // source mode only after the directory is complete so read-only Skill
+ // directories (for example 0555) can still be migrated lexically.
+ if err := skillPathMkdir(dst, 0o700); err != nil {
+ return err
+ }
+ entries, err := skillPathReadDir(src)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ if err := copySkillPathLexically(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
+ return err
+ }
+ }
+ return os.Chmod(dst, info.Mode().Perm())
+ case info.Mode()&os.ModeSymlink != 0:
+ target, err := skillPathReadlink(src)
+ if err != nil {
+ return err
+ }
+ return skillPathSymlink(target, dst)
+ case info.Mode().IsRegular():
+ return copyRegularSkillFile(src, dst, info.Mode().Perm())
+ default:
+ return fmt.Errorf("不支持复制特殊 Skill 路径 %s(mode=%s)", src, info.Mode())
+ }
+}
+
+func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) {
+ in, err := skillPathOpen(src)
+ if err != nil {
+ return err
+ }
+ defer func() { err = errors.Join(err, in.Close()) }()
+ out, err := skillPathOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ return err
+ }
+ defer func() { err = errors.Join(err, out.Close()) }()
+ if _, err := skillPathCopyBytes(out, in); err != nil {
+ return err
+ }
+ if err := skillPathSync(out); err != nil {
+ return err
+ }
+ return os.Chmod(dst, mode)
+}
+
+func verifySkillPathCopy(src, dst string) error {
+ srcInfo, err := skillPathLstat(src)
+ if err != nil {
+ return err
+ }
+ dstInfo, err := skillPathLstat(dst)
+ if err != nil {
+ return err
+ }
+ if srcInfo.Mode()&os.ModeType != dstInfo.Mode()&os.ModeType {
+ return fmt.Errorf("路径类型不一致: %s (%s) != %s (%s)", src, srcInfo.Mode(), dst, dstInfo.Mode())
+ }
+ switch {
+ case srcInfo.IsDir():
+ if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
+ return fmt.Errorf("目录权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm())
+ }
+ srcNames, err := skillPathEntryNames(src)
+ if err != nil {
+ return err
+ }
+ dstNames, err := skillPathEntryNames(dst)
+ if err != nil {
+ return err
+ }
+ if len(srcNames) != len(dstNames) {
+ return fmt.Errorf("目录项数量不一致: %s (%d) != %s (%d)", src, len(srcNames), dst, len(dstNames))
+ }
+ for i, name := range srcNames {
+ if name != dstNames[i] {
+ return fmt.Errorf("目录项不一致: %s != %s", name, dstNames[i])
+ }
+ if err := verifySkillPathCopy(filepath.Join(src, name), filepath.Join(dst, name)); err != nil {
+ return err
+ }
+ }
+ return nil
+ case srcInfo.Mode()&os.ModeSymlink != 0:
+ srcTarget, err := skillPathReadlink(src)
+ if err != nil {
+ return err
+ }
+ dstTarget, err := skillPathReadlink(dst)
+ if err != nil {
+ return err
+ }
+ if srcTarget != dstTarget {
+ return fmt.Errorf("符号链接目标不一致: %q != %q", srcTarget, dstTarget)
+ }
+ return nil
+ case srcInfo.Mode().IsRegular():
+ if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
+ return fmt.Errorf("文件权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm())
+ }
+ if srcInfo.Size() != dstInfo.Size() {
+ return fmt.Errorf("文件大小不一致: %s (%d) != %s (%d)", src, srcInfo.Size(), dst, dstInfo.Size())
+ }
+ srcDigest, err := skillPathFileDigest(src)
+ if err != nil {
+ return err
+ }
+ dstDigest, err := skillPathFileDigest(dst)
+ if err != nil {
+ return err
+ }
+ if srcDigest != dstDigest {
+ return fmt.Errorf("文件内容摘要不一致: %s != %s", src, dst)
+ }
+ return nil
+ default:
+ return fmt.Errorf("不支持校验特殊 Skill 路径 %s(mode=%s)", src, srcInfo.Mode())
+ }
+}
+
+func skillPathEntryNames(dir string) ([]string, error) {
+ entries, err := skillPathReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ names := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ names = append(names, entry.Name())
+ }
+ sort.Strings(names)
+ return names, nil
+}
+
+func skillPathFileDigest(path string) ([sha256.Size]byte, error) {
+ f, err := skillPathOpen(path)
+ if err != nil {
+ return [sha256.Size]byte{}, err
+ }
+ defer f.Close()
+ hash := sha256.New()
+ if _, err := skillPathCopyBytes(hash, f); err != nil {
+ return [sha256.Size]byte{}, err
+ }
+ var digest [sha256.Size]byte
+ copy(digest[:], hash.Sum(nil))
+ return digest, nil
+}
diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go
new file mode 100644
index 00000000..b9d00850
--- /dev/null
+++ b/internal/upgrade/skill_path_move_errors_test.go
@@ -0,0 +1,529 @@
+package upgrade
+
+import (
+ "errors"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
+)
+
+func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) {
+ t.Run("destination probe", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ original := skillPathLstat
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == dst {
+ return nil, os.ErrPermission
+ }
+ return original(path)
+ })
+ if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("destination parent", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ testseam.Swap(t, &skillPathMkdirAll, func(string, os.FileMode) error { return os.ErrPermission })
+ if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("staging creation", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ forceCrossDeviceRename(t, src, dst)
+ testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", os.ErrPermission })
+ if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("failed staging cleanup", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ forceCrossDeviceRename(t, src, dst)
+ testseam.Swap(t, &skillPathCopy, func(string, string) error { return errors.New("copy failed") })
+ testseam.Swap(t, &skillPathRemoveAll, func(string) error { return errors.New("cleanup failed") })
+ err := moveSkillPathRecoverably(src, dst)
+ if err == nil || !strings.Contains(err.Error(), "copy failed") || !strings.Contains(err.Error(), "cleanup failed") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("missing copied staging", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ forceCrossDeviceRename(t, src, dst)
+ testseam.Swap(t, &skillPathCopy, func(string, string) error { return nil })
+ testseam.Swap(t, &skillPathVerify, func(string, string) error { return nil })
+ if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "检查跨设备") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ for _, tc := range []struct {
+ name string
+ path func(src, dst string) string
+ want string
+ }{
+ {name: "prepare staging mode", path: func(_, dst string) string { return filepath.Join(filepath.Dir(dst), "payload") }, want: "准备跨设备"},
+ {name: "restore published mode", path: func(_, dst string) string { return dst }, want: "恢复已发布"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ forceCrossDeviceRename(t, src, dst)
+ original := skillPathChmod
+ testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error {
+ if tc.name == "prepare staging mode" && strings.HasSuffix(path, string(filepath.Separator)+"payload") {
+ return errors.New("chmod failed")
+ }
+ if tc.name == "restore published mode" && path == dst {
+ return errors.New("chmod failed")
+ }
+ return original(path, mode)
+ })
+ if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+
+ t.Run("published staging cleanup", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ forceCrossDeviceRename(t, src, dst)
+ original := skillPathRemoveAll
+ testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
+ if strings.Contains(filepath.Base(path), ".cross-device-") {
+ return errors.New("cleanup failed")
+ }
+ return original(path)
+ })
+ if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("source deletion cannot be confirmed", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ forceCrossDeviceRename(t, src, dst)
+ removalAttempted := false
+ originalLstat := skillPathLstat
+ originalRemove := skillPathRemoveAll
+ testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
+ if path == src {
+ removalAttempted = true
+ return nil
+ }
+ return originalRemove(path)
+ })
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == src && removalAttempted {
+ return nil, os.ErrPermission
+ }
+ return originalLstat(path)
+ })
+ if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) {
+ t.Run("source preparation error", func(t *testing.T) {
+ testseam.Swap(t, &skillPathWalkDir, func(string, fs.WalkDirFunc) error {
+ return errors.New("prepare failed")
+ })
+ if err := removePublishedSkillSource("ignored"); err == nil || !strings.Contains(err.Error(), "prepare failed") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("walk error", func(t *testing.T) {
+ testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
+ return fn(root, nil, errors.New("walk failed"))
+ })
+ if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "walk failed") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("writable mode preparation", func(t *testing.T) {
+ entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500}
+ testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
+ return fn(root, entry, nil)
+ })
+ var chmodMode os.FileMode
+ testseam.Swap(t, &skillPathChmod, func(_ string, mode os.FileMode) error {
+ chmodMode = mode
+ return nil
+ })
+ modes, err := prepareSkillPathTreeRemoval("source")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if chmodMode != 0o700 || len(modes) != 1 || modes[0].path != "source" || modes[0].mode != 0o500 {
+ t.Fatalf("prepared mode = %v, records = %#v", chmodMode, modes)
+ }
+ })
+
+ t.Run("writable mode preparation chmod error", func(t *testing.T) {
+ entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500}
+ testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
+ return fn(root, entry, nil)
+ })
+ testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error {
+ return errors.New("chmod failed")
+ })
+ if _, err := prepareSkillPathTreeRemoval("source"); err == nil || !strings.Contains(err.Error(), "chmod failed") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("entry info error", func(t *testing.T) {
+ testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error {
+ return fn(root, skillPathErrorDirEntry{}, nil)
+ })
+ if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "info failed") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("missing restored directory", func(t *testing.T) {
+ if err := restoreSkillPathDirModes([]skillPathDirMode{{path: filepath.Join(t.TempDir(), "missing"), mode: 0o555}}); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("restore probe error", func(t *testing.T) {
+ testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
+ if err := restoreSkillPathDirModes([]skillPathDirMode{{path: "blocked", mode: 0o555}}); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("restore chmod error", func(t *testing.T) {
+ path := t.TempDir()
+ testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission })
+ if err := restoreSkillPathDirModes([]skillPathDirMode{{path: path, mode: 0o555}}); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageSkillPathLexicalCopyErrors(t *testing.T) {
+ t.Run("source lstat", func(t *testing.T) {
+ if err := copySkillPathLexically(filepath.Join(t.TempDir(), "missing"), "unused"); !os.IsNotExist(err) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("destination mkdir", func(t *testing.T) {
+ root := t.TempDir()
+ src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
+ if err := os.Mkdir(src, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := copySkillPathLexically(src, dst); err == nil {
+ t.Fatal("expected mkdir failure")
+ }
+ })
+
+ t.Run("source readdir", func(t *testing.T) {
+ root := t.TempDir()
+ src := filepath.Join(root, "src")
+ if err := os.Mkdir(src, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission })
+ if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("nested copy", func(t *testing.T) {
+ root := t.TempDir()
+ src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
+ if err := os.Mkdir(src, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ original := skillPathLstat
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if filepath.Base(path) == "child" {
+ return nil, os.ErrPermission
+ }
+ return original(path)
+ })
+ if err := copySkillPathLexically(src, dst); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("readlink", func(t *testing.T) {
+ root := t.TempDir()
+ src := filepath.Join(root, "link")
+ if err := os.Symlink("target", src); err != nil {
+ t.Skipf("symlink unavailable: %v", err)
+ }
+ testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission })
+ if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("special path", func(t *testing.T) {
+ testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
+ return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
+ })
+ if err := copySkillPathLexically("special", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("regular open", func(t *testing.T) {
+ if err := copyRegularSkillFile(filepath.Join(t.TempDir(), "missing"), "dst", 0o600); !os.IsNotExist(err) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("regular destination open", func(t *testing.T) {
+ root := t.TempDir()
+ src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
+ if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := copyRegularSkillFile(src, dst, 0o600); err == nil {
+ t.Fatal("expected exclusive create failure")
+ }
+ })
+
+ for _, tc := range []struct {
+ name string
+ swap func(*testing.T)
+ }{
+ {name: "copy", swap: func(t *testing.T) {
+ testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy failed") })
+ }},
+ {name: "sync", swap: func(t *testing.T) {
+ testseam.Swap(t, &skillPathSync, func(*os.File) error { return errors.New("sync failed") })
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ root := t.TempDir()
+ src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
+ if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ tc.swap(t)
+ if err := copyRegularSkillFile(src, dst, 0o600); err == nil || !strings.Contains(err.Error(), tc.name) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) {
+ root := t.TempDir()
+
+ t.Run("source and destination lstat", func(t *testing.T) {
+ if err := verifySkillPathCopy(filepath.Join(root, "missing-src"), "dst"); !os.IsNotExist(err) {
+ t.Fatalf("source error = %v", err)
+ }
+ src := filepath.Join(root, "source")
+ if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifySkillPathCopy(src, filepath.Join(root, "missing-dst")); !os.IsNotExist(err) {
+ t.Fatalf("destination error = %v", err)
+ }
+ })
+
+ t.Run("directory and file modes", func(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ src os.FileMode
+ dst os.FileMode
+ }{
+ {name: "directory", src: os.ModeDir | 0o700, dst: os.ModeDir | 0o755},
+ {name: "file", src: 0o600, dst: 0o644},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ src, dst := "src", "dst"
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == src {
+ return skillPathFakeInfo{mode: tc.src}, nil
+ }
+ return skillPathFakeInfo{mode: tc.dst}, nil
+ })
+ if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+ })
+
+ t.Run("directory read errors", func(t *testing.T) {
+ for _, failDest := range []bool{false, true} {
+ t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
+ base := t.TempDir()
+ src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
+ if err := os.Mkdir(src, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(dst, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ original := skillPathReadDir
+ testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
+ if (path == dst) == failDest {
+ return nil, os.ErrPermission
+ }
+ return original(path)
+ })
+ if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+ })
+
+ t.Run("nested mismatch", func(t *testing.T) {
+ base := t.TempDir()
+ src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
+ if err := os.Mkdir(src, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(dst, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(filepath.Join(dst, "child"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("symlink reads", func(t *testing.T) {
+ base := t.TempDir()
+ src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
+ if err := os.Symlink("one", src); err != nil {
+ t.Skipf("symlink unavailable: %v", err)
+ }
+ if err := os.Symlink("one", dst); err != nil {
+ t.Fatal(err)
+ }
+ for _, failDest := range []bool{false, true} {
+ t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
+ original := skillPathReadlink
+ testseam.Swap(t, &skillPathReadlink, func(path string) (string, error) {
+ if (path == dst) == failDest {
+ return "", os.ErrPermission
+ }
+ return original(path)
+ })
+ if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+ })
+
+ t.Run("file digest opens", func(t *testing.T) {
+ base := t.TempDir()
+ src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst")
+ if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(dst, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ for _, failDest := range []bool{false, true} {
+ t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) {
+ original := skillPathOpen
+ testseam.Swap(t, &skillPathOpen, func(path string) (*os.File, error) {
+ if (path == dst) == failDest {
+ return nil, os.ErrPermission
+ }
+ return original(path)
+ })
+ if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+ })
+
+ t.Run("special path", func(t *testing.T) {
+ fake := skillPathFakeInfo{mode: os.ModeNamedPipe}
+ testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return fake, nil })
+ if err := verifySkillPathCopy("src", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("entry names", func(t *testing.T) {
+ if _, err := skillPathEntryNames(filepath.Join(root, "missing-dir")); !os.IsNotExist(err) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+
+ t.Run("digest open and read", func(t *testing.T) {
+ if _, err := skillPathFileDigest(filepath.Join(root, "missing-file")); !os.IsNotExist(err) {
+ t.Fatalf("open error = %v", err)
+ }
+ file := filepath.Join(root, "digest-file")
+ if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("read failed") })
+ if _, err := skillPathFileDigest(file); err == nil || !strings.Contains(err.Error(), "read failed") {
+ t.Fatalf("read error = %v", err)
+ }
+ })
+}
+
+type skillPathFakeInfo struct {
+ mode os.FileMode
+}
+
+func (skillPathFakeInfo) Name() string { return "fake" }
+func (skillPathFakeInfo) Size() int64 { return 0 }
+func (info skillPathFakeInfo) Mode() os.FileMode { return info.mode }
+func (skillPathFakeInfo) ModTime() time.Time { return time.Time{} }
+func (info skillPathFakeInfo) IsDir() bool { return info.mode.IsDir() }
+func (skillPathFakeInfo) Sys() any { return nil }
+
+type skillPathErrorDirEntry struct{}
+
+func (skillPathErrorDirEntry) Name() string { return "broken" }
+func (skillPathErrorDirEntry) IsDir() bool { return true }
+func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir }
+func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") }
+
+type skillPathModeDirEntry struct {
+ mode os.FileMode
+}
+
+func (skillPathModeDirEntry) Name() string { return "directory" }
+func (skillPathModeDirEntry) IsDir() bool { return true }
+func (entry skillPathModeDirEntry) Type() os.FileMode { return entry.mode.Type() }
+func (entry skillPathModeDirEntry) Info() (os.FileInfo, error) {
+ return skillPathFakeInfo{mode: entry.mode}, nil
+}
diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go
new file mode 100644
index 00000000..1963a257
--- /dev/null
+++ b/internal/upgrade/skill_path_move_test.go
@@ -0,0 +1,451 @@
+package upgrade
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
+)
+
+func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) {
+ t.Run("directory file and lexical links", func(t *testing.T) {
+ root := t.TempDir()
+ t.Cleanup(func() { makeSkillPathTreeWritable(root) })
+ src := filepath.Join(root, "external", "dingtalk-chat")
+ dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
+ if err := os.MkdirAll(filepath.Join(src, "references"), 0o750); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "references", "guide.md"), []byte("guide\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ linksCreated := true
+ if err := os.Symlink("SKILL.md", filepath.Join(src, "skill-link")); err != nil {
+ if runtime.GOOS != "windows" {
+ t.Fatal(err)
+ }
+ linksCreated = false
+ }
+ if linksCreated {
+ if err := os.Symlink("missing.md", filepath.Join(src, "dangling-link")); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ forceCrossDeviceRename(t, src, dst)
+ if err := moveSkillPathRecoverably(src, dst); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(src); !os.IsNotExist(err) {
+ t.Fatalf("source must be removed: %v", err)
+ }
+ if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" {
+ t.Fatalf("copied file = %q, %v", got, err)
+ }
+ if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil {
+ t.Fatalf("copied file stat = %v, %v", info, err)
+ } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 {
+ t.Fatalf("copied file mode = %v; want 0640", info.Mode().Perm())
+ }
+ if linksCreated {
+ for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} {
+ info, err := os.Lstat(filepath.Join(dst, name))
+ if err != nil || info.Mode()&os.ModeSymlink == 0 {
+ t.Fatalf("%s must remain a symlink: %v, %v", name, info, err)
+ }
+ if got, err := os.Readlink(filepath.Join(dst, name)); err != nil || got != want {
+ t.Fatalf("%s target = %q, %v; want %q", name, got, err, want)
+ }
+ }
+ }
+ assertNoCrossDeviceStage(t, dst)
+ })
+
+ t.Run("regular file root", func(t *testing.T) {
+ root := t.TempDir()
+ src := filepath.Join(root, "source-file")
+ dst := filepath.Join(root, "backup", "source-file")
+ if err := os.WriteFile(src, []byte("state"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ forceCrossDeviceRename(t, src, dst)
+ if err := moveSkillPathRecoverably(src, dst); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := os.ReadFile(dst); err != nil || string(got) != "state" {
+ t.Fatalf("backup file = %q, %v", got, err)
+ }
+ })
+
+ t.Run("read-only directory mode is restored after copying children", func(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("POSIX directory permission semantics are unavailable")
+ }
+ root := t.TempDir()
+ t.Cleanup(func() { makeSkillPathTreeWritable(root) })
+ src := filepath.Join(root, "external", "dingtalk-chat")
+ dst := filepath.Join(root, "backup", "dingtalk-chat")
+ if err := os.MkdirAll(src, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("read only\n"), 0o444); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(src, 0o555); err != nil {
+ t.Fatal(err)
+ }
+ forceCrossDeviceRename(t, src, dst)
+ if err := moveSkillPathRecoverably(src, dst); err != nil {
+ t.Fatal(err)
+ }
+ if info, err := os.Stat(dst); err != nil || info.Mode().Perm() != 0o555 {
+ t.Fatalf("copied directory mode = %v, %v", info, err)
+ }
+ if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "read only\n" {
+ t.Fatalf("copied read-only file = %q, %v", got, err)
+ }
+ })
+
+ t.Run("rollback uses the same cross-device contract", func(t *testing.T) {
+ root := t.TempDir()
+ backup := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
+ original := filepath.Join(root, "external", "claude", "skills", "dingtalk-chat")
+ if err := os.MkdirAll(backup, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(backup, "SKILL.md"), []byte("old"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ forceCrossDeviceRename(t, backup, original)
+ if err := RestoreSkillPath(backup, original); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := os.ReadFile(filepath.Join(original, "SKILL.md")); err != nil || string(got) != "old" {
+ t.Fatalf("restored content = %q, %v", got, err)
+ }
+ if _, err := os.Lstat(backup); !os.IsNotExist(err) {
+ t.Fatalf("backup must be consumed after verified restore: %v", err)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) {
+ t.Run("non cross-device rename never copies", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ copied := false
+ testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", os.ErrPermission })
+ testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil })
+ if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("error = %v", err)
+ }
+ if copied {
+ t.Fatal("permission failure must not enter cross-device copy fallback")
+ }
+ assertSkillSourcePreserved(t, src)
+ })
+
+ for _, tc := range []struct {
+ name string
+ copy func(string, string) error
+ verify func(string, string) error
+ }{
+ {name: "copy failure", copy: func(string, string) error { return errors.New("copy failed") }},
+ {name: "verification failure", verify: func(string, string) error { return errors.New("verify failed") }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ forceCrossDeviceRename(t, src, dst)
+ if tc.copy != nil {
+ testseam.Swap(t, &skillPathCopy, tc.copy)
+ }
+ if tc.verify != nil {
+ testseam.Swap(t, &skillPathVerify, tc.verify)
+ }
+ if err := moveSkillPathRecoverably(src, dst); err == nil {
+ t.Fatal("expected failure")
+ }
+ assertSkillSourcePreserved(t, src)
+ if _, err := os.Lstat(dst); !os.IsNotExist(err) {
+ t.Fatalf("formal backup must not publish: %v", err)
+ }
+ assertNoCrossDeviceStage(t, dst)
+ })
+ }
+
+ t.Run("staging publish failure", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ originalRename := skillPathRenameNoReplace
+ testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) {
+ if from == src && to == dst {
+ return "", testCrossDeviceError()
+ }
+ if to == dst {
+ return "", errors.New("publish failed")
+ }
+ return originalRename(from, to)
+ })
+ if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "publish failed") {
+ t.Fatalf("error = %v", err)
+ }
+ assertSkillSourcePreserved(t, src)
+ assertNoCrossDeviceStage(t, dst)
+ })
+
+ t.Run("published verification failure preserves both", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ forceCrossDeviceRename(t, src, dst)
+ calls := 0
+ testseam.Swap(t, &skillPathVerify, func(string, string) error {
+ calls++
+ if calls == 2 {
+ return errors.New("published verify failed")
+ }
+ return nil
+ })
+ if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "published verify failed") {
+ t.Fatalf("error = %v", err)
+ }
+ assertSkillSourcePreserved(t, src)
+ if _, err := os.Lstat(dst); err != nil {
+ t.Fatalf("verified backup candidate must remain: %v", err)
+ }
+ assertNoCrossDeviceStage(t, dst)
+ })
+
+ t.Run("source removal failure preserves both", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ forceCrossDeviceRename(t, src, dst)
+ originalRemove := skillPathRemoveAll
+ testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
+ if path == src {
+ return errors.New("remove failed")
+ }
+ return originalRemove(path)
+ })
+ if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") {
+ t.Fatalf("error = %v", err)
+ }
+ assertSkillSourcePreserved(t, src)
+ if _, err := os.Lstat(dst); err != nil {
+ t.Fatalf("formal backup must remain: %v", err)
+ }
+ })
+
+ t.Run("read-only source mode is restored after removal failure", func(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("POSIX directory permission semantics are unavailable")
+ }
+ src, dst := makeSkillMoveFixture(t)
+ if err := os.Chmod(src, 0o555); err != nil {
+ t.Fatal(err)
+ }
+ forceCrossDeviceRename(t, src, dst)
+ originalRemove := skillPathRemoveAll
+ testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
+ if path == src {
+ return errors.New("remove failed")
+ }
+ return originalRemove(path)
+ })
+ if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") {
+ t.Fatalf("error = %v", err)
+ }
+ if info, err := os.Stat(src); err != nil || info.Mode().Perm() != 0o555 {
+ t.Fatalf("source directory mode after failed removal = %v, %v", info, err)
+ }
+ if err := os.Chmod(src, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("source permission preparation failure preserves both", func(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("POSIX directory permission semantics are unavailable")
+ }
+ src, dst := makeSkillMoveFixture(t)
+ if err := os.Chmod(src, 0o555); err != nil {
+ t.Fatal(err)
+ }
+ forceCrossDeviceRename(t, src, dst)
+ originalChmod := skillPathChmod
+ testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error {
+ if path == src {
+ return errors.New("chmod failed")
+ }
+ return originalChmod(path, mode)
+ })
+ if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "chmod failed") {
+ t.Fatalf("error = %v", err)
+ }
+ if err := originalChmod(src, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("lying remover is detected", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ forceCrossDeviceRename(t, src, dst)
+ originalRemove := skillPathRemoveAll
+ testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
+ if path == src {
+ return nil
+ }
+ return originalRemove(path)
+ })
+ if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "仍存在") {
+ t.Fatalf("error = %v", err)
+ }
+ assertSkillSourcePreserved(t, src)
+ })
+
+ t.Run("same-filesystem fast path", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ if err := moveSkillPathRecoverably(src, dst); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(src); !os.IsNotExist(err) {
+ t.Fatalf("source still exists: %v", err)
+ }
+ })
+
+ t.Run("existing destination is never overwritten", func(t *testing.T) {
+ src, dst := makeSkillMoveFixture(t)
+ if err := os.MkdirAll(dst, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "目标已存在") {
+ t.Fatalf("error = %v", err)
+ }
+ assertSkillSourcePreserved(t, src)
+ })
+}
+
+func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) {
+ t.Run("type", func(t *testing.T) {
+ root := t.TempDir()
+ src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
+ if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(dst, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ t.Run("directory entries", func(t *testing.T) {
+ root := t.TempDir()
+ src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
+ if err := os.Mkdir(src, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(dst, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "one"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "数量") {
+ t.Fatalf("error = %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dst, "two"), []byte("x"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "目录项") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ t.Run("file size and digest", func(t *testing.T) {
+ root := t.TempDir()
+ src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
+ if err := os.WriteFile(src, []byte("same"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(dst, []byte("longer"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "大小") {
+ t.Fatalf("error = %v", err)
+ }
+ if err := os.WriteFile(dst, []byte("diff"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "摘要") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ t.Run("symlink target", func(t *testing.T) {
+ root := t.TempDir()
+ src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst")
+ if err := os.Symlink("one", src); err != nil {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink creation unavailable")
+ }
+ t.Fatal(err)
+ }
+ if err := os.Symlink("two", dst); err != nil {
+ t.Fatal(err)
+ }
+ if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "链接目标") {
+ t.Fatalf("error = %v", err)
+ }
+ })
+}
+
+func forceCrossDeviceRename(t *testing.T, src, dst string) {
+ t.Helper()
+ originalRename := skillPathRenameNoReplace
+ testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) {
+ if from == src && to == dst {
+ return "", testCrossDeviceError()
+ }
+ return originalRename(from, to)
+ })
+}
+
+func makeSkillMoveFixture(t *testing.T) (string, string) {
+ t.Helper()
+ root := t.TempDir()
+ t.Cleanup(func() { makeSkillPathTreeWritable(root) })
+ src := filepath.Join(root, "external", "dingtalk-chat")
+ dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat")
+ if err := os.MkdirAll(src, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("old"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return src, dst
+}
+
+func assertSkillSourcePreserved(t *testing.T, src string) {
+ t.Helper()
+ if got, err := os.ReadFile(filepath.Join(src, "SKILL.md")); err != nil || string(got) != "old" {
+ t.Fatalf("source content = %q, %v", got, err)
+ }
+}
+
+func assertNoCrossDeviceStage(t *testing.T, dst string) {
+ t.Helper()
+ entries, err := os.ReadDir(filepath.Dir(dst))
+ if err != nil {
+ if os.IsNotExist(err) {
+ return
+ }
+ t.Fatal(err)
+ }
+ prefix := "." + filepath.Base(dst) + ".cross-device-"
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), prefix) {
+ t.Fatalf("cross-device staging leaked: %s", entry.Name())
+ }
+ }
+}
diff --git a/internal/upgrade/skill_path_move_unix.go b/internal/upgrade/skill_path_move_unix.go
new file mode 100644
index 00000000..587c8217
--- /dev/null
+++ b/internal/upgrade/skill_path_move_unix.go
@@ -0,0 +1,12 @@
+//go:build !windows
+
+package upgrade
+
+import (
+ "errors"
+ "syscall"
+)
+
+func isCrossDeviceError(err error) bool {
+ return errors.Is(err, syscall.EXDEV)
+}
diff --git a/internal/upgrade/skill_path_move_unix_test.go b/internal/upgrade/skill_path_move_unix_test.go
new file mode 100644
index 00000000..288847ae
--- /dev/null
+++ b/internal/upgrade/skill_path_move_unix_test.go
@@ -0,0 +1,16 @@
+//go:build !windows
+
+package upgrade
+
+import (
+ "os"
+ "syscall"
+)
+
+func testCrossDeviceError() error {
+ return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV}
+}
+
+func testNoReplaceUnsupportedErrors() []error {
+ return []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported}
+}
diff --git a/internal/upgrade/skill_path_move_windows.go b/internal/upgrade/skill_path_move_windows.go
new file mode 100644
index 00000000..f43fe688
--- /dev/null
+++ b/internal/upgrade/skill_path_move_windows.go
@@ -0,0 +1,13 @@
+//go:build windows
+
+package upgrade
+
+import (
+ "errors"
+
+ "golang.org/x/sys/windows"
+)
+
+func isCrossDeviceError(err error) bool {
+ return errors.Is(err, windows.ERROR_NOT_SAME_DEVICE)
+}
diff --git a/internal/upgrade/skill_path_move_windows_test.go b/internal/upgrade/skill_path_move_windows_test.go
new file mode 100644
index 00000000..2da189d8
--- /dev/null
+++ b/internal/upgrade/skill_path_move_windows_test.go
@@ -0,0 +1,24 @@
+//go:build windows
+
+package upgrade
+
+import (
+ "os"
+ "testing"
+
+ "golang.org/x/sys/windows"
+)
+
+func testCrossDeviceError() error {
+ return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE}
+}
+
+func testNoReplaceUnsupportedErrors() []error {
+ return []error{errNoReplaceRenameUnsupported}
+}
+
+func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) {
+ if !isCrossDeviceError(testCrossDeviceError()) {
+ t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback")
+ }
+}
diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go
new file mode 100644
index 00000000..59a3af89
--- /dev/null
+++ b/internal/upgrade/skill_publication.go
@@ -0,0 +1,241 @@
+package upgrade
+
+import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "hash"
+ "os"
+ "path/filepath"
+ "sort"
+)
+
+// ErrSkillPathPublicationUncertain marks a publication whose transactional
+// outcome cannot be determined safely: this transaction did occupy the
+// destination at some point, but a stronger witness — the fresh mkdir-claim
+// identity captured by the child-move fallback — now indicates that the
+// object currently at the destination may belong to a concurrent writer.
+// Callers must not automatically retract, replace, or retry-copy over the
+// destination; instead they should surface the state so the user or a
+// higher layer can inspect it. Errors wrapping this sentinel are safe to
+// test with errors.Is.
+var ErrSkillPathPublicationUncertain = errors.New("Skill 发布状态不确定")
+
+// SkillPathPublication records enough immutable identity to prove that a
+// destination still belongs to the transaction that published it. The
+// fingerprint is intentionally private so callers cannot forge records.
+type SkillPathPublication struct {
+ Destination string
+ fingerprint [sha256.Size]byte
+ identity os.FileInfo
+ incarnation string
+ fileID string
+}
+
+// PublishSkillPathNoReplace atomically publishes a staged path without ever
+// replacing a destination created after the backup phase.
+//
+// The rename returns a mkdir-claim identity when (and only when) the
+// child-move fallback published dest. That identity is the tamperproof
+// witness that dest is still the mkdir claim this transaction created
+// (unlike the staged shell, whose presence alone is compatible with a
+// wholesale replacement of dest by a concurrent writer). When the claim
+// identity no longer matches dest, publication reports uncertain state
+// (ErrSkillPathPublicationUncertain) and keeps dest — auto-retracting or
+// overwriting would delete the concurrent writer's data.
+func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication, error) {
+ identity, err := skillPathLstat(staged)
+ if err != nil {
+ return SkillPathPublication{}, fmt.Errorf("读取待发布 Skill 身份失败 %s: %w", staged, err)
+ }
+ fingerprint, err := fingerprintSkillPath(staged)
+ if err != nil {
+ return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err)
+ }
+ stagedFileID := skillPathFileIdentity(staged)
+ claimIdentity, err := skillPathRenameNoReplace(staged, destination)
+ if err != nil {
+ return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err)
+ }
+ // If the child-move fallback published dest, the claim identity must
+ // still describe the object at destination. A wholesale replacement of
+ // dest between the mkdir claim and this check produces a different
+ // identity — dest is a foreign object and must not be treated as this
+ // transaction's publication. An unreadable live identity (dest already
+ // vanished, or the platform cannot report one) defers to the standard
+ // confirmation checks below instead of guessing.
+ if claimIdentity != "" {
+ if liveIdentity := skillPathFileIdentity(destination); liveIdentity != "" && liveIdentity != claimIdentity {
+ return SkillPathPublication{}, fmt.Errorf("%w:child-move 发布后目标 %s 已被替换;目标保留", ErrSkillPathPublicationUncertain, destination)
+ }
+ }
+ publishedIdentity, err := skillPathLstat(destination)
+ if err != nil {
+ return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err)
+ }
+ publishedFingerprint, err := fingerprintSkillPath(destination)
+ if err != nil {
+ return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err)
+ }
+ publishedFileID := skillPathFileIdentity(destination)
+ // When claim identity is available (child-move path), it is the
+ // authoritative witness of "dest is our object"; the staged inode
+ // necessarily differs from the mkdir-claim inode. Only enforce the
+ // staged-inode identity proof when there is no claim identity — that
+ // is, when the atomic primitive or the fast-path rename consumed the
+ // staged inode into dest.
+ if claimIdentity == "" && !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) {
+ return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination)
+ }
+ if publishedFingerprint != fingerprint {
+ return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): staging 内容已变化", destination)
+ }
+ return SkillPathPublication{
+ Destination: destination,
+ fingerprint: fingerprint,
+ identity: publishedIdentity,
+ incarnation: skillPathFileIncarnation(publishedIdentity),
+ fileID: publishedFileID,
+ }, nil
+}
+
+// RollbackSkillPathPublications removes only objects that can still be proven
+// to have been published by this transaction. Each live path is first claimed
+// into a private sibling quarantine. A concurrent replacement is restored when
+// possible, otherwise retained in quarantine and reported explicitly.
+func RollbackSkillPathPublications(publications []SkillPathPublication) error {
+ var rollbackErr error
+ for i := len(publications) - 1; i >= 0; i-- {
+ if err := rollbackSkillPathPublication(publications[i]); err != nil {
+ rollbackErr = errors.Join(rollbackErr, err)
+ }
+ }
+ return rollbackErr
+}
+
+func rollbackSkillPathPublication(publication SkillPathPublication) (err error) {
+ destination := publication.Destination
+ quarantineRoot, err := skillPathMkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".rollback-")
+ if err != nil {
+ return fmt.Errorf("创建 Skill 回滚隔离目录失败 %s: %w", destination, err)
+ }
+ quarantine := filepath.Join(quarantineRoot, "payload")
+ cleanupRoot := func() error {
+ if cleanupErr := skillPathRemoveAll(quarantineRoot); cleanupErr != nil {
+ return fmt.Errorf("清理 Skill 回滚隔离目录失败 %s: %w", quarantineRoot, cleanupErr)
+ }
+ return nil
+ }
+
+ liveIdentity, liveIdentityErr := skillPathLstat(destination)
+ if os.IsNotExist(liveIdentityErr) {
+ return cleanupRoot()
+ }
+ liveFingerprint, liveFingerprintErr := fingerprintSkillPath(destination)
+ liveVerificationErr := errors.Join(liveIdentityErr, liveFingerprintErr)
+ if liveVerificationErr != nil {
+ return errors.Join(
+ fmt.Errorf("拒绝删除无法验证的 Skill %s: %w", destination, liveVerificationErr),
+ cleanupRoot(),
+ )
+ }
+ if publication.identity == nil ||
+ !skillPathIdentityProven(publication.identity, liveIdentity, publication.fileID, skillPathFileIdentity(destination)) ||
+ publication.incarnation != skillPathFileIncarnation(liveIdentity) ||
+ liveFingerprint != publication.fingerprint {
+ return errors.Join(
+ fmt.Errorf("拒绝删除非本事务 Skill %s: 发布对象身份已变化", destination),
+ cleanupRoot(),
+ )
+ }
+
+ if err := skillPathRename(destination, quarantine); err != nil {
+ cleanupErr := cleanupRoot()
+ if os.IsNotExist(err) {
+ return cleanupErr
+ }
+ return errors.Join(fmt.Errorf("隔离待回滚 Skill 失败 %s: %w", destination, err), cleanupErr)
+ }
+
+ actualIdentity, identityErr := skillPathLstat(quarantine)
+ actual, fingerprintErr := fingerprintSkillPath(quarantine)
+ if identityErr == nil && fingerprintErr == nil &&
+ skillPathIdentityProven(publication.identity, actualIdentity, publication.fileID, skillPathFileIdentity(quarantine)) &&
+ actual == publication.fingerprint {
+ if removeErr := removePublishedSkillSource(quarantine); removeErr != nil {
+ return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr)
+ }
+ return cleanupRoot()
+ }
+
+ verificationErr := errors.Join(identityErr, fingerprintErr)
+ if verificationErr == nil {
+ verificationErr = errors.New("发布对象身份已变化")
+ }
+ // Rollback restore is not a publication path; ignore any claim
+ // identity returned by the rename — no ownership check follows.
+ if _, restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil {
+ return fmt.Errorf("拒绝删除非本事务 Skill %s: %v;并发对象保留于 %s,恢复原路径失败: %w", destination, verificationErr, quarantine, restoreErr)
+ }
+ return errors.Join(
+ fmt.Errorf("拒绝删除非本事务 Skill %s: %w", destination, verificationErr),
+ cleanupRoot(),
+ )
+}
+
+func fingerprintSkillPath(path string) ([sha256.Size]byte, error) {
+ h := sha256.New()
+ if err := fingerprintSkillPathInto(h, path); err != nil {
+ return [sha256.Size]byte{}, err
+ }
+ var result [sha256.Size]byte
+ copy(result[:], h.Sum(nil))
+ return result, nil
+}
+
+func fingerprintSkillPathInto(h hash.Hash, path string) error {
+ info, err := skillPathLstat(path)
+ if err != nil {
+ return err
+ }
+ writeFingerprintField(h, info.Mode().Type().String())
+ writeFingerprintField(h, info.Mode().Perm().String())
+ switch {
+ case info.IsDir():
+ entries, err := skillPathReadDir(path)
+ if err != nil {
+ return err
+ }
+ sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
+ writeFingerprintField(h, fmt.Sprintf("%d", len(entries)))
+ for _, entry := range entries {
+ writeFingerprintField(h, entry.Name())
+ if err := fingerprintSkillPathInto(h, filepath.Join(path, entry.Name())); err != nil {
+ return err
+ }
+ }
+ return nil
+ case info.Mode()&os.ModeSymlink != 0:
+ target, err := skillPathReadlink(path)
+ if err != nil {
+ return err
+ }
+ writeFingerprintField(h, target)
+ return nil
+ case info.Mode().IsRegular():
+ digest, err := skillPathFileDigest(path)
+ if err != nil {
+ return err
+ }
+ writeFingerprintField(h, fmt.Sprintf("%d", info.Size()))
+ _, err = h.Write(digest[:])
+ return err
+ default:
+ return fmt.Errorf("不支持识别特殊 Skill 路径 %s(mode=%s)", path, info.Mode())
+ }
+}
+
+func writeFingerprintField(h hash.Hash, value string) {
+ _, _ = fmt.Fprintf(h, "%d:", len(value))
+ _, _ = h.Write([]byte(value))
+}
diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go
new file mode 100644
index 00000000..0a9989ec
--- /dev/null
+++ b/internal/upgrade/skill_publication_identity_darwin.go
@@ -0,0 +1,50 @@
+//go:build darwin
+
+package upgrade
+
+import (
+ "fmt"
+ "os"
+ "syscall"
+)
+
+func skillPathFileIncarnationImpl(info os.FileInfo) string {
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return fmt.Sprintf("unknown:%T", info.Sys())
+ }
+ return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen,
+ stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec)
+}
+
+func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
+ return os.SameFile(left, right)
+}
+
+// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX
+// has no open-by-ID handle, but dev:ino identifies the object for its
+// lifetime and is the same witness os.SameFile compares. It anchors the
+// mkdir-claim identity of the child-move fallback: a wholesale replacement
+// of the claimed destination reports a different identity, except in the
+// classic inode-reuse race on recycled-inode filesystems, which the
+// publication fingerprint backstop still catches. An unreadable or
+// unrecognizable path reports "" and callers treat that as "no witness".
+func skillPathFileIdentityImpl(path string) string {
+ info, err := skillPathLstat(path)
+ if err != nil {
+ return ""
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return ""
+ }
+ return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino)
+}
+
+// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is
+// the same dev:ino witness, so preferring it would add no strength while
+// making the proof refuse publishes whose staged lstat (and therefore staged
+// identity) could not be read.
+func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
+ return skillPathSameFileIdentity(staged, published)
+}
diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go
new file mode 100644
index 00000000..ff20caf2
--- /dev/null
+++ b/internal/upgrade/skill_publication_identity_linux.go
@@ -0,0 +1,49 @@
+//go:build linux
+
+package upgrade
+
+import (
+ "fmt"
+ "os"
+ "syscall"
+)
+
+func skillPathFileIncarnationImpl(info os.FileInfo) string {
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return fmt.Sprintf("unknown:%T", info.Sys())
+ }
+ return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec)
+}
+
+func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
+ return os.SameFile(left, right)
+}
+
+// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX
+// has no open-by-ID handle, but dev:ino identifies the object for its
+// lifetime and is the same witness os.SameFile compares. It anchors the
+// mkdir-claim identity of the child-move fallback: a wholesale replacement
+// of the claimed destination reports a different identity, except in the
+// classic inode-reuse race on recycled-inode filesystems (notably tmpfs),
+// which the publication fingerprint backstop still catches. An unreadable
+// or unrecognizable path reports "" and callers treat that as "no witness".
+func skillPathFileIdentityImpl(path string) string {
+ info, err := skillPathLstat(path)
+ if err != nil {
+ return ""
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return ""
+ }
+ return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino)
+}
+
+// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is
+// the same dev:ino witness, so preferring it would add no strength while
+// making the proof refuse publishes whose staged lstat (and therefore staged
+// identity) could not be read.
+func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
+ return skillPathSameFileIdentity(staged, published)
+}
diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go
new file mode 100644
index 00000000..59bfd256
--- /dev/null
+++ b/internal/upgrade/skill_publication_identity_other.go
@@ -0,0 +1,27 @@
+//go:build !linux && !darwin && !windows
+
+package upgrade
+
+import (
+ "fmt"
+ "os"
+)
+
+func skillPathFileIncarnationImpl(info os.FileInfo) string {
+ return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode())
+}
+
+func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool {
+ return os.SameFile(left, right)
+}
+
+// skillPathFileIdentityImpl reports no identity on platforms whose stat
+// layout we do not decode; the child-move claim witness degrades gracefully
+// to the publication fingerprint backstop.
+func skillPathFileIdentityImpl(_ string) string {
+ return ""
+}
+
+func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool {
+ return skillPathSameFileIdentity(staged, published)
+}
diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go
new file mode 100644
index 00000000..d6740de1
--- /dev/null
+++ b/internal/upgrade/skill_publication_identity_windows.go
@@ -0,0 +1,64 @@
+//go:build windows
+
+package upgrade
+
+import (
+ "fmt"
+ "os"
+ "syscall"
+
+ "golang.org/x/sys/windows"
+)
+
+func skillPathFileIncarnationImpl(info os.FileInfo) string {
+ stat, ok := info.Sys().(*syscall.Win32FileAttributeData)
+ if !ok {
+ return fmt.Sprintf("unknown:%T", info.Sys())
+ }
+ return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime)
+}
+
+// skillPathSameFileIdentity returns false because os.FileInfo on Windows does
+// not expose the stable file ID (VolumeSerialNumber / FileIndex from
+// GetFileInformationByHandle), which is the only primitive that distinguishes
+// a recreated file from the original after NTFS file tunneling restores the
+// creation time. Callers must use skillPathIdentityProven, which compares file
+// IDs obtained via GetFileInformationByHandle.
+func skillPathSameFileIdentityImpl(_, _ os.FileInfo) bool {
+ return false
+}
+
+// skillPathFileIdentityImpl queries the stable volume serial number and file
+// index via GetFileInformationByHandle. Unlike creation time (which NTFS file
+// tunneling can restore for a recreated same-named object), the file index
+// uniquely identifies the file on the volume for its lifetime.
+//
+// FILE_FLAG_OPEN_REPARSE_POINT opens symlinks themselves rather than following
+// them to the target. This matters at publish time because a staged symlink
+// carries a relative target computed for the final destination, which may not
+// resolve from the staging directory. Opening the reparse point yields a file
+// ID that is stable across the rename and is the correct identity to compare.
+func skillPathFileIdentityImpl(path string) (id string) {
+ if p, err := windows.UTF16PtrFromString(path); err == nil {
+ if handle, err := windows.CreateFile(
+ p, windows.FILE_READ_ATTRIBUTES,
+ windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE,
+ nil, windows.OPEN_EXISTING,
+ windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0,
+ ); err == nil {
+ defer windows.CloseHandle(handle)
+ var info windows.ByHandleFileInformation
+ if err := windows.GetFileInformationByHandle(handle, &info); err == nil {
+ id = fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow)
+ }
+ }
+ }
+ return
+}
+
+// skillPathIdentityProven is the real identity proof on Windows. An empty
+// expected value means the file ID could not be obtained at publish time, so
+// identity cannot be proven and the caller must refuse the auto-delete.
+func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool {
+ return expected != "" && expected == actual
+}
diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go
new file mode 100644
index 00000000..aacf8706
--- /dev/null
+++ b/internal/upgrade/skill_publication_test.go
@@ -0,0 +1,532 @@
+package upgrade
+
+import (
+ "crypto/sha256"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
+)
+
+func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testing.T) {
+ t.Run("existing destination is never replaced", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "new", false)
+ seedUpgradeSkill(t, destination, "concurrent", false)
+
+ if _, err := PublishSkillPathNoReplace(staged, destination); err == nil {
+ t.Fatal("expected no-replace publication to fail")
+ }
+ assertUpgradeSkillContent(t, destination, "concurrent")
+ assertUpgradeSkillContent(t, staged, "new")
+ })
+
+ t.Run("owned publication is removed", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "new", false)
+ publication, err := PublishSkillPathNoReplace(staged, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Lstat(destination); !os.IsNotExist(err) {
+ t.Fatalf("owned publication remains: %v", err)
+ }
+ })
+
+ t.Run("concurrent replacement is quarantined instead of deleted", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "new", false)
+ publication, err := PublishSkillPathNoReplace(staged, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.RemoveAll(destination); err != nil {
+ t.Fatal(err)
+ }
+ // Even a byte-for-byte identical replacement is not owned by this
+ // transaction: inode identity, not content alone, is authoritative.
+ seedUpgradeSkill(t, destination, "new", false)
+
+ err = RollbackSkillPathPublications([]SkillPathPublication{publication})
+ if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
+ t.Fatalf("replacement rollback error = %v", err)
+ }
+ assertUpgradeSkillContent(t, destination, "new")
+ })
+
+ t.Run("tunneled replacement with same creation time is still refused", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "new", false)
+ // NTFS file tunneling can restore the original creation time for a
+ // same-named recreation, defeating the incarnation check. The file ID
+ // (or inode on Unix) must still differ and block the rollback.
+ testseam.Swap(t, &skillPathFileIncarnation, func(os.FileInfo) string { return "tunneled" })
+ publication, err := PublishSkillPathNoReplace(staged, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Simulate the identity change that a concurrent replacement causes.
+ // On Windows, skillPathIdentityProven compares the recorded file ID
+ // from GetFileInformationByHandle with the live one and ignores the
+ // SameFile seam; NTFS can reuse the freed MFT record for the
+ // recreation, so the live probe is perturbed through the file-ID
+ // seam as well. On Unix, where the file ID is advisory, this swap
+ // replaces the os.SameFile check (which can return true for a
+ // recreated file on tmpfs due to inode reuse) so the test is
+ // deterministic across filesystems.
+ testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false })
+ originalFileIdentity := skillPathFileIdentity
+ testseam.Swap(t, &skillPathFileIdentity, func(path string) string {
+ return originalFileIdentity(path) + ":replaced"
+ })
+ if err := os.RemoveAll(destination); err != nil {
+ t.Fatal(err)
+ }
+ seedUpgradeSkill(t, destination, "new", false)
+
+ err = RollbackSkillPathPublications([]SkillPathPublication{publication})
+ if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
+ t.Fatalf("tunneled replacement rollback error = %v", err)
+ }
+ assertUpgradeSkillContent(t, destination, "new")
+ })
+}
+
+func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) {
+ failure := errors.New("injected publication failure")
+
+ t.Run("platform no-replace path encoding", func(t *testing.T) {
+ if _, err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil {
+ t.Fatal("expected invalid source path failure")
+ }
+ source := filepath.Join(t.TempDir(), "source")
+ if err := os.WriteFile(source, []byte("value"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil {
+ t.Fatal("expected invalid destination path failure")
+ }
+ })
+
+ t.Run("publish identity", func(t *testing.T) {
+ if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil {
+ t.Fatal("expected identity failure")
+ }
+ })
+
+ t.Run("publish fingerprint", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ seedUpgradeSkill(t, staged, "value", false)
+ originalLstat := skillPathLstat
+ calls := 0
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ calls++
+ if calls == 2 {
+ return nil, failure
+ }
+ return originalLstat(path)
+ })
+ if _, err := PublishSkillPathNoReplace(staged, filepath.Join(root, "dest")); !errors.Is(err, failure) {
+ t.Fatal("expected fingerprint failure")
+ }
+ })
+
+ t.Run("publish confirmation missing", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "value", false)
+ originalRename := skillPathRenameNoReplace
+ testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
+ if _, err := originalRename(source, target); err != nil {
+ return "", err
+ }
+ return "", os.RemoveAll(target)
+ })
+ if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") {
+ t.Fatalf("publish confirmation error = %v", err)
+ }
+ })
+
+ t.Run("publish confirmation identity changed", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "value", false)
+ // A real rename consumed the staged path (fast path), but the object
+ // at the destination must be proven to be the staged one. Whether a
+ // same-content swap is observable through inode/file-ID comparison
+ // depends on the filesystem — CI runners' ext4/overlayfs recycle
+ // inodes eagerly and NTFS can immediately reuse the freed MFT record,
+ // so reproducing the swap physically is not portable. Force the
+ // failure through both underlying primitives the platform-specific
+ // proof consults: os.SameFile (Unix) and the stable file ID
+ // (Windows), pinning the confirmation's rejection on every platform.
+ testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false })
+ originalFileIdentity := skillPathFileIdentity
+ stagedID := ""
+ testseam.Swap(t, &skillPathFileIdentity, func(path string) string {
+ id := originalFileIdentity(path)
+ if stagedID == "" {
+ stagedID = id
+ return id
+ }
+ // The published probe must report a different object than the
+ // staged one so the proof fails on both platforms.
+ return id + ":swapped"
+ })
+ if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") {
+ t.Fatalf("publish identity error = %v", err)
+ }
+ assertUpgradeSkillContent(t, destination, "value")
+ })
+
+ t.Run("publish confirmation fingerprint", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "value", false)
+ originalRename := skillPathRenameNoReplace
+ published := false
+ testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
+ if _, err := originalRename(source, target); err != nil {
+ return "", err
+ }
+ published = true
+ return "", nil
+ })
+ originalReadDir := skillPathReadDir
+ testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
+ if published && path == destination {
+ return nil, failure
+ }
+ return originalReadDir(path)
+ })
+ if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") {
+ t.Fatalf("publish fingerprint confirmation error = %v", err)
+ }
+ })
+
+ t.Run("rollback temp", func(t *testing.T) {
+ testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure })
+ if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) {
+ t.Fatalf("temp error = %v", err)
+ }
+ })
+
+ t.Run("missing destination cleanup", func(t *testing.T) {
+ if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("missing destination cleanup failure", func(t *testing.T) {
+ testseam.Swap(t, &skillPathRemoveAll, func(string) error { return failure })
+ if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); !errors.Is(err, failure) {
+ t.Fatalf("cleanup error = %v", err)
+ }
+ })
+
+ t.Run("quarantine rename", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "value", false)
+ publication, err := PublishSkillPathNoReplace(staged, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &skillPathRename, func(string, string) error { return failure })
+ if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
+ t.Fatalf("quarantine error = %v", err)
+ }
+ })
+
+ t.Run("live verification", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "value", false)
+ publication, err := PublishSkillPathNoReplace(staged, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalReadDir := skillPathReadDir
+ testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
+ if path == destination {
+ return nil, failure
+ }
+ return originalReadDir(path)
+ })
+ if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
+ t.Fatalf("live verification error = %v", err)
+ }
+ })
+
+ t.Run("destination disappears before quarantine", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "value", false)
+ publication, err := PublishSkillPathNoReplace(staged, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &skillPathRename, func(source, target string) error {
+ if err := os.RemoveAll(source); err != nil {
+ return err
+ }
+ return &os.PathError{Op: "rename", Path: source, Err: os.ErrNotExist}
+ })
+ if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("owned remove", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "value", false)
+ publication, err := PublishSkillPathNoReplace(staged, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalRemove := skillPathRemoveAll
+ testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
+ if filepath.Base(path) == "payload" {
+ return failure
+ }
+ return originalRemove(path)
+ })
+ if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
+ t.Fatalf("remove error = %v", err)
+ }
+ })
+
+ t.Run("quarantined fingerprint", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "value", false)
+ publication, err := PublishSkillPathNoReplace(staged, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalLstat := skillPathLstat
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if filepath.Base(path) == "payload" {
+ return nil, failure
+ }
+ return originalLstat(path)
+ })
+ if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
+ t.Fatalf("fingerprint error = %v", err)
+ }
+ })
+
+ t.Run("mismatch restore and cleanup", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "published", false)
+ publication, err := PublishSkillPathNoReplace(staged, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalRename := skillPathRename
+ testseam.Swap(t, &skillPathRename, func(source, target string) error {
+ if source == destination {
+ if err := os.RemoveAll(source); err != nil {
+ return err
+ }
+ seedUpgradeSkill(t, source, "replacement", false)
+ }
+ return originalRename(source, target)
+ })
+ originalRemove := skillPathRemoveAll
+ testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
+ if strings.Contains(filepath.Base(path), ".destination.rollback-") {
+ return failure
+ }
+ return originalRemove(path)
+ })
+ if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
+ t.Fatalf("mismatch cleanup error = %v", err)
+ }
+ assertUpgradeSkillContent(t, destination, "replacement")
+ })
+
+ t.Run("mismatch restore failure", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "published", false)
+ publication, err := PublishSkillPathNoReplace(staged, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ originalRename := skillPathRename
+ testseam.Swap(t, &skillPathRename, func(source, target string) error {
+ if source == destination {
+ if err := os.RemoveAll(source); err != nil {
+ return err
+ }
+ seedUpgradeSkill(t, source, "replacement", false)
+ }
+ return originalRename(source, target)
+ })
+ testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", failure })
+ if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") {
+ t.Fatalf("mismatch restore error = %v", err)
+ }
+ })
+
+ t.Run("fingerprint read failures and lexical types", func(t *testing.T) {
+ t.Run("directory ordering", func(t *testing.T) {
+ dir := t.TempDir()
+ for _, name := range []string{"b", "a"} {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := fingerprintSkillPath(dir); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Run("directory read", func(t *testing.T) {
+ dir := t.TempDir()
+ testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
+ if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
+ t.Fatalf("read dir error = %v", err)
+ }
+ })
+ t.Run("recursive child", func(t *testing.T) {
+ dir := t.TempDir()
+ child := filepath.Join(dir, "child")
+ if err := os.WriteFile(child, []byte("value"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ originalLstat := skillPathLstat
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == child {
+ return nil, failure
+ }
+ return originalLstat(path)
+ })
+ if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
+ t.Fatalf("child error = %v", err)
+ }
+ })
+ t.Run("symlink read", func(t *testing.T) {
+ link := filepath.Join(t.TempDir(), "link")
+ if err := os.Symlink("missing", link); err != nil {
+ t.Skipf("symlink unavailable: %v", err)
+ }
+ testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", failure })
+ if _, err := fingerprintSkillPath(link); !errors.Is(err, failure) {
+ t.Fatalf("readlink error = %v", err)
+ }
+ })
+ t.Run("symlink success", func(t *testing.T) {
+ link := filepath.Join(t.TempDir(), "link")
+ if err := os.Symlink("missing", link); err != nil {
+ t.Skipf("symlink unavailable: %v", err)
+ }
+ if _, err := fingerprintSkillPath(link); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Run("file digest", func(t *testing.T) {
+ file := filepath.Join(t.TempDir(), "file")
+ if err := os.WriteFile(file, []byte("value"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &skillPathOpen, func(string) (*os.File, error) { return nil, failure })
+ if _, err := fingerprintSkillPath(file); !errors.Is(err, failure) {
+ t.Fatalf("digest error = %v", err)
+ }
+ })
+ t.Run("special", func(t *testing.T) {
+ testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
+ return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
+ })
+ if err := fingerprintSkillPathInto(sha256.New(), "special"); err == nil {
+ t.Fatal("expected special path error")
+ }
+ })
+ t.Run("unknown platform identity shape", func(t *testing.T) {
+ if got := skillPathFileIncarnation(skillPathFakeInfo{}); got == "" {
+ t.Fatal("empty incarnation")
+ }
+ })
+ t.Run("synthetic info does not match same-file identity", func(t *testing.T) {
+ fake := skillPathFakeInfo{}
+ if skillPathSameFileIdentity(fake, fake) {
+ t.Fatal("synthetic info should not match")
+ }
+ })
+ })
+}
+
+func TestCrossPlatformCoverageUpgradeRollbackRetainsConcurrentReplacement(t *testing.T) {
+ home := t.TempDir()
+ base := filepath.Join(home, ".agents", "skills")
+ first := filepath.Join(base, "dingtalk-first")
+ second := filepath.Join(base, "dingtalk-second")
+ seedUpgradeSkill(t, first, "old first", false)
+ seedUpgradeSkill(t, second, "old second", false)
+ stageRoot := filepath.Join(base, ".stage")
+ stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
+ stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
+ seedUpgradeSkill(t, stagedFirst, "new first", false)
+ seedUpgradeSkill(t, stagedSecond, "new second", false)
+
+ failure := errors.New("injected second publication failure")
+ originalPublish := upgradePublishSkillPath
+ calls := 0
+ testseam.Swap(t, &upgradePublishSkillPath, func(staged, destination string) (SkillPathPublication, error) {
+ calls++
+ if calls == 2 {
+ if err := os.RemoveAll(first); err != nil {
+ t.Fatal(err)
+ }
+ seedUpgradeSkill(t, first, "concurrent", false)
+ return SkillPathPublication{}, failure
+ }
+ return originalPublish(staged, destination)
+ })
+
+ err := publishStagedSkillSet(home, []stagedSkillDir{
+ {staged: stagedFirst, dest: first},
+ {staged: stagedSecond, dest: second},
+ }, []string{first, second})
+ if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
+ t.Fatalf("transaction error = %v", err)
+ }
+ assertUpgradeSkillContent(t, first, "concurrent")
+ assertUpgradeSkillContent(t, second, "old second")
+ matches, globErr := filepath.Glob(filepath.Join(home, ".dws", "skill-backups", "*", ".agents-skills-dingtalk-first", "SKILL.md"))
+ if globErr != nil || len(matches) != 1 {
+ t.Fatalf("retained old backup = %v, %v", matches, globErr)
+ }
+ content, readErr := os.ReadFile(matches[0])
+ if readErr != nil || string(content) != "old first" {
+ t.Fatalf("retained backup content = %q, %v", content, readErr)
+ }
+}
diff --git a/internal/upgrade/skill_publish_childmove_edges_test.go b/internal/upgrade/skill_publish_childmove_edges_test.go
new file mode 100644
index 00000000..0072228a
--- /dev/null
+++ b/internal/upgrade/skill_publish_childmove_edges_test.go
@@ -0,0 +1,451 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package upgrade
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
+)
+
+// childMoveDirEntry fakes one directory entry for seam-driven claim scans.
+type childMoveDirEntry struct {
+ name string
+ isDir bool
+}
+
+func (entry childMoveDirEntry) Name() string { return entry.name }
+func (entry childMoveDirEntry) IsDir() bool { return entry.isDir }
+func (entry childMoveDirEntry) Type() os.FileMode { return entry.info().Mode().Type() }
+func (entry childMoveDirEntry) Info() (os.FileInfo, error) { return entry.info(), nil }
+func (entry childMoveDirEntry) info() os.FileInfo {
+ if entry.isDir {
+ return skillPathFakeInfo{mode: os.ModeDir | 0o755}
+ }
+ return skillPathFakeInfo{mode: 0o644}
+}
+
+// forceChildMove routes every directory publication through the child-move
+// slow path: the atomic primitive reports the flag unsupported and the
+// fast-path rename fails the way platforms that refuse renaming over any
+// existing directory do.
+func forceChildMove(t *testing.T) {
+ t.Helper()
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
+}
+
+// TestCrossPlatformCoverageChildMoveEdges pins the error and dispatch
+// branches of the child-move directory fallback on every platform: the
+// platform coverage gates only execute TestCrossPlatformCoverage-named
+// tests, so these branches must be driven through seams rather than the
+// platform-specific filesystem behaviors that cover them incidentally
+// elsewhere.
+func TestCrossPlatformCoverageChildMoveEdges(t *testing.T) {
+
+ t.Run("directory source stat failure surfaces", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ original := skillPathLstat
+ calls := 0
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == source {
+ calls++
+ if calls > 1 {
+ return nil, os.ErrPermission
+ }
+ }
+ return original(path)
+ })
+ if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("directory stat failure = %v", err)
+ }
+ })
+
+ t.Run("claim read failure surfaces and reports the cleanup check", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
+ if path == destination {
+ return nil, os.ErrPermission
+ }
+ return os.ReadDir(path)
+ })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || !strings.Contains(err.Error(), "检查降级发布目标") {
+ t.Fatalf("claim read failure = %v", err)
+ }
+ })
+
+ t.Run("claim vanished during cleanup is tolerated", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ destinationReads := 0
+ original := skillPathReadDir
+ testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
+ if path == destination {
+ destinationReads++
+ if destinationReads == 1 {
+ return nil, os.ErrPermission
+ }
+ return nil, os.ErrNotExist
+ }
+ return original(path)
+ })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || strings.Contains(err.Error(), "检查降级发布目标") {
+ t.Fatalf("vanished claim cleanup = %v", err)
+ }
+ })
+
+ t.Run("file child link failure surfaces and the empty claim cleanup reports", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission })
+ testseam.Swap(t, &skillPathRemove, func(path string) error {
+ if path == destination {
+ return os.ErrPermission
+ }
+ return os.Remove(path)
+ })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !strings.Contains(err.Error(), "发布 Skill 文件失败") || !strings.Contains(err.Error(), "清理降级发布目标") {
+ t.Fatalf("link failure = %v", err)
+ }
+ assertUpgradeSkillContent(t, source, "payload")
+ })
+
+ t.Run("file child collision reports ErrExist", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrExist })
+ if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) {
+ t.Fatalf("file child collision = %v, want ErrExist", err)
+ }
+ })
+
+ t.Run("later child failure rolls earlier children back", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ if err := os.WriteFile(filepath.Join(source, "second"), []byte("second"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ forceChildMove(t)
+ // The second child's link fails; the forced rename failure then also
+ // breaks the rollback rename of the already relocated first child.
+ testseam.Swap(t, &skillPathLink, func(source, _ string) error {
+ if filepath.Base(source) == "second" {
+ return os.ErrPermission
+ }
+ return os.Link(source, filepath.Join(destination, filepath.Base(source)))
+ })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !strings.Contains(err.Error(), "迁移 Skill 目录项失败") {
+ t.Fatalf("child failure rollback = %v", err)
+ }
+ // The forced rename failure also breaks the rollback restore, so the
+ // relocated child stays at the destination — retained, never deleted.
+ assertUpgradeSkillContent(t, destination, "payload")
+ })
+
+ t.Run("live claim read failure aborts with rollback", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ destinationReads := 0
+ original := skillPathReadDir
+ testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
+ if path == destination {
+ destinationReads++
+ if destinationReads == 2 {
+ return nil, os.ErrPermission
+ }
+ }
+ return original(path)
+ })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !strings.Contains(err.Error(), "确认降级发布目标") {
+ t.Fatalf("live claim read failure = %v", err)
+ }
+ })
+
+ t.Run("foreign claim entry aborts the move", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ destinationReads := 0
+ original := skillPathReadDir
+ testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
+ if path == destination {
+ destinationReads++
+ if destinationReads == 2 {
+ return []os.DirEntry{
+ childMoveDirEntry{name: "SKILL.md"},
+ childMoveDirEntry{name: "foreign"},
+ }, nil
+ }
+ }
+ return original(path)
+ })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发目录项") {
+ t.Fatalf("foreign claim entry = %v", err)
+ }
+ })
+
+ t.Run("claim mode restore failure aborts with rollback", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !strings.Contains(err.Error(), "恢复已发布 Skill 目录权限") {
+ t.Fatalf("mode restore failure = %v", err)
+ }
+ })
+
+ t.Run("source shell removal failure surfaces with the destination retained", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ testseam.Swap(t, &skillPathRemove, func(path string) error {
+ if path == source {
+ return os.ErrPermission
+ }
+ return os.Remove(path)
+ })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !strings.Contains(err.Error(), "清理已迁移 Skill 源目录") {
+ t.Fatalf("shell removal failure = %v", err)
+ }
+ assertUpgradeSkillContent(t, destination, "payload")
+ })
+
+ t.Run("nested directories and mixed children publish", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(source, "nested", "inner.txt"), []byte("inner"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(source, "link"), []byte("stub"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ forceChildMove(t)
+ // The "link" child is presented to the dispatcher as a symlink; its
+ // recreation is simulated through seams so the test needs no
+ // symlink privileges (Windows runners refuse unprivileged symlinks).
+ originalLstat := skillPathLstat
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == filepath.Join(source, "link") {
+ return skillPathFakeInfo{mode: os.ModeSymlink}, nil
+ }
+ return originalLstat(path)
+ })
+ testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
+ testseam.Swap(t, &skillPathSymlink, func(string, string) error { return nil })
+ claim, err := renameSkillPathNoReplace(source, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if claim == "" {
+ t.Fatal("child-move must return the claim identity")
+ }
+ assertUpgradeSkillContent(t, destination, "payload")
+ if data, readErr := os.ReadFile(filepath.Join(destination, "nested", "inner.txt")); readErr != nil || string(data) != "inner" {
+ t.Fatalf("nested child = %q, %v", data, readErr)
+ }
+ if _, statErr := os.Lstat(filepath.Join(source, "link")); !os.IsNotExist(statErr) {
+ t.Fatalf("simulated symlink child must have its source consumed, stat err=%v", statErr)
+ }
+ if _, statErr := os.Lstat(source); !os.IsNotExist(statErr) {
+ t.Fatalf("source must be gone, stat err=%v", statErr)
+ }
+ })
+
+ t.Run("special-type child is refused", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ originalLstat := skillPathLstat
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == filepath.Join(source, "SKILL.md") {
+ return mockSpecialInfo{}, nil
+ }
+ return originalLstat(path)
+ })
+ if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, errNoReplaceRenameUnsupported) {
+ t.Fatalf("special child = %v, want unsupported", err)
+ }
+ })
+
+ t.Run("symlink child readlink failure surfaces", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ originalLstat := skillPathLstat
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == filepath.Join(source, "SKILL.md") {
+ return skillPathFakeInfo{mode: os.ModeSymlink}, nil
+ }
+ return originalLstat(path)
+ })
+ testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission })
+ if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("symlink readlink failure = %v", err)
+ }
+ })
+
+ t.Run("symlink child collision reports ErrExist", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ originalLstat := skillPathLstat
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == filepath.Join(source, "SKILL.md") {
+ return skillPathFakeInfo{mode: os.ModeSymlink}, nil
+ }
+ return originalLstat(path)
+ })
+ testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
+ testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrExist })
+ if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) {
+ t.Fatalf("symlink collision = %v, want ErrExist", err)
+ }
+ })
+
+ t.Run("symlink child publish failure surfaces", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ originalLstat := skillPathLstat
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == filepath.Join(source, "SKILL.md") {
+ return skillPathFakeInfo{mode: os.ModeSymlink}, nil
+ }
+ return originalLstat(path)
+ })
+ testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil })
+ testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrPermission })
+ if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("symlink publish failure = %v", err)
+ }
+ })
+
+ t.Run("child stat failure surfaces", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ originalLstat := skillPathLstat
+ testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
+ if path == filepath.Join(source, "SKILL.md") {
+ return nil, os.ErrPermission
+ }
+ return originalLstat(path)
+ })
+ if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("child stat failure = %v", err)
+ }
+ assertUpgradeSkillContent(t, source, "payload")
+ })
+
+ t.Run("source read failure aborts with the empty claim cleaned", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ forceChildMove(t)
+ original := skillPathReadDir
+ testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
+ if path == source {
+ return nil, os.ErrPermission
+ }
+ return original(path)
+ })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !strings.Contains(err.Error(), "读取源 Skill 目录失败") {
+ t.Fatalf("source read failure = %v", err)
+ }
+ if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) {
+ t.Fatalf("empty claim must be cleaned, stat err=%v", statErr)
+ }
+ assertUpgradeSkillContent(t, source, "payload")
+ })
+
+ t.Run("file identity degrades on unreadable and unrecognizable objects", func(t *testing.T) {
+ testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
+ if id := skillPathFileIdentityImpl("unreadable"); id != "" {
+ t.Fatalf("unreadable identity = %q, want empty", id)
+ }
+ testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return skillPathFakeInfo{mode: 0o644}, nil })
+ if id := skillPathFileIdentityImpl("synthetic"); id != "" {
+ t.Fatalf("unrecognizable identity = %q, want empty", id)
+ }
+ })
+
+ t.Run("published content drift after the fast-path rename surfaces", func(t *testing.T) {
+ dir := t.TempDir()
+ staged := filepath.Join(dir, "staged")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, staged, "value", false)
+ originalRename := skillPathRenameNoReplace
+ testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
+ if _, err := originalRename(source, target); err != nil {
+ return "", err
+ }
+ // The published bytes drift after the rename while the object
+ // keeps its identity, so only the fingerprint check can refuse.
+ if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("drifted"), 0o644); err != nil {
+ return "", err
+ }
+ return "", nil
+ })
+ if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 内容已变化") {
+ t.Fatalf("content drift = %v", err)
+ }
+ })
+}
diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go
new file mode 100644
index 00000000..ae1742c5
--- /dev/null
+++ b/internal/upgrade/skill_publish_rename.go
@@ -0,0 +1,290 @@
+package upgrade
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace
+// rename primitive at all.
+var errNoReplaceRenameUnsupported = errors.New("平台不支持原子 no-replace rename")
+
+var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic
+
+// renameSkillPathNoReplace moves source onto destination and never replaces an
+// object that already occupies destination. On success it also returns the
+// fresh mkdir-claim identity captured by the child-move directory fallback
+// (empty when the atomic primitive or the empty-target fast path published
+// dest, or when the platform cannot report a stable file identity). The
+// publication path uses that identity as a tamperproof witness that dest is
+// still the mkdir claim this transaction created; every other caller ignores
+// it.
+//
+// The atomic primitives (Linux RENAME_NOREPLACE, Darwin RENAME_EXCL) require
+// support from the underlying filesystem and report EINVAL/ENOTSUP when it is
+// missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and
+// overlayfs homes reject the flag. Failing there would abort the whole install
+// on those filesystems, so the fallback uses truly atomic no-clobber primitives
+// that work on every POSIX filesystem: os.Mkdir for directories (fails with
+// EEXIST if the path is occupied), os.Link for regular files (fails with
+// EEXIST if the path is occupied), and os.Symlink for symbolic links (same
+// EEXIST guarantee). Neither primitive has a TOCTOU window.
+func renameSkillPathNoReplace(source, destination string) (string, error) {
+ err := skillPathRenameNoReplaceAtomic(source, destination)
+ if err == nil {
+ return "", nil
+ }
+ if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) {
+ return "", err
+ }
+ return renameSkillPathNoReplaceFallback(source, destination)
+}
+
+// renameSkillPathNoReplaceFallback is used when the kernel no-replace rename
+// flag is unavailable. It dispatches on the source type to pick an atomic
+// no-clobber primitive that does not require filesystem support. When the
+// directory slow path publishes via child-move, the returned claim identity
+// is the destination file identity captured immediately after mkdir; every
+// other outcome returns an empty claim identity.
+func renameSkillPathNoReplaceFallback(source, destination string) (string, error) {
+ info, err := skillPathLstat(source)
+ if err != nil {
+ return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err)
+ }
+ switch {
+ case info.IsDir():
+ return renameSkillDirNoReplaceFallback(source, destination)
+ case info.Mode().IsRegular():
+ return "", renameSkillFileNoReplaceFallback(source, destination)
+ default:
+ return "", &os.LinkError{
+ Op: "rename", Old: source, New: destination,
+ Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported),
+ }
+ }
+}
+
+// renameSkillDirNoReplaceFallback claims the destination once with mkdir and
+// never unlinks it for the remainder of the transaction. The fast path
+// renames the source over the claim; on platforms that permit replacing an
+// empty directory (Linux) that rename only ever touches the claim we own.
+// Platforms that refuse to rename over any directory (macOS returns EEXIST
+// even for an empty target) take the slow path instead — but only after
+// proving the claim is still empty: a rename that failed because a
+// concurrent writer landed inside the claim must abort with the destination
+// retained, never fall through to child moves whose per-entry primitives
+// would collide with (and rollback would delete) the foreign data. The slow
+// path moves the source children into the claim one by one, each through an
+// atomic no-clobber primitive (mkdir claim for directories, os.Link for
+// files, os.Symlink for links), so a same-name concurrent entry aborts the
+// move and a foreign entry of any name — detected by re-reading the claim —
+// retains the destination instead of letting the rollback delete it. The
+// destination claim is removed only while still empty, so a foreign entry
+// always survives a failed publication. On success the source is fully
+// consumed either way: the fast-path rename moves it onto the claim, and
+// the child-move slow path removes the emptied source shell after the last
+// child relocates.
+//
+// The returned claim identity is non-empty only when the child-move slow
+// path published dest and the platform can report a stable file identity;
+// the fast-path rename consumes the mkdir claim (dest inherits the source
+// inode) and returns "", as does an unsuccessful publish. Callers that need
+// a tamperproof ownership witness (see PublishSkillPathNoReplace) compare
+// the returned identity against skillPathFileIdentity(destination) at the
+// moment of the ownership check — a wholesale replacement of dest after
+// the claim produces a different identity.
+//
+// The old fallback (mkdir → rename → remove → rename) had a TOCTOU window
+// between the second remove and the second rename during which a concurrent
+// writer could create a file or empty directory at dest that the second
+// os.Rename would silently overwrite. This implementation removes that
+// window: the claim is never unlinked between the fast-path rename attempt
+// and publication, and child-move relies exclusively on atomic no-clobber
+// primitives that fail with EEXIST rather than replace.
+func renameSkillDirNoReplaceFallback(source, destination string) (string, error) {
+ sourceInfo, err := skillPathLstat(source)
+ if err != nil {
+ return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err)
+ }
+ if err := skillPathMkdir(destination, 0o700); err != nil {
+ if os.IsExist(err) {
+ return "", &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
+ }
+ return "", fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err)
+ }
+ // Capture the claim identity immediately after mkdir but before any
+ // children move in. A concurrent replacement of destination (unlink +
+ // recreate) after this point produces a different identity.
+ claimID := skillPathFileIdentity(destination)
+ if err := skillPathRename(source, destination); err == nil {
+ // Fast path consumed the source: destination inode is now the
+ // staged inode, not the mkdir claim. Return no claim identity —
+ // the caller falls back to the standard staged-identity proof.
+ return "", nil
+ }
+ claimEntries, claimErr := skillPathReadDir(destination)
+ if claimErr != nil {
+ return "", errors.Join(
+ fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, claimErr),
+ removeClaimIfEmpty(destination),
+ )
+ }
+ if len(claimEntries) > 0 {
+ // A concurrent writer populated the empty claim before we could
+ // take the slow path. Falling through to child-move would let
+ // its per-entry no-clobber primitives collide with the foreign
+ // data; abort with the destination retained instead.
+ return "", fmt.Errorf("降级发布中止,目标在声明后被并发写入 %s: %w", destination, os.ErrExist)
+ }
+ if err := moveSkillDirChildrenIntoClaim(source, destination, sourceInfo.Mode().Perm()); err != nil {
+ return "", fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, err)
+ }
+ // Child-move consumed every source entry, so the emptied source shell is
+ // ours by construction. os.Remove refuses a non-empty directory, so a
+ // concurrent writer inside the staging tree fails the publication loudly
+ // instead of being silently discarded with the shell.
+ if err := skillPathRemove(source); err != nil && !os.IsNotExist(err) {
+ return "", fmt.Errorf("清理已迁移 Skill 源目录失败 %s: %w", source, err)
+ }
+ // Child-move succeeded: dest is still the mkdir claim we created.
+ return claimID, nil
+}
+
+// moveSkillDirChildNoReplace moves one child of a staged directory into the
+// claimed destination through an atomic no-clobber primitive: directories
+// recursively claim with mkdir, regular files publish via os.Link, and
+// symlinks are recreated with os.Symlink. Every primitive fails with EEXIST
+// when the destination entry was taken concurrently, so no step can replace
+// a foreign object.
+func moveSkillDirChildNoReplace(source, destination string) error {
+ info, err := skillPathLstat(source)
+ if err != nil {
+ return fmt.Errorf("读取源 Skill 目录项失败 %s: %w", source, err)
+ }
+ switch {
+ case info.IsDir():
+ // The nested fallback consumes the nested source on success — the
+ // fast-path rename moves it away and the child-move slow path
+ // removes the emptied shell. The nested claim identity is not a
+ // witness for this transaction (only the top-level destination's
+ // identity is), so it is discarded here.
+ _, err := renameSkillDirNoReplaceFallback(source, destination)
+ return err
+ case info.Mode().IsRegular():
+ return renameSkillFileNoReplaceFallback(source, destination)
+ case info.Mode()&os.ModeSymlink != 0:
+ return renameSkillSymlinkNoReplaceFallback(source, destination)
+ default:
+ return &os.LinkError{
+ Op: "rename", Old: source, New: destination,
+ Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported),
+ }
+ }
+}
+
+// removeClaimIfEmpty deletes the claimed destination only while it still
+// holds no entries. A foreign entry that appeared after the claim must never
+// be removed: the rollback has no ownership proof for it.
+func removeClaimIfEmpty(destination string) error {
+ entries, err := skillPathReadDir(destination)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return fmt.Errorf("检查降级发布目标失败 %s: %w", destination, err)
+ }
+ if len(entries) > 0 {
+ return nil
+ }
+ if err := skillPathRemove(destination); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("清理降级发布目标失败 %s: %w", destination, err)
+ }
+ return nil
+}
+
+// moveSkillDirChildrenIntoClaim moves every top-level child of source into
+// the claimed destination — each through moveSkillDirChildNoReplace, so a
+// concurrently created same-name entry fails atomically — and restores the
+// source mode on the claim. The claim is re-read after the moves: an entry
+// count above the source's means a different-named foreign object landed
+// mid-move, and the publication aborts with the destination retained. Any
+// failure moves the already relocated children back and removes the claim
+// only while it is empty, leaving the source intact and foreign entries in
+// place.
+func moveSkillDirChildrenIntoClaim(source, destination string, sourceMode os.FileMode) error {
+ entries, err := skillPathReadDir(source)
+ if err != nil {
+ return errors.Join(
+ fmt.Errorf("读取源 Skill 目录失败 %s: %w", source, err),
+ removeClaimIfEmpty(destination),
+ )
+ }
+ moved := make([]string, 0, len(entries))
+ rollback := func() error {
+ var rollbackErr error
+ for i := len(moved) - 1; i >= 0; i-- {
+ if err := skillPathRename(filepath.Join(destination, moved[i]), filepath.Join(source, moved[i])); err != nil {
+ rollbackErr = errors.Join(rollbackErr, err)
+ }
+ }
+ rollbackErr = errors.Join(rollbackErr, removeClaimIfEmpty(destination))
+ return rollbackErr
+ }
+ for _, entry := range entries {
+ name := entry.Name()
+ if err := moveSkillDirChildNoReplace(filepath.Join(source, name), filepath.Join(destination, name)); err != nil {
+ return errors.Join(fmt.Errorf("迁移 Skill 目录项失败 %s: %w", name, err), rollback())
+ }
+ moved = append(moved, name)
+ }
+ live, liveErr := skillPathReadDir(destination)
+ if liveErr != nil {
+ return errors.Join(
+ fmt.Errorf("确认降级发布目标失败 %s: %w", destination, liveErr),
+ rollback(),
+ )
+ }
+ if len(live) > len(entries) {
+ return errors.Join(
+ fmt.Errorf("降级发布中止,目标出现并发目录项 %s: %w", destination, os.ErrExist),
+ rollback(),
+ )
+ }
+ if err := skillPathChmod(destination, sourceMode); err != nil {
+ return errors.Join(
+ fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", destination, err),
+ rollback(),
+ )
+ }
+ return nil
+}
+
+// renameSkillFileNoReplaceFallback uses os.Link, which atomically fails if the
+// destination exists, then removes the source to complete the move.
+func renameSkillFileNoReplaceFallback(source, destination string) error {
+ if err := skillPathLink(source, destination); err != nil {
+ if os.IsExist(err) {
+ return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
+ }
+ return fmt.Errorf("发布 Skill 文件失败 %s: %w", destination, err)
+ }
+ return skillPathRemove(source)
+}
+
+// renameSkillSymlinkNoReplaceFallback recreates the link at the destination
+// with os.Symlink — atomic, EEXIST when taken — and then removes the source.
+func renameSkillSymlinkNoReplaceFallback(source, destination string) error {
+ target, err := skillPathReadlink(source)
+ if err != nil {
+ return fmt.Errorf("读取源 Skill 链接失败 %s: %w", source, err)
+ }
+ if err := skillPathSymlink(target, destination); err != nil {
+ if os.IsExist(err) {
+ return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist}
+ }
+ return fmt.Errorf("发布 Skill 链接失败 %s: %w", destination, err)
+ }
+ return skillPathRemove(source)
+}
diff --git a/internal/upgrade/skill_publish_rename_darwin.go b/internal/upgrade/skill_publish_rename_darwin.go
new file mode 100644
index 00000000..94df0fb2
--- /dev/null
+++ b/internal/upgrade/skill_publish_rename_darwin.go
@@ -0,0 +1,22 @@
+//go:build darwin
+
+package upgrade
+
+import (
+ "errors"
+ "syscall"
+
+ "golang.org/x/sys/unix"
+)
+
+func renameSkillPathNoReplaceAtomic(source, destination string) error {
+ return unix.RenamexNp(source, destination, unix.RENAME_EXCL)
+}
+
+// Volumes whose filesystem does not implement renamex_np (several network and
+// FUSE mounts) reject RENAME_EXCL instead of performing the rename.
+func isNoReplaceRenameUnsupported(err error) bool {
+ return errors.Is(err, syscall.ENOTSUP) ||
+ errors.Is(err, syscall.EINVAL) ||
+ errors.Is(err, syscall.ENOSYS)
+}
diff --git a/internal/upgrade/skill_publish_rename_linux.go b/internal/upgrade/skill_publish_rename_linux.go
new file mode 100644
index 00000000..20d1be77
--- /dev/null
+++ b/internal/upgrade/skill_publish_rename_linux.go
@@ -0,0 +1,23 @@
+//go:build linux
+
+package upgrade
+
+import (
+ "errors"
+ "syscall"
+
+ "golang.org/x/sys/unix"
+)
+
+func renameSkillPathNoReplaceAtomic(source, destination string) error {
+ return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE)
+}
+
+// rename(2) documents EINVAL as "the filesystem does not support one of the
+// flags", which is what NFS, FUSE and overlayfs return; ENOSYS covers kernels
+// without renameat2 at all.
+func isNoReplaceRenameUnsupported(err error) bool {
+ return errors.Is(err, syscall.EINVAL) ||
+ errors.Is(err, syscall.ENOSYS) ||
+ errors.Is(err, syscall.EOPNOTSUPP)
+}
diff --git a/internal/upgrade/skill_publish_rename_other.go b/internal/upgrade/skill_publish_rename_other.go
new file mode 100644
index 00000000..c9695771
--- /dev/null
+++ b/internal/upgrade/skill_publish_rename_other.go
@@ -0,0 +1,9 @@
+//go:build !linux && !darwin && !windows
+
+package upgrade
+
+func renameSkillPathNoReplaceAtomic(_, _ string) error {
+ return errNoReplaceRenameUnsupported
+}
+
+func isNoReplaceRenameUnsupported(error) bool { return false }
diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go
new file mode 100644
index 00000000..6e068eaa
--- /dev/null
+++ b/internal/upgrade/skill_publish_rename_test.go
@@ -0,0 +1,300 @@
+package upgrade
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
+)
+
+type mockSpecialInfo struct{}
+
+func (mockSpecialInfo) Name() string { return "source" }
+func (mockSpecialInfo) Size() int64 { return 0 }
+func (mockSpecialInfo) Mode() os.FileMode { return os.ModeNamedPipe }
+func (mockSpecialInfo) ModTime() time.Time { return time.Time{} }
+func (mockSpecialInfo) IsDir() bool { return false }
+func (mockSpecialInfo) Sys() interface{} { return nil }
+
+// TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for
+// filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs).
+// Those installs must still publish, and must still refuse to clobber an
+// occupied destination. The fallback uses atomic no-clobber primitives
+// (os.Mkdir for directories, os.Link for files) instead of a TOCTOU-prone
+// check-then-rename.
+func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) {
+ unsupported := testNoReplaceUnsupportedErrors()
+
+ t.Run("publishes a file when the filesystem rejects the flag", func(t *testing.T) {
+ for _, unsupportedErr := range unsupported {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr })
+ if _, err := renameSkillPathNoReplace(source, destination); err != nil {
+ t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err)
+ }
+ if data, err := os.ReadFile(destination); err != nil || string(data) != "payload" {
+ t.Fatalf("destination = %q, %v", data, err)
+ }
+ if _, err := os.Lstat(source); !os.IsNotExist(err) {
+ t.Fatalf("source must be gone, stat err=%v", err)
+ }
+ }
+ })
+
+ t.Run("publishes a directory when the filesystem rejects the flag", func(t *testing.T) {
+ for _, unsupportedErr := range unsupported {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr })
+ if _, err := renameSkillPathNoReplace(source, destination); err != nil {
+ t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err)
+ }
+ assertUpgradeSkillContent(t, destination, "payload")
+ if _, err := os.Lstat(source); !os.IsNotExist(err) {
+ t.Fatalf("source must be gone, stat err=%v", err)
+ }
+ }
+ })
+
+ t.Run("still refuses an occupied destination for files", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ for _, path := range []string{source, destination} {
+ if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !errors.Is(err, os.ErrExist) {
+ t.Fatalf("occupied destination must report ErrExist, got %v", err)
+ }
+ if data, readErr := os.ReadFile(destination); readErr != nil || string(data) != "destination" {
+ t.Fatalf("destination must be untouched, got %q, %v", data, readErr)
+ }
+ if _, statErr := os.Lstat(source); statErr != nil {
+ t.Fatalf("source must be preserved: %v", statErr)
+ }
+ })
+
+ t.Run("file link failure with non-EEXIST error surfaces", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("link error must surface, got %v", err)
+ }
+ if _, statErr := os.Lstat(source); statErr != nil {
+ t.Fatalf("source must be preserved: %v", statErr)
+ }
+ })
+
+ t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ seedUpgradeSkill(t, destination, "existing", false)
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !errors.Is(err, os.ErrExist) {
+ t.Fatalf("occupied destination must report ErrExist, got %v", err)
+ }
+ assertUpgradeSkillContent(t, destination, "existing")
+ assertUpgradeSkillContent(t, source, "payload")
+ })
+
+ t.Run("concurrent entry in the claimed directory aborts with the destination retained", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ testseam.Swap(t, &skillPathRename, func(_, dst string) error {
+ // A concurrent writer lands inside the empty claim before the
+ // slow path can take it; the fast-path rename then fails the
+ // way it would against a populated target.
+ if err := os.WriteFile(filepath.Join(dst, "foreign"), []byte("foreign"), 0o644); err != nil {
+ return err
+ }
+ return os.ErrExist
+ })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发写入") {
+ t.Fatalf("concurrent claim entry must abort with ErrExist, got %v", err)
+ }
+ if data, readErr := os.ReadFile(filepath.Join(destination, "foreign")); readErr != nil || string(data) != "foreign" {
+ t.Fatalf("foreign entry must be retained, got %q, %v", data, readErr)
+ }
+ assertUpgradeSkillContent(t, source, "payload")
+ })
+
+ t.Run("a genuine error is not retried", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ calls := 0
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error {
+ calls++
+ return os.ErrPermission
+ })
+ if _, err := renameSkillPathNoReplace(source, filepath.Join(dir, "destination")); !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("permission error must surface, got %v", err)
+ }
+ if calls != 1 {
+ t.Fatalf("atomic attempts = %d, want 1", calls)
+ }
+ })
+
+ t.Run("source stat failure surfaces without rename", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ destination := filepath.Join(dir, "destination")
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("stat error must surface, got %v", err)
+ }
+ if _, statErr := os.Lstat(source); statErr != nil {
+ t.Fatalf("source must be preserved: %v", statErr)
+ }
+ })
+
+ t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) {
+ crossDevice := testCrossDeviceError()
+ if isNoReplaceRenameUnsupported(crossDevice) {
+ t.Fatal("cross-device error must not be treated as an unsupported flag")
+ }
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ if err := os.MkdirAll(source, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("payload"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ destination := filepath.Join(dir, "backup", "destination")
+ original := skillPathRenameNoReplaceAtomic
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error {
+ if from == source {
+ return crossDevice
+ }
+ return original(from, to)
+ })
+ if err := moveSkillPathRecoverably(source, destination); err != nil {
+ t.Fatalf("cross-device move = %v", err)
+ }
+ if data, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(data) != "payload" {
+ t.Fatalf("cross-device destination = %q, %v", data, err)
+ }
+ })
+
+ t.Run("mkdir failure with non-EEXIST error surfaces", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !errors.Is(err, os.ErrPermission) {
+ t.Fatalf("mkdir error must surface, got %v", err)
+ }
+ assertUpgradeSkillContent(t, source, "payload")
+ })
+
+ t.Run("remove failure after rename failure surfaces", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") })
+ removeErr := errors.New("simulated remove failure")
+ testseam.Swap(t, &skillPathRemove, func(string) error { return removeErr })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !errors.Is(err, removeErr) {
+ t.Fatalf("remove error must surface, got %v", err)
+ }
+ if _, statErr := os.Lstat(destination); statErr != nil {
+ t.Fatalf("destination must still exist after remove failure: %v", statErr)
+ }
+ assertUpgradeSkillContent(t, source, "payload")
+ _ = os.RemoveAll(destination)
+ })
+
+ t.Run("fast-path rename failure alone publishes through child-move", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ // macOS refuses to rename over any directory, empty or not. The
+ // claim's verified emptiness — not the rename error — decides
+ // whether the child-move slow path may proceed.
+ testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
+ if _, err := renameSkillPathNoReplace(source, destination); err != nil {
+ t.Fatalf("child-move must publish despite rename failure, got %v", err)
+ }
+ assertUpgradeSkillContent(t, destination, "payload")
+ if _, err := os.Lstat(source); !os.IsNotExist(err) {
+ t.Fatalf("source must be gone, stat err=%v", err)
+ }
+ })
+
+ t.Run("directory publish when first rename replaces empty dir", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ originalRename := skillPathRename
+ testseam.Swap(t, &skillPathRename, func(src, dst string) error {
+ _ = os.Remove(dst)
+ return originalRename(src, dst)
+ })
+ if _, err := renameSkillPathNoReplace(source, destination); err != nil {
+ t.Fatalf("publish must succeed when rename replaces empty dir, got %v", err)
+ }
+ assertUpgradeSkillContent(t, destination, "payload")
+ })
+
+ t.Run("non-regular non-directory source fails safely", func(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return mockSpecialInfo{}, nil })
+ _, err := renameSkillPathNoReplace(source, destination)
+ if !errors.Is(err, errNoReplaceRenameUnsupported) {
+ t.Fatalf("unsupported error must surface for special files, got %v", err)
+ }
+ })
+}
diff --git a/internal/upgrade/skill_publish_rename_windows.go b/internal/upgrade/skill_publish_rename_windows.go
new file mode 100644
index 00000000..18927afe
--- /dev/null
+++ b/internal/upgrade/skill_publish_rename_windows.go
@@ -0,0 +1,22 @@
+//go:build windows
+
+package upgrade
+
+import "golang.org/x/sys/windows"
+
+func renameSkillPathNoReplaceAtomic(source, destination string) error {
+ sourcePtr, err := windows.UTF16PtrFromString(source)
+ if err != nil {
+ return err
+ }
+ destinationPtr, err := windows.UTF16PtrFromString(destination)
+ if err != nil {
+ return err
+ }
+ return windows.MoveFile(sourcePtr, destinationPtr)
+}
+
+// MoveFile without MOVEFILE_REPLACE_EXISTING already refuses an occupied
+// destination on every supported Windows version, so there is nothing to
+// degrade to.
+func isNoReplaceRenameUnsupported(error) bool { return false }
diff --git a/internal/upgrade/skill_publish_transaction_test.go b/internal/upgrade/skill_publish_transaction_test.go
index 02afa793..dcf1792e 100644
--- a/internal/upgrade/skill_publish_transaction_test.go
+++ b/internal/upgrade/skill_publish_transaction_test.go
@@ -92,10 +92,10 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing.
t.Run("backup failure restores earlier victims", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
- originalRename := upgradeRename
- testseam.Swap(t, &upgradeRename, func(src, dest string) error {
+ originalRename := skillPathRenameNoReplace
+ testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) {
if src == filepath.Join(base, "dingtalk-b") {
- return failure
+ return "", failure
}
return originalRename(src, dest)
})
@@ -107,14 +107,14 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing.
t.Run("publish failure rolls back complete set", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
- originalRename := upgradeRename
+ originalPublish := upgradePublishSkillPath
failed := false
- testseam.Swap(t, &upgradeRename, func(src, dest string) error {
+ testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
if !failed && strings.Contains(src, ".dws-upgrade-multi-") && filepath.Base(dest) == "dingtalk-b" {
failed = true
- return failure
+ return SkillPathPublication{}, failure
}
- return originalRename(src, dest)
+ return originalPublish(src, dest)
})
if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) {
t.Fatalf("publish failure = %v, want injected failure", err)
@@ -156,14 +156,14 @@ func TestCrossPlatformCoverageMonoUpgradeTransactionPreservesOldSet(t *testing.T
t.Run("publish failure rolls back mono and multi", func(t *testing.T) {
home, base, monoRoot := seed(t)
- originalRename := upgradeRename
+ originalPublish := upgradePublishSkillPath
failed := false
- testseam.Swap(t, &upgradeRename, func(src, dest string) error {
+ testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
if !failed && strings.Contains(src, ".dws-upgrade-mono-") && filepath.Base(dest) == "dws" {
failed = true
- return failure
+ return SkillPathPublication{}, failure
}
- return originalRename(src, dest)
+ return originalPublish(src, dest)
})
if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) {
t.Fatalf("publish failure = %v, want injected failure", err)
@@ -211,10 +211,10 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
})
t.Run("restore reports removal and directory failures", func(t *testing.T) {
- testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure })
+ testseam.Swap(t, &upgradeRollbackPublishedSkillPaths, func([]SkillPathPublication) error { return failure })
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return restoreFailure })
err := restoreSkillSet(
- []string{filepath.Join(t.TempDir(), "published")},
+ []SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "published")}},
[]backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}},
)
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
@@ -223,7 +223,7 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
})
t.Run("restore reports rename failure", func(t *testing.T) {
- testseam.Swap(t, &upgradeRename, func(string, string) error { return restoreFailure })
+ testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", restoreFailure })
err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}})
if !errors.Is(err, restoreFailure) {
t.Fatalf("restore rename error = %v", err)
@@ -236,13 +236,13 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
second := filepath.Join(home, "second")
seedUpgradeSkill(t, first, "first", false)
seedUpgradeSkill(t, second, "second", false)
- originalRename := upgradeRename
- testseam.Swap(t, &upgradeRename, func(src, dest string) error {
+ originalRename := skillPathRenameNoReplace
+ testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) {
switch {
case src == second:
- return failure
+ return "", failure
case strings.Contains(filepath.ToSlash(src), skillBackupSubdir):
- return restoreFailure
+ return "", restoreFailure
default:
return originalRename(src, dest)
}
@@ -259,16 +259,15 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T)
staged := filepath.Join(home, "skills", ".stage", "dws")
seedUpgradeSkill(t, old, "old", false)
seedUpgradeSkill(t, staged, "new", false)
- originalRename := upgradeRename
- testseam.Swap(t, &upgradeRename, func(src, dest string) error {
- switch {
- case src == staged:
- return failure
- case strings.Contains(filepath.ToSlash(src), skillBackupSubdir):
- return restoreFailure
- default:
- return originalRename(src, dest)
+ testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
+ return SkillPathPublication{}, failure
+ })
+ originalRename := skillPathRenameNoReplace
+ testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) {
+ if strings.Contains(filepath.ToSlash(src), skillBackupSubdir) {
+ return "", restoreFailure
}
+ return originalRename(src, dest)
})
err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old})
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
diff --git a/internal/upgrade/skill_publish_uncertain_test.go b/internal/upgrade/skill_publish_uncertain_test.go
new file mode 100644
index 00000000..f32c2707
--- /dev/null
+++ b/internal/upgrade/skill_publish_uncertain_test.go
@@ -0,0 +1,176 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package upgrade
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
+)
+
+// TestCrossPlatformCoverageUncertainPublicationState pins the contract behind
+// ErrSkillPathPublicationUncertain: a child-move publication whose mkdir
+// claim no longer describes the destination reports the uncertain sentinel
+// instead of guessing, keeps the destination in place, and stops upstream
+// retry paths (the linked-copy fallback) from displacing the concurrent
+// writer's object.
+func TestCrossPlatformCoverageUncertainPublicationState(t *testing.T) {
+
+ t.Run("destination replaced after child-move reports uncertain and is retained", func(t *testing.T) {
+ root := t.TempDir()
+ staged := filepath.Join(root, "staged")
+ destination := filepath.Join(root, "destination")
+ seedUpgradeSkill(t, staged, "payload", false)
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ // Force the child-move slow path on every platform (Linux would
+ // otherwise publish through the fast-path rename over the claim).
+ testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
+ // The identity seam simulates the wholesale replacement: the mkdir
+ // claim captures "claim", every later read of the destination
+ // reports the replacement's "foreign" identity. This keeps the
+ // test deterministic on platforms without a stable file identity.
+ destinationReads := 0
+ testseam.Swap(t, &skillPathFileIdentity, func(path string) string {
+ if path != destination {
+ return ""
+ }
+ destinationReads++
+ if destinationReads == 1 {
+ return "claim"
+ }
+ return "foreign"
+ })
+ _, err := PublishSkillPathNoReplace(staged, destination)
+ if !errors.Is(err, ErrSkillPathPublicationUncertain) {
+ t.Fatalf("replacement after child-move = %v, want uncertain sentinel", err)
+ }
+ if destinationReads != 2 {
+ t.Fatalf("destination identity reads = %d, want 2 (claim capture + ownership check)", destinationReads)
+ }
+ // Nothing was rolled back over the retained destination.
+ assertUpgradeSkillContent(t, destination, "payload")
+ })
+
+ t.Run("child-move reports the real claim identity where the platform supports it", func(t *testing.T) {
+ dir := t.TempDir()
+ if id := skillPathFileIdentity(dir); id == "" {
+ t.Skip("platform reports no stable file identity; the claim witness degrades to the fingerprint backstop")
+ }
+ source := filepath.Join(dir, "source")
+ destination := filepath.Join(dir, "destination")
+ seedUpgradeSkill(t, source, "payload", false)
+ testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported })
+ testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist })
+ claim, err := renameSkillPathNoReplace(source, destination)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if claim == "" {
+ t.Fatal("child-move must return the mkdir-claim identity on platforms that report one")
+ }
+ if live := skillPathFileIdentity(destination); live == "" || live != claim {
+ t.Fatalf("destination identity = %q, want the published claim %q", live, claim)
+ }
+ })
+
+ t.Run("uncertain publication passes through the staged-set transaction", func(t *testing.T) {
+ home := t.TempDir()
+ old := filepath.Join(home, "skills", "dws")
+ staged := filepath.Join(home, "skills", ".stage", "dws")
+ seedUpgradeSkill(t, old, "old", false)
+ seedUpgradeSkill(t, staged, "new", false)
+ testseam.Swap(t, &upgradePublishSkillPath, func(string, string) (SkillPathPublication, error) {
+ return SkillPathPublication{}, fmt.Errorf("并发写入: %w", ErrSkillPathPublicationUncertain)
+ })
+ err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old})
+ if !errors.Is(err, ErrSkillPathPublicationUncertain) {
+ t.Fatalf("staged-set publish = %v, want uncertain passthrough", err)
+ }
+ // The pre-publish victim is restored; the uncertain destination is
+ // never retried or overwritten.
+ assertUpgradeSkillContent(t, old, "old")
+ })
+
+ t.Run("mono copy fallback does not retry an uncertain destination", func(t *testing.T) {
+ home := t.TempDir()
+ base := filepath.Join(home, ".agents", "skills")
+ seedUpgradeSkill(t, filepath.Join(base, "dws"), "old mono", false)
+ if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
+ monoRoot := t.TempDir()
+ if err := os.WriteFile(filepath.Join(monoRoot, "SKILL.md"), []byte("new mono"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ linkedDest := filepath.Join(home, ".claude", "skills", "dws")
+ linkedAttempts := 0
+ originalPublish := upgradePublishSkillPath
+ testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
+ if dest == linkedDest {
+ linkedAttempts++
+ return SkillPathPublication{}, fmt.Errorf("%w:链接发布状态不确定", ErrSkillPathPublicationUncertain)
+ }
+ return originalPublish(src, dest)
+ })
+ result, err := upgradeMonoSkillLocations(home, monoRoot)
+ if err != nil {
+ t.Fatalf("mono upgrade = %v", err)
+ }
+ if linkedAttempts != 1 {
+ t.Fatalf("linked publish attempts = %d, want exactly 1 (the copy fallback must not retry an uncertain destination)", linkedAttempts)
+ }
+ recorded := false
+ for _, entry := range result.Results {
+ if entry.Dir == linkedDest && entry.Status == SkillDirFailed && errors.Is(entry.Err, ErrSkillPathPublicationUncertain) {
+ recorded = true
+ }
+ }
+ if !recorded {
+ t.Fatalf("results = %#v, want a recorded uncertain failure for %s", result.Results, linkedDest)
+ }
+ })
+
+ t.Run("multi copy fallback does not retry an uncertain destination", func(t *testing.T) {
+ home := t.TempDir()
+ base := filepath.Join(home, ".agents", "skills")
+ seedUpgradeSkill(t, filepath.Join(base, "dingtalk-a"), "old a", false)
+ if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
+ multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-a")
+ linkedBase := filepath.Join(home, ".claude", "skills")
+ linkedAttempts := 0
+ originalPublish := upgradePublishSkillPath
+ testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) {
+ if strings.HasPrefix(dest, linkedBase) {
+ linkedAttempts++
+ return SkillPathPublication{}, fmt.Errorf("%w:链接发布状态不确定", ErrSkillPathPublicationUncertain)
+ }
+ return originalPublish(src, dest)
+ })
+ result, err := upgradeMultiSkillLocations(home, multiRoot, []string{"dingtalk-a"})
+ if err != nil {
+ t.Fatalf("multi upgrade = %v", err)
+ }
+ if linkedAttempts != 1 {
+ t.Fatalf("linked publish attempts = %d, want exactly 1 (the copy fallback must not retry an uncertain destination)", linkedAttempts)
+ }
+ recorded := false
+ for _, entry := range result.Results {
+ if entry.Dir == linkedBase && entry.Status == SkillDirFailed && errors.Is(entry.Err, ErrSkillPathPublicationUncertain) {
+ recorded = true
+ }
+ }
+ if !recorded {
+ t.Fatalf("results = %#v, want a recorded uncertain failure for %s", result.Results, linkedBase)
+ }
+ })
+}
diff --git a/pkg/edition/default.go b/pkg/edition/default.go
index 5e0310b6..04e92642 100644
--- a/pkg/edition/default.go
+++ b/pkg/edition/default.go
@@ -43,11 +43,11 @@ func defaultHooks() *Hooks {
}
}
-// openSupplementServers returns helper-only MCP endpoints owned by the open
-// CLI. They are callable by explicit server ID but are deliberately excluded
-// from VisibleProducts, so no top-level product command is generated. They
-// stay separate from syncdata.StaticServers because only explicitly wired CLI
-// helpers may call them; they are not public MCP product surfaces.
+// openSupplementServers returns explicitly wired MCP endpoints owned by the
+// open CLI. They are callable by explicit server ID but are deliberately
+// excluded from VisibleProducts, so this hook never generates a top-level
+// product command. A public command using one of these endpoints must register
+// its Cobra tree explicitly via helpers.RegisterPublic.
func openSupplementServers() []ServerInfo {
return []ServerInfo{
{
@@ -60,6 +60,12 @@ func openSupplementServers() []ServerInfo {
Name: "钉钉白板",
Endpoint: "https://mcp-gw.dingtalk.com/server/whiteboard",
},
+ {
+ ID: "recruit",
+ Name: "钉钉招聘",
+ Endpoint: "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff",
+ Prefixes: []string{"recruit", "job"},
+ },
}
}
diff --git a/pkg/edition/edition_test.go b/pkg/edition/edition_test.go
index c581c714..7418836e 100644
--- a/pkg/edition/edition_test.go
+++ b/pkg/edition/edition_test.go
@@ -107,7 +107,12 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
servers := openSupplementServers()
foundMCPMeta := false
foundWhiteboard := false
+ foundRecruit := false
for _, server := range servers {
+ if server.ID == "recruit" {
+ foundRecruit = server.Endpoint == "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff" &&
+ len(server.Prefixes) == 2 && server.Prefixes[0] == "recruit" && server.Prefixes[1] == "job"
+ }
if server.ID == "whiteboard" {
foundWhiteboard = server.Endpoint == "https://mcp-gw.dingtalk.com/server/whiteboard"
}
@@ -128,4 +133,7 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
if !foundWhiteboard {
t.Fatal("openSupplementServers() missing helper-only whiteboard endpoint")
}
+ if !foundRecruit {
+ t.Fatal("openSupplementServers() missing explicitly wired recruit endpoint")
+ }
}
diff --git a/scripts/ci/run-app-race-tests.sh b/scripts/ci/run-app-race-tests.sh
index 3f586dd0..0183762c 100755
--- a/scripts/ci/run-app-race-tests.sh
+++ b/scripts/ci/run-app-race-tests.sh
@@ -14,7 +14,7 @@ usage() {
# Single source of truth for the partition set. CI runs one job per partition and
# pins its shard names to this list, so a name that appears here without a
# dispatch entry below fails closed rather than silently skipping tests.
-APP_PARTITIONS='schema a-b c d-r s-z-example-fuzz'
+APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz'
mode="${1:-}"
partition=""
@@ -66,7 +66,10 @@ fi
schema_count=0
ab_count=0
-c_count=0
+cal_count=0
+cmr_count=0
+csz_count=0
+cother_count=0
dr_count=0
sz_count=0
unmatched_count=0
@@ -75,7 +78,10 @@ while IFS= read -r test_name; do
case "$test_name" in
Test*Schema*) schema_count=$((schema_count + 1)) ;;
Test[A-B]*) ab_count=$((ab_count + 1)) ;;
- TestC*) c_count=$((c_count + 1)) ;;
+ TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;;
+ TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;;
+ TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;;
+ TestC*) cother_count=$((cother_count + 1)) ;;
Test[D-R]*) dr_count=$((dr_count + 1)) ;;
Test[S-Z]*|Example*|Fuzz*) sz_count=$((sz_count + 1)) ;;
*)
@@ -96,7 +102,10 @@ classified=''
for spec in \
"schema:$schema_count" \
"a-b:$ab_count" \
- "c:$c_count" \
+ "c-a-l:$cal_count" \
+ "c-m-r:$cmr_count" \
+ "c-s-z:$csz_count" \
+ "c-other:$cother_count" \
"d-r:$dr_count" \
"s-z-example-fuzz:$sz_count"
do
@@ -133,14 +142,14 @@ for name in $classified; do
done
total_count="$(wc -l < "$tests" | tr -d ' ')"
-assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count))
+assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count))
if [ "$assigned_count" -ne "$total_count" ]; then
printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2
exit 1
fi
-printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c=%s d-r=%s s-z-example-fuzz=%s\n' \
- "$total_count" "$schema_count" "$ab_count" "$c_count" "$dr_count" "$sz_count"
+printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
+ "$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
if [ "$mode" = "verify" ]; then
exit 0
@@ -198,7 +207,10 @@ run_named_partition() {
case "$1" in
schema) run_partition schema no-race "$schema_pattern" ;;
a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;;
- c) run_partition c race '^TestC' "$schema_pattern" ;;
+ c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;;
+ c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;;
+ c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;;
+ c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;;
d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;;
s-z-example-fuzz)
run_partition s-z-example-fuzz race '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern"
diff --git a/scripts/dev/test-multi-profile-e2e.sh b/scripts/dev/test-multi-profile-e2e.sh
index 50cba792..d24371b7 100755
--- a/scripts/dev/test-multi-profile-e2e.sh
+++ b/scripts/dev/test-multi-profile-e2e.sh
@@ -638,13 +638,13 @@ MONO_HOME="$WORKDIR/home-mono"
MULTI_HOME="$WORKDIR/home-multi"
mkdir -p "$MONO_HOME" "$MULTI_HOME"
capture "$OUT_DIR/skill-mono-setup.txt" env HOME="$MONO_HOME" "$BIN" skill setup --mode mono --target codex --source "$ROOT" --yes
-expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "corpId:userId"
-expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
-expect_contains "$MONO_HOME/.codex/skills/dws/references/global-reference.md" "userId/userName"
+expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "corpId:userId"
+expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
+expect_contains "$MONO_HOME/.agents/skills/dws/references/global-reference.md" "userId/userName"
capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes
-expect_contains "$MULTI_HOME/.codex/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
-expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "corpId:userId"
-expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "isOrgCurrent"
+expect_contains "$MULTI_HOME/.agents/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号"
+expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "corpId:userId"
+expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "isOrgCurrent"
log "verifying empty profile list"
capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json
diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py
index 0b37827d..de3a1f2d 100644
--- a/scripts/gen_shortcut_public_catalog.py
+++ b/scripts/gen_shortcut_public_catalog.py
@@ -27,6 +27,11 @@ SEMANTIC_PATHS = [
ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_calendar.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json",
+ ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json",
+ ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json",
+ ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json",
+ ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json",
+ ROOT / "internal" / "shortcut" / "semantic_catalog_report.json",
]
diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1
index 894e4f5e..686db8e1 100644
--- a/scripts/install-devapp.ps1
+++ b/scripts/install-devapp.ps1
@@ -22,9 +22,529 @@ $InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Pat
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
$SkillName = "dingtalk-misc"
+# vercel-labs/skills agents.ts (c6f69c6). Each row is
+# id|universal|effective global directory; '-' means no global directory.
+$AgentRegistryRows = @(
+ "aider-desk|0|.aider-desk\skills", "amp|1|.config\agents\skills",
+ "antigravity|1|.gemini\antigravity\skills", "antigravity-cli|1|.gemini\antigravity-cli\skills",
+ "astrbot|0|.astrbot\data\skills", "autohand-code|0|.autohand\skills", "augment|0|.augment\skills",
+ "bob|0|.bob\skills", "claude-code|0|.claude\skills", "openclaw|0|.openclaw\skills",
+ "cline|1|.agents\skills", "codearts-agent|0|.codeartsdoer\skills", "codebuddy|0|.codebuddy\skills",
+ "codemaker|0|.codemaker\skills", "codestudio|0|.codestudio\skills", "codex|1|.codex\skills",
+ "command-code|0|.commandcode\skills", "continue|0|.continue\skills", "cortex|0|.snowflake\cortex\skills",
+ "crush|0|.config\crush\skills", "cursor|1|.cursor\skills", "deepagents|1|.deepagents\agent\skills",
+ "devin|0|.config\devin\skills", "dexto|1|.agents\skills", "droid|0|.factory\skills", "eve|0|-",
+ "firebender|1|.firebender\skills", "forgecode|0|.forge\skills", "gemini-cli|1|.gemini\skills",
+ "github-copilot|1|.copilot\skills", "goose|0|.config\goose\skills", "grok|0|.grok\skills",
+ "hermes-agent|0|.hermes\skills", "inference-sh|0|.inferencesh\skills", "jazz|0|.jazz\skills",
+ "junie|0|.junie\skills", "iflow-cli|0|.iflow\skills", "kilo|0|.kilocode\skills",
+ "kimchi|0|.config\kimchi\harness\skills", "kimi-code-cli|1|.agents\skills", "kiro-cli|0|.kiro\skills",
+ "kode|0|.kode\skills", "lingma|0|.lingma\skills", "loaf|1|.agents\skills", "mcpjam|0|.mcpjam\skills",
+ "minimax-code|0|.minimax\skills", "mistral-vibe|0|.vibe\skills", "moxby|0|.moxby\skills",
+ "mux|0|.mux\skills", "opencode|1|.config\opencode\skills", "openhands|0|.openhands\skills",
+ "ona|0|.ona\skills", "pi|0|.pi\agent\skills", "qoder|0|.qoder\skills", "qoder-cn|0|.qoder-cn\skills",
+ "qwen-code|0|.qwen\skills", "replit|1|.config\agents\skills", "reasonix|0|.reasonix\skills",
+ "rovodev|0|.rovodev\skills", "roo|0|.roo\skills", "tabnine-cli|0|.tabnine\agent\skills",
+ "terramind|0|.terramind\skills", "tinycloud|0|.tinycloud\skills", "trae|0|.trae\skills",
+ "trae-cn|0|.trae-cn\skills", "warp|1|.agents\skills", "windsurf|0|.codeium\windsurf\skills",
+ "zed|1|.agents\skills", "zcode|0|.zcode\skills", "zencoder|0|.zencoder\skills",
+ "zenflow|0|.zencoder\skills", "neovate|0|.neovate\skills", "pochi|0|.pochi\skills",
+ "promptscript|1|-", "adal|0|.adal\skills", "universal|1|.config\agents\skills"
+)
+$LegacyAgentCleanupRows = @(
+ "dws-qoderwork|0|.qoderwork\skills", "dws-legacy-github|1|.github\skills",
+ "dws-legacy-amp|1|.amp\skills", "dws-legacy-cline|1|.cline\skills",
+ "dws-legacy-windsurf|1|.windsurf\skills"
+)
+
function Say($m) { Write-Host " $m" }
function Die($m) { Write-Host " X $m" -ForegroundColor Red; exit 1 }
+function Resolve-AgentBase([string]$row) {
+ $parts = $row.Split('|')
+ $id = $parts[0]
+ if ($parts[2] -eq "-") { return $null }
+ switch ($id) {
+ "autohand-code" { if ($env:AUTOHAND_HOME) { return Join-Path $env:AUTOHAND_HOME "skills" } }
+ "claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return Join-Path $env:CLAUDE_CONFIG_DIR "skills" } }
+ "codex" { if ($env:CODEX_HOME) { return Join-Path $env:CODEX_HOME "skills" } }
+ "grok" { if ($env:GROK_HOME) { return Join-Path $env:GROK_HOME "skills" } }
+ "hermes-agent" { if ($env:HERMES_HOME) { return Join-Path $env:HERMES_HOME "skills" } }
+ "mistral-vibe" { if ($env:VIBE_HOME) { return Join-Path $env:VIBE_HOME "skills" } }
+ "openclaw" {
+ foreach ($legacy in @(".openclaw", ".clawdbot", ".moltbot")) {
+ $candidate = Join-Path $HOME $legacy
+ if (Test-Path $candidate -PathType Container) { return Join-Path $candidate "skills" }
+ }
+ }
+ { $_ -in @("amp", "replit", "universal") } {
+ $xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" }
+ return Join-Path $xdg "agents\skills"
+ }
+ { $_ -in @("crush", "devin", "goose", "kimchi", "opencode") } {
+ $xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" }
+ $child = switch ($id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; "kimchi" { "kimchi\harness\skills" }; default { "opencode\skills" } }
+ return Join-Path $xdg $child
+ }
+ }
+ return Join-Path $HOME $parts[2]
+}
+
+function Test-AgentBaseDetected([string]$id, [string]$base) {
+ $parent = Split-Path $base -Parent
+ switch ($id) {
+ "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
+ "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
+ "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) }
+ "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) }
+ default { return Test-Path $parent -PathType Container }
+ }
+}
+
+function Test-PathLexically([string]$path) {
+ try { Get-Item -LiteralPath $path -Force -ErrorAction Stop | Out-Null; return $true } catch {}
+ $parent = Split-Path $path -Parent; $leaf = Split-Path $path -Leaf
+ if (!(Test-Path $parent -PathType Container)) { return $false }
+ return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -eq $leaf } | Select-Object -First 1)
+}
+
+function Move-DevSkillPath([string]$Source, [string]$Destination) {
+ if (Test-PathLexically $Destination) { throw "Skill move destination already exists: $Destination" }
+ $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
+ if ($sourceItem.PSIsContainer) {
+ [System.IO.Directory]::Move($Source, $Destination)
+ } else {
+ [System.IO.File]::Copy($Source, $Destination, $false)
+ try {
+ Remove-DevSkillPathLexically $Source
+ } catch {
+ $removeErr = $_
+ try {
+ Remove-DevSkillPathLexically $Destination
+ } catch {
+ throw "Skill move state uncertain; source $Source and dest $Destination retained: $removeErr; retract failed: $_"
+ }
+ throw $removeErr
+ }
+ }
+}
+
+function Test-DevCrossDeviceMoveError([System.Management.Automation.ErrorRecord]$Record) {
+ $exception = $Record.Exception
+ while ($null -ne $exception) {
+ if (($exception.HResult -band 0xffff) -eq 17) { return $true }
+ $exception = $exception.InnerException
+ }
+ return $false
+}
+
+function Copy-DevSkillMetadata($SourceItem, [string]$Destination) {
+ (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes
+ $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
+ if ($nativeWindows) {
+ Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop
+ } else {
+ $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName)
+ [System.IO.File]::SetUnixFileMode($Destination, $mode)
+ }
+}
+
+function Get-DevSkillPermissionFingerprint([string]$Path) {
+ $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
+ if ($nativeWindows) { return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl }
+ return [string][System.IO.File]::GetUnixFileMode($Path)
+}
+
+function Copy-DevSkillPathLexically([string]$Source, [string]$Destination) {
+ $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
+ if ($item.LinkType) {
+ $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" }
+ New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null
+ return
+ }
+ if ($item.PSIsContainer) {
+ New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null
+ foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) {
+ Copy-DevSkillPathLexically $child.FullName (Join-Path $Destination $child.Name)
+ }
+ Copy-DevSkillMetadata $item $Destination
+ return
+ }
+ if ($item -isnot [System.IO.FileInfo]) { throw "unsupported special Skill path $Source" }
+ [System.IO.File]::Copy($Source, $Destination, $false)
+ Copy-DevSkillMetadata $item $Destination
+}
+
+function Assert-DevSkillPathCopy([string]$Source, [string]$Destination) {
+ $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
+ $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop
+ if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or
+ $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) {
+ throw "Skill path type mismatch: $Source != $Destination"
+ }
+ if ($sourceItem.LinkType) {
+ if ($sourceItem.LinkType -ne $destinationItem.LinkType -or
+ ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) {
+ throw "Skill link target mismatch: $Source != $Destination"
+ }
+ return
+ }
+ if ((Get-DevSkillPermissionFingerprint $Source) -ne (Get-DevSkillPermissionFingerprint $Destination)) {
+ throw "Skill path permissions mismatch: $Source != $Destination"
+ }
+ if ($sourceItem.PSIsContainer) {
+ $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name)
+ $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name)
+ if ($sourceChildren.Count -ne $destinationChildren.Count) { throw "Skill directory entries mismatch: $Source != $Destination" }
+ for ($i = 0; $i -lt $sourceChildren.Count; $i++) {
+ if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { throw "Skill directory entries mismatch: $Source != $Destination" }
+ Assert-DevSkillPathCopy $sourceChildren[$i].FullName $destinationChildren[$i].FullName
+ }
+ return
+ }
+ if ($sourceItem.Length -ne $destinationItem.Length) { throw "Skill file size mismatch: $Source != $Destination" }
+ if ((Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash -ne
+ (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash) {
+ throw "Skill file digest mismatch: $Source != $Destination"
+ }
+}
+
+function Remove-DevSkillPathLexically([string]$Path) {
+ $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -and !$item.LinkType) {
+ Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
+ } else {
+ Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
+ }
+}
+
+# Removes a staging directory that may hold junction/symlink children. Link
+# children must be removed non-recursively first: Windows PowerShell 5.1 can
+# follow reparse points during Remove-Item -Recurse and would delete the
+# canonical store's contents through the staged link.
+function Remove-DevLinkStageRoot([string]$StageRoot) {
+ if (!(Test-Path -LiteralPath $StageRoot)) { return $true }
+ $ok = $true
+ foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) {
+ try { Remove-DevSkillPathLexically $child.FullName } catch { $ok = $false }
+ }
+ try { Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop } catch { $ok = $false }
+ return $ok
+}
+
+function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) {
+ if (Test-PathLexically $Destination) { throw "move destination already exists: $Destination" }
+ $destinationParent = Split-Path $Destination -Parent
+ New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null
+ try {
+ Move-DevSkillPath $Source $Destination
+ return
+ } catch {
+ if (!(Test-DevCrossDeviceMoveError $_)) { throw }
+ }
+ $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N"))
+ $stage = Join-Path $stageRoot "payload"
+ New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
+ $published = $false
+ try {
+ Copy-DevSkillPathLexically $Source $stage
+ Assert-DevSkillPathCopy $Source $stage
+ Move-DevSkillPath $stage $Destination
+ $published = $true
+ try {
+ Assert-DevSkillPathCopy $Source $Destination
+ if (!(Remove-DevLinkStageRoot $stageRoot)) {
+ throw "Skill staging cleanup failed: $stageRoot"
+ }
+ } catch {
+ $postErr = $_
+ try {
+ # Retract only after proving the destination is still this
+ # transaction's copy (mirrors install.ps1's
+ # Remove-PublishedSkillPathSafely retract): a path-blind
+ # lexical removal would delete a concurrent replacement.
+ if (Test-PathLexically $Destination) {
+ Remove-VerifiedDevSkillPublication $Destination $Source ""
+ }
+ } catch {
+ throw "Skill move state uncertain: $postErr; failed to retract $Destination`: $_; source $Source and dest $Destination retained"
+ }
+ throw "Skill move failed, dest retracted, source retained ${Source}: $postErr"
+ }
+ try { Remove-DevSkillPathLexically $Source } catch {
+ throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_"
+ }
+ if (Test-PathLexically $Source) { throw "Skill source still exists; both retained ($Source, $Destination)" }
+ } catch {
+ $failure = $_
+ if (Test-Path -LiteralPath $stageRoot) {
+ if (!(Remove-DevLinkStageRoot $stageRoot)) {
+ if (-not $published) {
+ throw "$failure; cross-device Skill staging cleanup failed $stageRoot (backup and original retained)"
+ }
+ }
+ }
+ throw $failure
+ }
+}
+
+# Get-DevSkillBackupName encodes the HOME-relative path of a backed-up Skill
+# directory ('.codex\skills\dingtalk-misc' → '.codex-skills-dingtalk-misc') so
+# copies retired from different Agent roots stay distinguishable. Mirrors
+# build/npm/install.js and internal/upgrade/paths.go; paths outside HOME fall
+# back to the bare leaf.
+function Get-DevSkillBackupName([string]$Dir) {
+ $leaf = Split-Path $Dir -Leaf
+ try {
+ $full = [System.IO.Path]::GetFullPath($Dir)
+ $root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/'))
+ } catch { return $leaf }
+ if ([string]::IsNullOrWhiteSpace($root)) { return $leaf }
+ foreach ($sep in @('\', '/')) {
+ $prefix = $root + $sep
+ if ($full.Length -gt $prefix.Length -and
+ $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
+ $rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/'))
+ if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') }
+ }
+ }
+ return $leaf
+}
+
+# Keep $HOME\.dws\skill-backups bounded to the newest stamped roots, matching
+# skillBackupKeep / pruneSkillBackups in internal/upgrade/paths.go. Roots this
+# run created are never pruned: a rollback still needs them.
+$DevSkillBackupKeep = 5
+$script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+
+# Ownership marker: every stamp root DWS creates carries .dws-skill-backup
+# with exactly "dws skill backup v1" + LF — the same bytes internal/upgrade/
+# paths.go, install.sh, and build/npm/install.js write. A stamp-shaped name
+# alone is not ownership proof, so pruning only deletes directories whose
+# marker content verifies.
+$DevSkillBackupMarkerFile = ".dws-skill-backup"
+$DevSkillBackupMarkerBody = "dws skill backup v1"
+
+# Write-DevSkillBackupMarker stamps a freshly created stamp root as DWS-owned.
+# [IO.File]::WriteAllText pins the exact LF-terminated bytes (Set-Content
+# would append a platform newline on Windows PowerShell 5.1).
+function Write-DevSkillBackupMarker([string]$Root) {
+ [System.IO.File]::WriteAllText(
+ (Join-Path $Root $DevSkillBackupMarkerFile),
+ "$DevSkillBackupMarkerBody`n",
+ [System.Text.UTF8Encoding]::new($false))
+}
+
+# Test-DevSkillBackupMarker reports whether a stamp root carries the ownership
+# marker. The check normalizes CRLF→LF and drops trailing newlines before
+# comparing, so this surface accepts every surface's exact-LF bytes (writer
+# and checker agree); any other content, or a missing/unreadable marker,
+# means foreign data.
+function Test-DevSkillBackupMarker([string]$Dir) {
+ try {
+ $marker = Join-Path $Dir $DevSkillBackupMarkerFile
+ if (![System.IO.File]::Exists($marker)) { return $false }
+ $body = [System.IO.File]::ReadAllText($marker).Replace("`r`n", "`n").TrimEnd("`r", "`n")
+ return ($body -eq $DevSkillBackupMarkerBody)
+ } catch {
+ return $false
+ }
+}
+
+# Removes a whole stamp root child-first without ever following a reparse
+# point: link children are deleted non-recursively, real directories are
+# recursed the same way, and only an emptied directory is removed. Backup
+# trees can contain junctions/symlinks (victims are collected before the
+# physical-equality filter), and Windows PowerShell 5.1 can follow reparse
+# points during Remove-Item -Recurse — the invariant Remove-DevLinkStageRoot
+# enforces for staging roots, applied at every depth here.
+function Remove-DevSkillBackupTreeLexically([string]$Path) {
+ $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -and !$item.LinkType) {
+ foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) {
+ Remove-DevSkillBackupTreeLexically $child.FullName
+ }
+ }
+ Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
+}
+
+function Remove-OldDevSkillBackups {
+ $root = Join-Path $HOME ".dws\skill-backups"
+ if (!(Test-Path -LiteralPath $root -PathType Container)) { return }
+ # Only directories whose names match the DWS backup stamp format (UTC
+ # yyyyMMdd-HHmmss, optional -N collision suffix) AND whose ownership
+ # marker verifies are candidates; anything else is foreign data —
+ # preserved and never counted against $DevSkillBackupKeep.
+ $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' -and (Test-DevSkillBackupMarker $_.FullName) } |
+ Sort-Object -Property Name)
+ $excess = $dirs.Count - $DevSkillBackupKeep
+ foreach ($dir in $dirs) {
+ if ($excess -le 0) { break }
+ if ($script:DevSkillBackupRootsThisRun.Contains($dir.FullName)) { continue }
+ try { Remove-DevSkillBackupTreeLexically $dir.FullName } catch { }
+ $excess--
+ }
+}
+
+function Backup-DevSkill([string]$path, [ref]$BackupPath) {
+ if ($null -ne $BackupPath) { $BackupPath.Value = "" }
+ if (!(Test-PathLexically $path)) { return }
+ $backupBase = Join-Path $HOME ".dws\skill-backups"
+ $stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
+ $name = Get-DevSkillBackupName $path
+ $backupRoot = Join-Path $backupBase $stamp
+ $target = Join-Path $backupRoot $name
+ $i = 1
+ while (Test-PathLexically $target) {
+ $backupRoot = Join-Path $backupBase "$stamp-$i"
+ $target = Join-Path $backupRoot $name
+ $i++
+ if ($i -gt 1000) { throw "备份目录冲突无法解决,保留原目录: $path" }
+ }
+ New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null
+ # Stamp ownership immediately after creating the stamp root and before
+ # any skill directory moves into it, so an interrupted backup can never
+ # leave an unmarked (never-prunable) stamp behind.
+ try {
+ Write-DevSkillBackupMarker $backupRoot
+ } catch {
+ # The removal stays non-recursive so a pre-existing non-empty root
+ # (foreign data) is never destroyed; a failed marker write must not
+ # leave an empty unowned stamp root behind either.
+ Remove-Item -LiteralPath $backupRoot -Force -ErrorAction SilentlyContinue
+ throw
+ }
+ Move-DevSkillPathRecoverably $path $target
+ if ($null -ne $BackupPath) { $BackupPath.Value = $target }
+ try {
+ $script:DevSkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($backupRoot)) | Out-Null
+ Remove-OldDevSkillBackups
+ } catch {
+ Say "⚠️ 旧备份清理失败(备份本身已成功): $($_.Exception.Message)"
+ }
+}
+
+function Get-DevSkillLinkSignature([string]$Path) {
+ try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { return $null }
+ if (!$item.LinkType) { return $null }
+ return ([string]$item.LinkType + "`0" + ((@($item.Target) | ForEach-Object { [string]$_ }) -join "`0"))
+}
+
+function New-DevRollbackFailure([string]$Message) {
+ $failure = [System.InvalidOperationException]::new($Message)
+ $failure.Data["DWSRollbackFailed"] = $true
+ return $failure
+}
+
+function Remove-VerifiedDevSkillPublication(
+ [string]$Destination,
+ [string]$PublishedSource,
+ [string]$PublishedLinkSignature
+) {
+ $parent = Split-Path $Destination -Parent
+ $quarantine = Join-Path $parent (".dws-dev-rollback-" + [guid]::NewGuid().ToString("N"))
+ Move-DevSkillPath $Destination $quarantine
+ try {
+ if (![string]::IsNullOrWhiteSpace($PublishedLinkSignature)) {
+ if ((Get-DevSkillLinkSignature $quarantine) -ne $PublishedLinkSignature) {
+ throw "published Skill link was replaced by another process: $Destination"
+ }
+ } elseif (![string]::IsNullOrWhiteSpace($PublishedSource)) {
+ Assert-DevSkillPathCopy $PublishedSource $quarantine
+ } else {
+ throw "Skill restore destination is occupied by another process: $Destination"
+ }
+ Remove-DevSkillPathLexically $quarantine
+ } catch {
+ $failure = $_
+ if (Test-PathLexically $quarantine) {
+ try {
+ if (Test-PathLexically $Destination) { throw "original path is occupied" }
+ Move-DevSkillPath $quarantine $Destination
+ } catch {
+ throw "$failure; concurrent object retained at $quarantine`: $_"
+ }
+ }
+ throw $failure
+ }
+}
+
+function Restore-DevSkillBackup(
+ [string]$Destination,
+ [string]$Backup,
+ [string]$PublishedSource = "",
+ [string]$PublishedLinkSignature = ""
+) {
+ if (Test-PathLexically $Destination) {
+ Remove-VerifiedDevSkillPublication $Destination $PublishedSource $PublishedLinkSignature
+ }
+ if (![string]::IsNullOrWhiteSpace($Backup)) {
+ Move-DevSkillPathRecoverably $Backup $Destination
+ }
+}
+
+function Publish-DevSkillCopy([string]$Source, [string]$Destination) {
+ $parent = Split-Path $Destination -Parent
+ New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null
+ $stageRoot = Join-Path $parent (".dws-dev-copy-" + [guid]::NewGuid().ToString("N"))
+ $stage = Join-Path $stageRoot "payload"
+ $backup = ""
+ $publishedSource = ""
+ New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
+ try {
+ Copy-DevSkillPathLexically $Source $stage
+ Assert-DevSkillPathCopy $Source $stage
+ Backup-DevSkill $Destination ([ref]$backup)
+ try {
+ Move-DevSkillPath $stage $Destination
+ $publishedSource = $Source
+ Assert-DevSkillPathCopy $Source $Destination
+ } catch {
+ $publishFailure = $_
+ try { Restore-DevSkillBackup $Destination $backup $publishedSource "" } catch {
+ throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_")
+ }
+ throw $publishFailure
+ }
+ } finally {
+ Remove-DevLinkStageRoot $stageRoot | Out-Null
+ }
+}
+
+function Publish-DevSkillJunction([string]$Target, [string]$Destination) {
+ $parent = Split-Path $Destination -Parent
+ New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null
+ $stageRoot = Join-Path $parent (".dws-dev-link-" + [guid]::NewGuid().ToString("N"))
+ $stage = Join-Path $stageRoot "payload"
+ $backup = ""
+ $publishedSignature = ""
+ New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
+ try {
+ New-Item -ItemType Junction -Path $stage -Target ([System.IO.Path]::GetFullPath($Target)) -ErrorAction Stop | Out-Null
+ Backup-DevSkill $Destination ([ref]$backup)
+ try {
+ Move-DevSkillPath $stage $Destination
+ $published = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop
+ if (!$published.LinkType) { throw "published Skill path is not a junction: $Destination" }
+ $publishedSignature = Get-DevSkillLinkSignature $Destination
+ if ([string]::IsNullOrEmpty($publishedSignature)) { throw "published Skill link identity is unavailable: $Destination" }
+ } catch {
+ $publishFailure = $_
+ try { Restore-DevSkillBackup $Destination $backup "" $publishedSignature } catch {
+ throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_")
+ }
+ throw $publishFailure
+ }
+ } finally {
+ # The staged payload is a junction to the canonical store; it must be
+ # removed lexically or Windows PowerShell 5.1 recursion could delete
+ # canonical content through the reparse point.
+ Remove-DevLinkStageRoot $stageRoot | Out-Null
+ }
+}
+
function Get-Arch {
if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH }
try {
@@ -40,6 +560,26 @@ function Get-Arch {
}
}
+function Assert-DevReleaseAssetChecksum([string]$AssetPath, [string]$AssetName, [string]$TempDir) {
+ $checksums = Join-Path $TempDir "checksums.txt"
+ if (!(Test-Path -LiteralPath $checksums -PathType Leaf)) {
+ try {
+ Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/checksums.txt" `
+ -OutFile $checksums -UseBasicParsing -ErrorAction Stop
+ } catch {
+ Die "Could not download checksums.txt; refusing unverified release assets."
+ }
+ }
+ $line = Get-Content -LiteralPath $checksums | Where-Object {
+ $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$"
+ } | Select-Object -First 1
+ if (-not $line) { Die "$AssetName is missing from checksums.txt." }
+ $expected = ($line -split '\s+')[0].ToLowerInvariant()
+ $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
+ if ($actual -ne $expected) { Die "SHA256 checksum mismatch for $AssetName." }
+ Say "SHA256 checksum verified: $AssetName"
+}
+
# Read the releases list (newest first) and take the top tag, so this also works
# if a release is ever published as a prerelease (which /releases/latest skips).
if (-not $Version) {
@@ -70,6 +610,7 @@ try {
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/$asset" `
-OutFile $zip -UseBasicParsing
} catch { Die "Binary download failed - does release $Version have $asset?" }
+Assert-DevReleaseAssetChecksum $zip $asset $tmp
Expand-Archive -Path $zip -DestinationPath $tmp -Force
$exe = Get-ChildItem -Path $tmp -Recurse -Filter "dws.exe" | Select-Object -First 1
@@ -84,6 +625,7 @@ if (-not $NoSkills) {
$skzip = Join-Path $tmp "dws-skills.zip"
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" `
-OutFile $skzip -UseBasicParsing
+ Assert-DevReleaseAssetChecksum $skzip "dws-skills.zip" $tmp
$skdir = Join-Path $tmp "sk"
Expand-Archive -Path $skzip -DestinationPath $skdir -Force
@@ -95,33 +637,63 @@ if (-not $NoSkills) {
if ($src) {
# cache so `dws skill setup --mode multi` can find a source later
$cache = Join-Path $HOME ".dws\skills\multi\$SkillName"
- if (Test-Path $cache) { Remove-Item -Recurse -Force $cache }
- New-Item -ItemType Directory -Path $cache -Force | Out-Null
- Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force
+ Publish-DevSkillCopy $src $cache
- $agentDirs = @(
- ".agents\skills", ".claude\skills", ".cursor\skills", ".qoder\skills", ".qoderwork\skills",
- ".gemini\skills", ".codex\skills", ".github\skills", ".windsurf\skills", ".augment\skills",
- ".cline\skills", ".amp\skills", ".kiro\skills", ".trae\skills", ".openclaw\skills",
- ".hermes\skills", ".config\opencode\skills"
- )
- $installed = 0; $idx = 0
- foreach ($d in $agentDirs) {
- $base = Join-Path $HOME $d
- $parent = Split-Path $base -Parent
- if ($idx -gt 0 -and -not (Test-Path $parent)) { $idx++; continue }
+ $canonicalBase = Join-Path $HOME ".agents\skills"
+ $canonical = Join-Path $canonicalBase $SkillName
+ Publish-DevSkillCopy $src $canonical
+ $installed = 1
+ $failed = 0
+ $retireFailed = 0
+ $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) {
+ $parts = $row.Split('|'); $universal = $parts[1] -eq "1"
+ $base = Resolve-AgentBase $row
+ if ([string]::IsNullOrWhiteSpace($base)) { continue }
+ $baseKey = [System.IO.Path]::GetFullPath($base).TrimEnd([char[]]@('\', '/'))
+ if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue }
+ if (!(Test-AgentBaseDetected $parts[0] $base)) { continue }
$dest = Join-Path $base $SkillName
- if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
- New-Item -ItemType Directory -Path $dest -Force | Out-Null
- Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force
- $installed++; $idx++
+ if ($universal) {
+ # Cleanup-only migration: universal Agents read the
+ # canonical store directly, so nothing is installed here. A
+ # retire failure only leaves an obsolete copy behind and
+ # must never fail an otherwise complete install.
+ try { Backup-DevSkill $dest } catch {
+ Say "⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): $dest ($($_.Exception.Message))"
+ $retireFailed++
+ }
+ continue
+ }
+ # Per-agent degrade like install.sh: a failed link→copy fallback
+ # skips that agent loudly instead of aborting the whole loop.
+ try {
+ New-Item -ItemType Directory -Path $base -Force -ErrorAction Stop | Out-Null
+ try { Publish-DevSkillJunction $canonical $dest }
+ catch {
+ # A failed rollback leaves the old backup as the only
+ # trusted copy. Do not run a second publisher over that
+ # state; surface the failure and continue other agents.
+ if ($_.Exception.Data["DWSRollbackFailed"]) { throw }
+ Publish-DevSkillCopy $canonical $dest
+ }
+ } catch {
+ Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))"
+ $failed++
+ continue
+ }
+ $installed++
+ }
+ if ($failed -gt 0) { Die "有 $failed 个 Agent 目标安装 $SkillName 失败(其余目标已安装)" }
+ if ($retireFailed -gt 0) {
+ Say "⚠️ 有 $retireFailed 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)"
}
Say "Skill dingtalk-misc -> $installed agent dir(s)"
} else {
Say "(dingtalk-misc not found in skills bundle; skipped)"
}
} catch {
- Say "(skill install skipped: $($_.Exception.Message))"
+ Die "Skill install failed: $($_.Exception.Message)"
}
}
diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh
index 1e7c5d7f..b7afc315 100755
--- a/scripts/install-devapp.sh
+++ b/scripts/install-devapp.sh
@@ -26,6 +26,448 @@ say() { printf ' %s\n' "$@"; }
err() { printf ' ❌ %s\n' "$@" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; }
+sha256_file() {
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
+ elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
+ elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}'
+ else return 1
+ fi
+}
+
+verify_release_asset() {
+ name="$1"; file="$2"
+ checksums="$tmp/checksums.txt"
+ if [ ! -f "$checksums" ]; then
+ curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/checksums.txt" -o "$checksums" \
+ || err "Could not download checksums.txt; refusing unverified release assets."
+ fi
+ expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")"
+ [ -n "$expected" ] || err "${name} is missing from checksums.txt."
+ actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}."
+ [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}."
+ say "✅ SHA256 checksum verified: ${name}"
+}
+
+
+# perm_of
+# Prints the octal permission bits of path. Linux stat and BSD/macOS stat
+# disagree on flags, so try both spellings and fail loudly when neither is
+# available.
+perm_of() {
+ _po_mode="$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" 2>/dev/null)" || return 1
+ printf '%s\n' "$_po_mode"
+}
+
+# discard_stage
+# Best-effort removal of our own staging tree. Read-only directories copied
+# from the skill bundle resist rm -rf, so grant owner write access first.
+discard_stage() {
+ chmod -R u+w "$1" 2>/dev/null || true
+ rm -rf "$1" 2>/dev/null || true
+}
+
+# count_tree_entries
+count_tree_entries() {
+ _cte_n=0
+ for _cte_e in "$1"/* "$1"/.[!.]* "$1"/..?*; do
+ [ -e "$_cte_e" ] || [ -L "$_cte_e" ] || continue
+ _cte_n=$((_cte_n+1))
+ done
+ printf '%s\n' "$_cte_n"
+}
+
+# publish_tree_children
+# Publishes every child of the staging directory into the claimed dest through
+# an atomic no-clobber primitive: directories claim with mkdir and recurse,
+# regular files publish via ln (a hard link is a single atomic rename-class
+# syscall that fails with EEXIST when the target exists) and then drop the
+# staging source, and symlinks are recreated with ln -s. A POSIX `mv` cannot
+# be used here: renaming a directory onto a concurrently created same-name
+# (empty) directory replaces it, and mv offers no kernel-level no-replace.
+# Every primitive therefore refuses a concurrently created object instead of
+# replacing it. The absolute destination path of each published entry is
+# appended to the manifest so the rollback only ever retracts entries this
+# transaction published. The recursion runs in a subshell so the caller's
+# loop variables survive; manifest appends and filesystem effects persist.
+# After each level the destination is re-counted: more entries than the
+# staging side had means a different-named concurrent writer landed
+# mid-publish, and the whole publication aborts with the destination kept.
+publish_tree_children() {
+ _ptc_stage="$1"; _ptc_dest="$2"; _ptc_manifest="$3"
+ _ptc_staged="$(count_tree_entries "$_ptc_stage")"
+ for _ptc_child in "$_ptc_stage"/* "$_ptc_stage"/.[!.]* "$_ptc_stage"/..?*; do
+ [ -e "$_ptc_child" ] || [ -L "$_ptc_child" ] || continue
+ _ptc_name="$(basename "$_ptc_child")"
+ _ptc_target="${_ptc_dest%/}/$_ptc_name"
+ if [ -L "$_ptc_child" ]; then
+ _ptc_link="$(readlink "$_ptc_child")" || return 1
+ ln -s "$_ptc_link" "$_ptc_target" 2>/dev/null || {
+ [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
+ printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
+ return 1
+ }
+ rm -f "$_ptc_child" || return 1
+ elif [ -d "$_ptc_child" ]; then
+ _ptc_mode="$(perm_of "$_ptc_child")" || return 1
+ # The staging tree is ours alone: skill trees ship read-only
+ # directories (0555), which would block unlinking the children and
+ # removing the emptied shell. Grant owner write access for the move;
+ # the published target receives the recorded mode below.
+ chmod u+w "$_ptc_child" || return 1
+ mkdir "$_ptc_target" 2>/dev/null || {
+ [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
+ printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
+ return 1
+ }
+ ( publish_tree_children "$_ptc_child" "$_ptc_target" "$_ptc_manifest" ) || return 1
+ chmod "$_ptc_mode" "$_ptc_target" || return 1
+ rmdir "$_ptc_child" 2>/dev/null || return 1
+ else
+ ln "$_ptc_child" "$_ptc_target" 2>/dev/null || {
+ [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
+ printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
+ return 1
+ }
+ rm -f "$_ptc_child" || return 1
+ fi
+ printf '%s\n' "$_ptc_target" >> "$_ptc_manifest"
+ done
+ _ptc_live="$(count_tree_entries "$_ptc_dest")"
+ if [ "$_ptc_live" -gt "$_ptc_staged" ]; then
+ printf ' 目标出现并发目录项,中止发布并保留 %s\n' "$_ptc_dest" >&2
+ return 1
+ fi
+ return 0
+}
+
+# retract_published_children
+# Moves the manifest-recorded entries back into the staging tree in reverse
+# publication order, so deeper entries move before (or together with, when a
+# containing directory retraction carries them) the directories that hold
+# them. Only entries this transaction published are touched; a concurrently
+# created object is never retracted. An entry that no longer exists was
+# carried away with an ancestor; a genuine move failure stops the retraction
+# so the caller reports the retained destination.
+retract_published_children() {
+ _rpc_stage_root="$1"; _rpc_dest_root="$2"; _rpc_manifest="$3"
+ [ -s "$_rpc_manifest" ] || return 0
+ _rpc_rev="${_rpc_manifest}.rev"
+ sed '1!G;h;$!d' "$_rpc_manifest" > "$_rpc_rev" || { rm -f "$_rpc_rev"; return 1; }
+ while IFS= read -r _rpc_entry; do
+ [ -n "$_rpc_entry" ] || continue
+ _rpc_rel="${_rpc_entry#"${_rpc_dest_root%/}/"}"
+ _rpc_back="${_rpc_stage_root%/}/$_rpc_rel"
+ mkdir -p "$(dirname "$_rpc_back")" 2>/dev/null || break
+ rmdir "$_rpc_back" 2>/dev/null || true
+ mv "$_rpc_entry" "$_rpc_back" 2>/dev/null || {
+ [ -e "$_rpc_entry" ] || [ -L "$_rpc_entry" ] || continue
+ break
+ }
+ done < "$_rpc_rev"
+ rm -f "$_rpc_rev"
+ return 0
+}
+
+copy_tree() {
+ src="$1"
+ dest="$2"
+ parent="$(dirname "$dest")"
+ mkdir -p "$parent" || return 1
+ stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1
+ manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || { rm -rf "$stage"; return 1; }
+ if ! cp -R "$src/." "$stage/"; then discard_stage "$stage"; rm -f "$manifest"; return 1; fi
+ chmod u+w "$stage" 2>/dev/null || true
+ backup="$(backup_skill_dir "$dest")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; }
+ _ct_mode="$(perm_of "$src")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; }
+ # mkdir is the atomic no-replace claim: it fails when the path is already
+ # occupied, and the claim is then held for the whole transaction. Children
+ # publish into the claim through per-entry no-clobber primitives (see
+ # publish_tree_children) so a concurrent writer can never be replaced; the
+ # rollback only retracts what the manifest recorded, and the claim itself
+ # is removed only while still empty, so foreign entries always survive.
+ if mkdir "$dest" 2>/dev/null; then
+ if publish_tree_children "$stage" "$dest" "$manifest"; then
+ chmod "$_ct_mode" "$dest" 2>/dev/null || true
+ discard_stage "$stage"
+ rm -f "$manifest"
+ return 0
+ fi
+ retract_published_children "$stage" "$dest" "$manifest"
+ rmdir "$dest" 2>/dev/null || true
+ fi
+ discard_stage "$stage"
+ rm -f "$manifest"
+ if [ -n "$backup" ]; then
+ restore_manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || return 1
+ if mkdir "$dest" 2>/dev/null; then
+ # The restore publishes the backup through the same no-clobber
+ # discipline: a concurrent writer that took the emptied path (or any
+ # entry inside it) is refused and the backup is kept intact instead
+ # of being partially drained by an overwriting move.
+ if publish_tree_children "$backup" "$dest" "$restore_manifest"; then
+ rmdir "$backup" 2>/dev/null || true
+ else
+ retract_published_children "$backup" "$dest" "$restore_manifest"
+ rmdir "$dest" 2>/dev/null || true
+ printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
+ fi
+ else
+ printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
+ fi
+ rm -f "$restore_manifest"
+ fi
+ return 1
+}
+
+# refresh_cache_tree
+# Ephemeral ~/.dws/skills cache refresh, identical to install-event.sh. The tree
+# is byte-reproducible from the release asset, so it is never archived into
+# ~/.dws/skill-backups; unlike the previous `rm -rf` + `cp` it only deletes the
+# old cache after the replacement is fully staged, so an interrupted install can
+# no longer leave a half-copied cache behind.
+refresh_cache_tree() {
+ src="$1"; dest="$2"; parent="$(dirname "$dest")"
+ mkdir -p "$parent" || return 1
+ stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1
+ if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
+ old=""
+ if [ -e "$dest" ] || [ -L "$dest" ]; then
+ old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; }
+ rmdir "$old" || { rm -rf "$stage" "$old"; return 1; }
+ if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi
+ fi
+ if mv "$stage" "$dest"; then
+ if [ -n "$old" ] && ! rm -rf "$old"; then
+ printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2
+ fi
+ return 0
+ fi
+ rm -rf "$stage"
+ if [ -n "$old" ] && ! mv "$old" "$dest"; then
+ printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2
+ fi
+ return 1
+}
+
+backup_skill_dir() {
+ victim="$1"
+ [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; }
+ # Prune before creating this run's archive; every stamp this run has already
+ # created is registered, so pruning can only remove earlier-run batches.
+ prune_skill_backups
+ stamp="$(date -u +%Y%m%d-%H%M%S)"
+ name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')"
+ backup_root="$HOME/.dws/skill-backups/$stamp"; backup="$backup_root/$name"; i=0
+ # Bump not only when the payload path is taken but also when the stamp
+ # root exists without a verified ownership marker: a same-second foreign
+ # directory must never be stamped DWS-owned and made prunable. A
+ # marker-verified root from this run's same second stays reusable.
+ while [ -e "$backup" ] || [ -L "$backup" ] ||
+ { [ -d "$backup_root" ] && ! is_current_run_backup_stamp "$backup_root" &&
+ [ "$(cat "$backup_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do
+ i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"
+ # Same bail-out as install-skills.sh: never spin forever on a pathological
+ # backup root, report it and keep the original directory instead.
+ if [ "$i" -gt 1000 ]; then
+ printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2
+ return 1
+ fi
+ done
+ record_current_run_backup_stamp "$backup_root"
+ # Freshness must be sampled before mkdir: the collision loop tests the
+ # payload path, so a second backup in the same stamp second reuses this
+ # root while a sibling payload from this run already lives in it.
+ fresh=1
+ [ ! -d "$backup_root" ] || fresh=0
+ mkdir -p "$backup_root" || return 1
+ # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a
+ # stamp-shaped name alone is not evidence, so pruning only ever removes
+ # roots carrying this marker — it must exist before the payload moves in.
+ printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || {
+ # Non-recursive cleanup, sibling protection: only a root this call
+ # created may be dropped, and only the marker plus an empty root (rmdir
+ # refuses a non-empty directory) — rm -rf here would destroy a completed
+ # same-second sibling backup whose original is already moved away.
+ if [ "$fresh" -eq 1 ]; then
+ rm -f "$backup_root/.dws-skill-backup"
+ rmdir "$backup_root" 2>/dev/null || true
+ fi
+ return 1
+ }
+ mv "$victim" "$backup" || return 1
+ printf '%s\n' "$backup"
+}
+
+# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup
+# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's
+# skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots carrying the
+# .dws-skill-backup marker with the exact expected content are counted or
+# removed: foreign data with a stamp-like name never consumes a keep slot
+# and is never deleted, silently. Stamp directories created by the
+# current process are never pruned (mirroring Go's run-root registry and
+# install.js's currentRunBackupRoots), so a migration that retires more than
+# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and
+# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort:
+# a removal failure is reported and never fatal.
+SKILL_BACKUP_KEEP=5
+
+# Stamp directories created by this run, recorded by backup_skill_dir so
+# pruning can never delete a backup the running migration may still need to
+# roll back to.
+CURRENT_RUN_BACKUP_STAMPS=""
+
+record_current_run_backup_stamp() {
+ case " $CURRENT_RUN_BACKUP_STAMPS " in
+ *" $1 "*) return 0 ;;
+ esac
+ CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1"
+}
+
+# is_current_run_backup_stamp reports whether the stamp root was created by
+# this very process. Such a root is ours by construction and stays reusable
+# even when its marker cannot be re-verified mid-run (for example a
+# permission failure after the first payload moved in).
+is_current_run_backup_stamp() {
+ case " $CURRENT_RUN_BACKUP_STAMPS " in
+ *" $1 "*) return 0 ;;
+ esac
+ return 1
+}
+
+# is_skill_backup_stamp accepts only directory names with the stamp shape DWS
+# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix.
+# Shape is necessary but not sufficient — pruning additionally verifies the
+# .dws-skill-backup ownership marker — while any other entry in the backup
+# root is foreign (user data, unrelated tooling) and is preserved so pruning
+# can never remove a directory it cannot prove DWS created.
+is_skill_backup_stamp() {
+ case "$1" in
+ [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
+ return 0 ;;
+ [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
+ # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
+ # those 16 chars and require the remainder to be all digits.
+ _isbs_suffix="${1#????????????????}"
+ case "$_isbs_suffix" in
+ ""|*[!0-9]*) return 1 ;;
+ esac
+ return 0 ;;
+ esac
+ return 1
+}
+
+prune_skill_backups() {
+ prune_root="$HOME/.dws/skill-backups"
+ [ -d "$prune_root" ] || return 0
+ prune_total=0
+ for prune_entry in "$prune_root"/*; do
+ [ -d "$prune_entry" ] || continue
+ is_skill_backup_stamp "${prune_entry##*/}" || continue
+ [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
+ prune_total=$((prune_total + 1))
+ done
+ [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
+ prune_drop=$((prune_total - SKILL_BACKUP_KEEP))
+ for prune_entry in "$prune_root"/*; do
+ [ "$prune_drop" -gt 0 ] || break
+ [ -d "$prune_entry" ] || continue
+ is_skill_backup_stamp "${prune_entry##*/}" || continue
+ [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
+ case " $CURRENT_RUN_BACKUP_STAMPS " in
+ *" $prune_entry "*) continue ;;
+ esac
+ rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
+ prune_drop=$((prune_drop - 1))
+ done
+}
+
+same_physical_skill() {
+ [ -d "$1" ] && [ -d "$2" ] || return 1
+ left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
+ right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
+ [ "$left" = "$right" ]
+}
+
+is_cleanup_only_agent_dir() {
+ case "$1" in
+ .config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+agent_skill_dirs() {
+ printf '%s\n' \
+ .config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \
+ .aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \
+ .qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills
+}
+
+resolve_agent_skill_base() {
+ agent_dir="$1"; base="$HOME/$agent_dir"
+ case "$agent_dir" in
+ .claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;;
+ .codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;;
+ .hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;;
+ .autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;;
+ .grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;;
+ .vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;;
+ .openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;;
+ .config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;;
+ esac
+ printf '%s\n' "$base"
+}
+
+agent_skill_base_detected() {
+ agent_dir="$1"; base="$2"
+ case "$agent_dir" in
+ .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;;
+ .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;;
+ .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
+ *) [ -d "$(dirname "$base")" ] ;;
+ esac
+}
+
+link_or_copy_skill() {
+ canonical="$1"; src="$2"; dest="$3"
+ same_physical_skill "$dest" "$canonical" && return 0
+ parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1
+ parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1
+ target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1
+ relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then
+ rm -rf "$stage"
+ if copy_tree "$src" "$dest"; then
+ printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2
+ return 0
+ fi
+ return 1
+ fi
+ backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
+ # Create the link directly at the destination: symlink(2) refuses an
+ # occupied path with EEXIST, so publication itself is the atomic no-replace
+ # check (`mv` after the backup could still replace a file or symlink a
+ # concurrent writer created in between). A directory that slipped in turns
+ # `ln -s` into a container: remove exactly our nested link and roll back.
+ if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then
+ rm -rf "$stage"
+ return 0
+ fi
+ nested="$dest/${relative##*/}"
+ if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then
+ rm -f "$nested" || true
+ fi
+ rm -rf "$stage"
+ if [ -n "$backup" ]; then
+ if [ -e "$dest" ] || [ -L "$dest" ]; then
+ printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2
+ elif ! mv "$backup" "$dest"; then
+ printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
+ fi
+ fi
+ return 1
+}
+
need_cmd curl
detect_os() {
@@ -81,20 +523,43 @@ install_skill() {
# cache so `dws skill setup --mode multi` can find a source later
cache="$HOME/.dws/skills/multi/$SKILL_NAME"
- rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/"
+ refresh_cache_tree "$src" "$cache" || { printf ' ⚠️ Skill 缓存刷新失败: %s\n' "$cache" >&2; return 1; }
- installed=0; idx=0
- for agent_dir in \
- .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \
- .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \
- .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \
- .config/opencode/skills
- do
- base="$HOME/$agent_dir"
- if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then idx=$((idx + 1)); continue; fi
- dest="$base/$SKILL_NAME"; rm -rf "$dest"; mkdir -p "$dest"; cp -R "$src/." "$dest/"
- installed=$((installed + 1)); idx=$((idx + 1))
+ canonical="$HOME/.agents/skills/$SKILL_NAME"
+ copy_tree "$src" "$canonical" || return 1
+ installed=1
+ failed=0
+ retire_failed=0
+ for agent_dir in $(agent_skill_dirs); do
+ base="$(resolve_agent_skill_base "$agent_dir")"
+ agent_skill_base_detected "$agent_dir" "$base" || continue
+ same_physical_skill "$base" "$HOME/.agents/skills" && continue
+ if is_cleanup_only_agent_dir "$agent_dir"; then
+ # Cleanup-only: universal Agents read the canonical store directly, so
+ # nothing is installed here. A retire failure only leaves an obsolete copy
+ # behind and must never fail an otherwise complete install.
+ if ! backup_skill_dir "$base/$SKILL_NAME" >/dev/null; then
+ printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$SKILL_NAME" >&2
+ retire_failed=$((retire_failed + 1))
+ fi
+ continue
+ fi
+ # Per-agent degrade like install.sh: a failed target is reported and
+ # skipped loudly instead of aborting the remaining agents mid-loop.
+ if ! link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME"; then
+ printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$SKILL_NAME" >&2
+ failed=$((failed + 1))
+ continue
+ fi
+ installed=$((installed + 1))
done
+ if [ "$retire_failed" -gt 0 ]; then
+ printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2
+ fi
+ if [ "$failed" -gt 0 ]; then
+ printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$SKILL_NAME" >&2
+ return 1
+ fi
say "✅ Skill dingtalk-misc → ${installed} agent dir(s)"
}
@@ -119,6 +584,7 @@ main() {
say "⬇ Downloading ${asset} ..."
curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/${asset}" -o "$tmp/$asset" \
|| err "Binary download failed — does release ${DEVAPP_VERSION} have ${asset}?"
+ verify_release_asset "$asset" "$tmp/$asset"
if [ "$os" = "windows" ]; then
need_cmd unzip; unzip -q "$tmp/$asset" -d "$tmp"
else
@@ -132,10 +598,11 @@ main() {
# 2) dev skill from the release's skills bundle
if [ "$NO_SKILLS" != "1" ]; then
if curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/dws-skills.zip" -o "$tmp/skills.zip" 2>/dev/null; then
+ verify_release_asset "dws-skills.zip" "$tmp/skills.zip"
mkdir -p "$tmp/sk"
if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi
say ""
- install_skill "$tmp/sk"
+ install_skill "$tmp/sk" || err "dingtalk-misc Skill 安装失败,详见上方告警"
else
say " (no dws-skills.zip in release ${DEVAPP_VERSION}; skill skipped)"
fi
diff --git a/scripts/install-event.sh b/scripts/install-event.sh
index 3402a333..aa3716cf 100755
--- a/scripts/install-event.sh
+++ b/scripts/install-event.sh
@@ -39,6 +39,28 @@ say() { printf ' %s\n' "$@"; }
err() { printf ' ERROR: %s\n' "$@" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; }
+sha256_file() {
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
+ elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
+ elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}'
+ else return 1
+ fi
+}
+
+verify_release_asset() {
+ name="$1"; file="$2"
+ checksums="$tmp/checksums.txt"
+ if [ ! -f "$checksums" ]; then
+ curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/checksums.txt" -o "$checksums" \
+ || err "Could not download checksums.txt; refusing unverified release assets."
+ fi
+ expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")"
+ [ -n "$expected" ] || err "${name} is missing from checksums.txt."
+ actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}."
+ [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}."
+ say "SHA256 checksum verified: ${name}"
+}
+
detect_os() {
case "$(uname -s)" in
Linux*) echo linux ;;
@@ -89,39 +111,467 @@ resolve_event_version() {
[ -n "$EVENT_VERSION" ] || err "No stable release found on ${EVENT_REPO}. Set EVENT_VERSION to a published release tag."
}
+
+# perm_of
+# Prints the octal permission bits of path. Linux stat and BSD/macOS stat
+# disagree on flags, so try both spellings and fail loudly when neither is
+# available.
+perm_of() {
+ _po_mode="$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" 2>/dev/null)" || return 1
+ printf '%s\n' "$_po_mode"
+}
+
+# discard_stage
+# Best-effort removal of our own staging tree. Read-only directories copied
+# from the skill bundle resist rm -rf, so grant owner write access first.
+discard_stage() {
+ chmod -R u+w "$1" 2>/dev/null || true
+ rm -rf "$1" 2>/dev/null || true
+}
+
+# count_tree_entries
+count_tree_entries() {
+ _cte_n=0
+ for _cte_e in "$1"/* "$1"/.[!.]* "$1"/..?*; do
+ [ -e "$_cte_e" ] || [ -L "$_cte_e" ] || continue
+ _cte_n=$((_cte_n+1))
+ done
+ printf '%s\n' "$_cte_n"
+}
+
+# publish_tree_children
+# Publishes every child of the staging directory into the claimed dest through
+# an atomic no-clobber primitive: directories claim with mkdir and recurse,
+# regular files publish via ln (a hard link is a single atomic rename-class
+# syscall that fails with EEXIST when the target exists) and then drop the
+# staging source, and symlinks are recreated with ln -s. A POSIX `mv` cannot
+# be used here: renaming a directory onto a concurrently created same-name
+# (empty) directory replaces it, and mv offers no kernel-level no-replace.
+# Every primitive therefore refuses a concurrently created object instead of
+# replacing it. The absolute destination path of each published entry is
+# appended to the manifest so the rollback only ever retracts entries this
+# transaction published. The recursion runs in a subshell so the caller's
+# loop variables survive; manifest appends and filesystem effects persist.
+# After each level the destination is re-counted: more entries than the
+# staging side had means a different-named concurrent writer landed
+# mid-publish, and the whole publication aborts with the destination kept.
+publish_tree_children() {
+ _ptc_stage="$1"; _ptc_dest="$2"; _ptc_manifest="$3"
+ _ptc_staged="$(count_tree_entries "$_ptc_stage")"
+ for _ptc_child in "$_ptc_stage"/* "$_ptc_stage"/.[!.]* "$_ptc_stage"/..?*; do
+ [ -e "$_ptc_child" ] || [ -L "$_ptc_child" ] || continue
+ _ptc_name="$(basename "$_ptc_child")"
+ _ptc_target="${_ptc_dest%/}/$_ptc_name"
+ if [ -L "$_ptc_child" ]; then
+ _ptc_link="$(readlink "$_ptc_child")" || return 1
+ ln -s "$_ptc_link" "$_ptc_target" 2>/dev/null || {
+ [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
+ printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
+ return 1
+ }
+ rm -f "$_ptc_child" || return 1
+ elif [ -d "$_ptc_child" ]; then
+ _ptc_mode="$(perm_of "$_ptc_child")" || return 1
+ # The staging tree is ours alone: skill trees ship read-only
+ # directories (0555), which would block unlinking the children and
+ # removing the emptied shell. Grant owner write access for the move;
+ # the published target receives the recorded mode below.
+ chmod u+w "$_ptc_child" || return 1
+ mkdir "$_ptc_target" 2>/dev/null || {
+ [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
+ printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
+ return 1
+ }
+ ( publish_tree_children "$_ptc_child" "$_ptc_target" "$_ptc_manifest" ) || return 1
+ chmod "$_ptc_mode" "$_ptc_target" || return 1
+ rmdir "$_ptc_child" 2>/dev/null || return 1
+ else
+ ln "$_ptc_child" "$_ptc_target" 2>/dev/null || {
+ [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1
+ printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2
+ return 1
+ }
+ rm -f "$_ptc_child" || return 1
+ fi
+ printf '%s\n' "$_ptc_target" >> "$_ptc_manifest"
+ done
+ _ptc_live="$(count_tree_entries "$_ptc_dest")"
+ if [ "$_ptc_live" -gt "$_ptc_staged" ]; then
+ printf ' 目标出现并发目录项,中止发布并保留 %s\n' "$_ptc_dest" >&2
+ return 1
+ fi
+ return 0
+}
+
+# retract_published_children
+# Moves the manifest-recorded entries back into the staging tree in reverse
+# publication order, so deeper entries move before (or together with, when a
+# containing directory retraction carries them) the directories that hold
+# them. Only entries this transaction published are touched; a concurrently
+# created object is never retracted. An entry that no longer exists was
+# carried away with an ancestor; a genuine move failure stops the retraction
+# so the caller reports the retained destination.
+retract_published_children() {
+ _rpc_stage_root="$1"; _rpc_dest_root="$2"; _rpc_manifest="$3"
+ [ -s "$_rpc_manifest" ] || return 0
+ _rpc_rev="${_rpc_manifest}.rev"
+ sed '1!G;h;$!d' "$_rpc_manifest" > "$_rpc_rev" || { rm -f "$_rpc_rev"; return 1; }
+ while IFS= read -r _rpc_entry; do
+ [ -n "$_rpc_entry" ] || continue
+ _rpc_rel="${_rpc_entry#"${_rpc_dest_root%/}/"}"
+ _rpc_back="${_rpc_stage_root%/}/$_rpc_rel"
+ mkdir -p "$(dirname "$_rpc_back")" 2>/dev/null || break
+ rmdir "$_rpc_back" 2>/dev/null || true
+ mv "$_rpc_entry" "$_rpc_back" 2>/dev/null || {
+ [ -e "$_rpc_entry" ] || [ -L "$_rpc_entry" ] || continue
+ break
+ }
+ done < "$_rpc_rev"
+ rm -f "$_rpc_rev"
+ return 0
+}
+
copy_tree() {
src="$1"
dest="$2"
- rm -rf "$dest"
- mkdir -p "$dest"
- cp -R "$src/." "$dest/"
+ parent="$(dirname "$dest")"
+ mkdir -p "$parent" || return 1
+ stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1
+ manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || { rm -rf "$stage"; return 1; }
+ if ! cp -R "$src/." "$stage/"; then discard_stage "$stage"; rm -f "$manifest"; return 1; fi
+ chmod u+w "$stage" 2>/dev/null || true
+ backup="$(backup_skill_dir "$dest")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; }
+ _ct_mode="$(perm_of "$src")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; }
+ # mkdir is the atomic no-replace claim: it fails when the path is already
+ # occupied, and the claim is then held for the whole transaction. Children
+ # publish into the claim through per-entry no-clobber primitives (see
+ # publish_tree_children) so a concurrent writer can never be replaced; the
+ # rollback only retracts what the manifest recorded, and the claim itself
+ # is removed only while still empty, so foreign entries always survive.
+ if mkdir "$dest" 2>/dev/null; then
+ if publish_tree_children "$stage" "$dest" "$manifest"; then
+ chmod "$_ct_mode" "$dest" 2>/dev/null || true
+ discard_stage "$stage"
+ rm -f "$manifest"
+ return 0
+ fi
+ retract_published_children "$stage" "$dest" "$manifest"
+ rmdir "$dest" 2>/dev/null || true
+ fi
+ discard_stage "$stage"
+ rm -f "$manifest"
+ if [ -n "$backup" ]; then
+ restore_manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || return 1
+ if mkdir "$dest" 2>/dev/null; then
+ # The restore publishes the backup through the same no-clobber
+ # discipline: a concurrent writer that took the emptied path (or any
+ # entry inside it) is refused and the backup is kept intact instead
+ # of being partially drained by an overwriting move.
+ if publish_tree_children "$backup" "$dest" "$restore_manifest"; then
+ rmdir "$backup" 2>/dev/null || true
+ else
+ retract_published_children "$backup" "$dest" "$restore_manifest"
+ rmdir "$dest" 2>/dev/null || true
+ printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
+ fi
+ else
+ printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
+ fi
+ rm -f "$restore_manifest"
+ fi
+ return 1
+}
+
+# refresh_cache_tree
+# Same staged, atomic swap as copy_tree, but for the ephemeral ~/.dws/skills
+# caches: those trees are byte-reproducible from the release asset, so archiving
+# every stale generation into ~/.dws/skill-backups only grows that directory
+# without bound. The previous cache is deleted, exactly like install-devapp.sh —
+# but only after the replacement is fully staged and published.
+refresh_cache_tree() {
+ src="$1"
+ dest="$2"
+ parent="$(dirname "$dest")"
+ mkdir -p "$parent" || return 1
+ stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1
+ if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi
+ old=""
+ if [ -e "$dest" ] || [ -L "$dest" ]; then
+ old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; }
+ rmdir "$old" || { rm -rf "$stage" "$old"; return 1; }
+ if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi
+ fi
+ if mv "$stage" "$dest"; then
+ if [ -n "$old" ] && ! rm -rf "$old"; then
+ printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2
+ fi
+ return 0
+ fi
+ rm -rf "$stage"
+ if [ -n "$old" ] && ! mv "$old" "$dest"; then
+ printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2
+ fi
+ return 1
+}
+
+backup_skill_dir() {
+ victim="$1"
+ [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; }
+ # Prune before creating this run's archive; every stamp this run has already
+ # created is registered, so pruning can only remove earlier-run batches.
+ prune_skill_backups
+ stamp="$(date -u +%Y%m%d-%H%M%S)"
+ name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')"
+ backup_root="$HOME/.dws/skill-backups/$stamp"
+ backup="$backup_root/$name"
+ i=0
+ # Bump not only when the payload path is taken but also when the stamp
+ # root exists without a verified ownership marker: a same-second foreign
+ # directory must never be stamped DWS-owned and made prunable. A
+ # marker-verified root from this run's same second stays reusable.
+ while [ -e "$backup" ] || [ -L "$backup" ] ||
+ { [ -d "$backup_root" ] && ! is_current_run_backup_stamp "$backup_root" &&
+ [ "$(cat "$backup_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do
+ i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name"
+ # Same bail-out as install-skills.sh: never spin forever on a pathological
+ # backup root, report it and keep the original directory instead.
+ if [ "$i" -gt 1000 ]; then
+ printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2
+ return 1
+ fi
+ done
+ record_current_run_backup_stamp "$backup_root"
+ # Freshness must be sampled before mkdir: the collision loop tests the
+ # payload path, so a second backup in the same stamp second reuses this
+ # root while a sibling payload from this run already lives in it.
+ fresh=1
+ [ ! -d "$backup_root" ] || fresh=0
+ mkdir -p "$backup_root" || return 1
+ # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a
+ # stamp-shaped name alone is not evidence, so pruning only ever removes
+ # roots carrying this marker — it must exist before the payload moves in.
+ printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || {
+ # Non-recursive cleanup, sibling protection: only a root this call
+ # created may be dropped, and only the marker plus an empty root (rmdir
+ # refuses a non-empty directory) — rm -rf here would destroy a completed
+ # same-second sibling backup whose original is already moved away.
+ if [ "$fresh" -eq 1 ]; then
+ rm -f "$backup_root/.dws-skill-backup"
+ rmdir "$backup_root" 2>/dev/null || true
+ fi
+ return 1
+ }
+ mv "$victim" "$backup" || return 1
+ printf '%s\n' "$backup"
+}
+
+# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup
+# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's
+# skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots carrying the
+# .dws-skill-backup marker with the exact expected content are counted or
+# removed: foreign data with a stamp-like name never consumes a keep slot
+# and is never deleted, silently. Stamp directories created by the
+# current process are never pruned (mirroring Go's run-root registry and
+# install.js's currentRunBackupRoots), so a migration that retires more than
+# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and
+# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort:
+# a removal failure is reported and never fatal.
+SKILL_BACKUP_KEEP=5
+
+# Stamp directories created by this run, recorded by backup_skill_dir so
+# pruning can never delete a backup the running migration may still need to
+# roll back to.
+CURRENT_RUN_BACKUP_STAMPS=""
+
+record_current_run_backup_stamp() {
+ case " $CURRENT_RUN_BACKUP_STAMPS " in
+ *" $1 "*) return 0 ;;
+ esac
+ CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1"
+}
+
+# is_current_run_backup_stamp reports whether the stamp root was created by
+# this very process. Such a root is ours by construction and stays reusable
+# even when its marker cannot be re-verified mid-run (for example a
+# permission failure after the first payload moved in).
+is_current_run_backup_stamp() {
+ case " $CURRENT_RUN_BACKUP_STAMPS " in
+ *" $1 "*) return 0 ;;
+ esac
+ return 1
+}
+
+# is_skill_backup_stamp accepts only directory names with the stamp shape DWS
+# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix.
+# Shape is necessary but not sufficient — pruning additionally verifies the
+# .dws-skill-backup ownership marker — while any other entry in the backup
+# root is foreign (user data, unrelated tooling) and is preserved so pruning
+# can never remove a directory it cannot prove DWS created.
+is_skill_backup_stamp() {
+ case "$1" in
+ [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
+ return 0 ;;
+ [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
+ # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
+ # those 16 chars and require the remainder to be all digits.
+ _isbs_suffix="${1#????????????????}"
+ case "$_isbs_suffix" in
+ ""|*[!0-9]*) return 1 ;;
+ esac
+ return 0 ;;
+ esac
+ return 1
+}
+
+prune_skill_backups() {
+ prune_root="$HOME/.dws/skill-backups"
+ [ -d "$prune_root" ] || return 0
+ prune_total=0
+ for prune_entry in "$prune_root"/*; do
+ [ -d "$prune_entry" ] || continue
+ is_skill_backup_stamp "${prune_entry##*/}" || continue
+ [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
+ prune_total=$((prune_total + 1))
+ done
+ [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0
+ prune_drop=$((prune_total - SKILL_BACKUP_KEEP))
+ for prune_entry in "$prune_root"/*; do
+ [ "$prune_drop" -gt 0 ] || break
+ [ -d "$prune_entry" ] || continue
+ is_skill_backup_stamp "${prune_entry##*/}" || continue
+ [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
+ case " $CURRENT_RUN_BACKUP_STAMPS " in
+ *" $prune_entry "*) continue ;;
+ esac
+ rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2
+ prune_drop=$((prune_drop - 1))
+ done
+}
+
+same_physical_skill() {
+ [ -d "$1" ] && [ -d "$2" ] || return 1
+ left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
+ right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
+ [ "$left" = "$right" ]
+}
+
+is_cleanup_only_agent_dir() {
+ case "$1" in
+ .config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+agent_skill_dirs() {
+ printf '%s\n' \
+ .config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \
+ .aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \
+ .qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills
+}
+
+resolve_agent_skill_base() {
+ agent_dir="$1"; base="$HOME/$agent_dir"
+ case "$agent_dir" in
+ .claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;;
+ .codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;;
+ .hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;;
+ .autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;;
+ .grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;;
+ .vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;;
+ .openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;;
+ .config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;;
+ esac
+ printf '%s\n' "$base"
+}
+
+agent_skill_base_detected() {
+ agent_dir="$1"; base="$2"
+ case "$agent_dir" in
+ .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;;
+ .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;;
+ .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
+ *) [ -d "$(dirname "$base")" ] ;;
+ esac
+}
+
+link_or_copy_skill() {
+ canonical="$1"; src="$2"; dest="$3"
+ same_physical_skill "$dest" "$canonical" && return 0
+ parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1
+ parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1
+ target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1
+ relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then
+ rm -rf "$stage"
+ if copy_tree "$src" "$dest"; then
+ printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2
+ return 0
+ fi
+ return 1
+ fi
+ backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; }
+ # Create the link directly at the destination: symlink(2) refuses an
+ # occupied path with EEXIST, so publication itself is the atomic no-replace
+ # check (`mv` after the backup could still replace a file or symlink a
+ # concurrent writer created in between). A directory that slipped in turns
+ # `ln -s` into a container: remove exactly our nested link and roll back.
+ if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then
+ rm -rf "$stage"
+ return 0
+ fi
+ nested="$dest/${relative##*/}"
+ if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then
+ rm -f "$nested" || true
+ fi
+ rm -rf "$stage"
+ if [ -n "$backup" ]; then
+ if [ -e "$dest" ] || [ -L "$dest" ]; then
+ printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2
+ elif ! mv "$backup" "$dest"; then
+ printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2
+ fi
+ fi
+ return 1
}
install_skill_to_homes() {
src="$1"
skill_name="$2"
-
- installed=0
- idx=0
- for agent_dir in \
- .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \
- .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \
- .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \
- .config/opencode/skills
- do
- base="$HOME/$agent_dir"
- if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then
- idx=$((idx + 1))
+ canonical="$HOME/.agents/skills/$skill_name"
+ copy_tree "$src" "$canonical" || return 1
+ installed=1
+ failed=0
+ retire_failed=0
+ for agent_dir in $(agent_skill_dirs); do
+ base="$(resolve_agent_skill_base "$agent_dir")"
+ agent_skill_base_detected "$agent_dir" "$base" || continue
+ same_physical_skill "$base" "$HOME/.agents/skills" && continue
+ if is_cleanup_only_agent_dir "$agent_dir"; then
+ # Cleanup-only: universal Agents read the canonical store directly, so
+ # nothing is installed here. A retire failure only leaves an obsolete copy
+ # behind and must never fail an otherwise complete install.
+ if ! backup_skill_dir "$base/$skill_name" >/dev/null; then
+ printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$skill_name" >&2
+ retire_failed=$((retire_failed + 1))
+ fi
+ continue
+ fi
+ # Per-agent degrade like install.sh: a failed target is reported and
+ # skipped loudly instead of aborting the remaining agents mid-loop.
+ if ! link_or_copy_skill "$canonical" "$src" "$base/$skill_name"; then
+ printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$skill_name" >&2
+ failed=$((failed + 1))
continue
fi
- copy_tree "$src" "$base/$skill_name"
installed=$((installed + 1))
- idx=$((idx + 1))
done
-
- if [ "$installed" -eq 0 ]; then
- copy_tree "$src" "$HOME/.agents/skills/$skill_name"
- installed=1
+ if [ "$retire_failed" -gt 0 ]; then
+ printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2
+ fi
+ if [ "$failed" -gt 0 ]; then
+ printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$skill_name" >&2
+ return 1
fi
printf '%s\n' "$installed"
}
@@ -176,17 +626,21 @@ install_skills_from_bundle() {
event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME"
shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME"
misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME"
- copy_tree "$event_src" "$event_cache"
- copy_tree "$shared_src" "$shared_cache"
- copy_tree "$misc_src" "$misc_cache"
+ refresh_cache_tree "$event_src" "$event_cache" || err "${EVENT_SKILL_NAME} 缓存刷新失败: ${event_cache}"
+ refresh_cache_tree "$shared_src" "$shared_cache" || err "${SHARED_SKILL_NAME} 缓存刷新失败: ${shared_cache}"
+ refresh_cache_tree "$misc_src" "$misc_cache" || err "${MISC_SKILL_NAME} 缓存刷新失败: ${misc_cache}"
mono_cache="$HOME/.dws/skills/mono"
- copy_tree "$mono_src" "$mono_cache"
+ refresh_cache_tree "$mono_src" "$mono_cache" || err "mono ${MONO_SKILL_NAME} 缓存刷新失败: ${mono_cache}"
- event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")"
- shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")"
- misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")"
- mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")"
+ event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" \
+ || err "${EVENT_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
+ shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" \
+ || err "${SHARED_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
+ misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" \
+ || err "${MISC_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
+ mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" \
+ || err "mono ${MONO_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警"
say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)"
say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)"
@@ -212,6 +666,7 @@ install_binary() {
say "Downloading ${asset} ..."
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/${asset}" -o "$tmp/$asset" \
|| err "Binary download failed. Does release ${EVENT_VERSION} have ${asset}?"
+ verify_release_asset "$asset" "$tmp/$asset"
if [ "$os" = "windows" ]; then
extract_zip "$tmp/$asset" "$tmp/bin"
@@ -240,6 +695,7 @@ install_skills() {
say "Downloading dws-skills.zip ..."
curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/dws-skills.zip" -o "$tmp/dws-skills.zip" \
|| err "dws-skills.zip download failed for release ${EVENT_VERSION}"
+ verify_release_asset "dws-skills.zip" "$tmp/dws-skills.zip"
mkdir -p "$tmp/skills"
extract_zip "$tmp/dws-skills.zip" "$tmp/skills"
diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh
index ad4eeb03..77ad6145 100755
--- a/scripts/install-skills.sh
+++ b/scripts/install-skills.sh
@@ -53,24 +53,54 @@ DWS_LAST_SKILL_BACKUP=""
backup_and_remove_skill_dir() {
_bed_dir="$1"
DWS_LAST_SKILL_BACKUP=""
- [ -d "$_bed_dir" ] || return 0
+ [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0
_bed_root="${HOME}/.dws/skill-backups"
_bed_stamp="$(date -u +%Y%m%d-%H%M%S)"
_bed_name="$(basename "$_bed_dir")"
- _bed_target="$_bed_root/$_bed_stamp/$_bed_name"
+ _bed_stamp_root="$_bed_root/$_bed_stamp"
+ _bed_target="$_bed_stamp_root/$_bed_name"
_bed_i=1
- while [ -e "$_bed_target" ]; do
- _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
+ # Bump not only when the payload path is taken but also when the stamp
+ # root exists without a verified ownership marker: a same-second foreign
+ # directory must never be stamped DWS-owned and made prunable. A
+ # marker-verified root from this run's same second stays reusable.
+ while [ -e "$_bed_target" ] || [ -L "$_bed_target" ] ||
+ { [ -d "$_bed_stamp_root" ] && ! is_current_run_backup_stamp "$_bed_stamp_root" &&
+ [ "$(cat "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do
+ _bed_stamp_root="$_bed_root/$_bed_stamp-$_bed_i"
+ _bed_target="$_bed_stamp_root/$_bed_name"
_bed_i=$((_bed_i + 1))
if [ "$_bed_i" -gt 1000 ]; then
printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$_bed_dir"
return 1
fi
done
- mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || {
+ _bed_stamp_root="$(dirname "$_bed_target")"
+ record_current_run_backup_stamp "$_bed_stamp_root"
+ # Freshness must be sampled before mkdir: the collision loop tests the
+ # payload path, so a second backup in the same stamp second reuses this
+ # root while a sibling payload from this run already lives in it.
+ _bed_fresh=1
+ [ ! -d "$_bed_stamp_root" ] || _bed_fresh=0
+ mkdir -p "$_bed_stamp_root" 2>/dev/null || {
printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir"
return 1
}
+ # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a
+ # stamp-shaped name alone is not evidence, so pruning only ever removes
+ # roots carrying this marker — it must exist before any payload moves in.
+ printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || {
+ # Non-recursive cleanup, sibling protection: only a root this call
+ # created may be dropped, and only the marker plus an empty root (rmdir
+ # refuses a non-empty directory) — rm -rf here would destroy a completed
+ # same-second sibling backup whose original is already moved away.
+ if [ "$_bed_fresh" -eq 1 ]; then
+ rm -f "$_bed_stamp_root/.dws-skill-backup"
+ rmdir "$_bed_stamp_root" 2>/dev/null || true
+ fi
+ printf ' ⚠️ 无法写入备份所有权标记,保留原目录 %s\n' "$_bed_dir"
+ return 1
+ }
if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then
DWS_LAST_SKILL_BACKUP="$_bed_target"
printf ' × 已备份并移除 %s → %s\n' "$_bed_dir" "$_bed_target"
@@ -80,6 +110,88 @@ backup_and_remove_skill_dir() {
return 1
}
+# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped
+# backup directories from earlier runs under $HOME/.dws/skill-backups,
+# matching Go's skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots
+# carrying the .dws-skill-backup marker with the exact expected content are
+# counted or removed: foreign data with a stamp-like name never consumes a
+# keep slot and is never deleted, silently. Stamp directories created
+# by the current process are never pruned (mirroring Go's run-root registry
+# and install.js's currentRunBackupRoots), so a migration that retires more
+# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal
+# failure is reported, never fatal.
+DWS_SKILL_BACKUP_KEEP=5
+
+# Stamp directories created by this run, recorded by
+# backup_and_remove_skill_dir so pruning can never delete a backup the
+# running migration may still need to roll back to.
+DWS_CURRENT_RUN_BACKUP_STAMPS=""
+
+record_current_run_backup_stamp() {
+ case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
+ *" $1 "*) return 0 ;;
+ esac
+ DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1"
+}
+
+# is_current_run_backup_stamp reports whether the stamp root was created by
+# this very process. Such a root is ours by construction and stays reusable
+# even when its marker cannot be re-verified mid-run (for example a
+# permission failure after the first payload moved in).
+is_current_run_backup_stamp() {
+ case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
+ *" $1 "*) return 0 ;;
+ esac
+ return 1
+}
+
+# is_skill_backup_stamp accepts only directory names with the stamp shape DWS
+# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix.
+# Shape is necessary but not sufficient — pruning additionally verifies the
+# .dws-skill-backup ownership marker — while any other entry in the backup
+# root is foreign (user data, unrelated tooling) and is preserved so pruning
+# can never remove a directory it cannot prove DWS created.
+is_skill_backup_stamp() {
+ case "$1" in
+ [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
+ return 0 ;;
+ [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
+ # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
+ # those 16 chars and require the remainder to be all digits.
+ _isbs_suffix="${1#????????????????}"
+ case "$_isbs_suffix" in
+ ""|*[!0-9]*) return 1 ;;
+ esac
+ return 0 ;;
+ esac
+ return 1
+}
+
+prune_skill_backups() {
+ _psb_root="${HOME}/.dws/skill-backups"
+ [ -d "$_psb_root" ] || return 0
+ _psb_total=0
+ for _psb_entry in "$_psb_root"/*; do
+ [ -d "$_psb_entry" ] || continue
+ is_skill_backup_stamp "${_psb_entry##*/}" || continue
+ [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
+ _psb_total=$((_psb_total + 1))
+ done
+ [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0
+ _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP))
+ for _psb_entry in "$_psb_root"/*; do
+ [ "$_psb_drop" -gt 0 ] || break
+ [ -d "$_psb_entry" ] || continue
+ is_skill_backup_stamp "${_psb_entry##*/}" || continue
+ [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
+ case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
+ *" $_psb_entry "*) continue ;;
+ esac
+ rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry"
+ _psb_drop=$((_psb_drop - 1))
+ done
+}
+
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
# use it too. Ownership comes from the centralized skills-state.json.
is_managed_multi_skill_dir() {
@@ -123,6 +235,46 @@ sha256_stdin() {
fi
}
+# sha256_file : content digest used by skill_dir_content_digest. Never
+# emits on failure. Callers that capture its status directly detect that
+# failure; skill_dir_content_digest cannot — see the pipeline caveat there:
+# POSIX sh returns only the right-hand pipeline status, so a subshell exit on
+# its left side is discarded and digest-failure detection stays best-effort.
+sha256_file() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$1" 2>/dev/null | awk '{print $1}'
+ elif command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
+ elif command -v openssl >/dev/null 2>&1; then
+ openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}'
+ else
+ return 1
+ fi
+}
+
+verify_release_asset_checksum() {
+ _checksum_asset="$1"
+ _checksum_path="$2"
+ _checksum_dir="$3"
+ _checksum_url="$(asset_url checksums.txt)"
+ [ -n "$_checksum_url" ] || { printf '❌ Could not resolve checksums.txt for %s.\n' "$VERSION" >&2; return 1; }
+ curl -fsSL "$_checksum_url" -o "$_checksum_dir/checksums.txt" || {
+ printf '❌ Could not download checksums.txt for %s; refusing unverified %s.\n' "$VERSION" "$_checksum_asset" >&2
+ return 1
+ }
+ _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")"
+ [ -n "$_checksum_expected" ] || { printf '❌ %s is missing from checksums.txt.\n' "$_checksum_asset" >&2; return 1; }
+ _checksum_actual="$(sha256_stdin < "$_checksum_path")" || {
+ printf '❌ Could not compute SHA256 for %s; install sha256sum, shasum, or openssl.\n' "$_checksum_asset" >&2
+ return 1
+ }
+ [ "$_checksum_actual" = "$_checksum_expected" ] || {
+ printf '❌ SHA256 checksum mismatch for %s. Expected %s, got %s.\n' "$_checksum_asset" "$_checksum_expected" "$_checksum_actual" >&2
+ return 1
+ }
+ printf ' ✅ SHA256 checksum verified: %s\n' "$_checksum_asset"
+}
+
digest_skill_dir() {
_digest_dir="$1"
_digest="$({
@@ -189,24 +341,147 @@ backup_and_record_skill_dir() {
fi
}
+# restore_quarantine_no_replace
+# Puts an unmatched quarantined object back onto dest without replacing
+# anything that occupied dest after the claim. Directories use mkdir-claim
+# plus child moves; links are created at dest; files hard-link then drop
+# the quarantine copy.
+restore_quarantine_no_replace() {
+ _rqn_src="$1"
+ _rqn_dest="$2"
+ if [ -L "$_rqn_src" ]; then
+ _rqn_target="$(readlink "$_rqn_src")" || return 1
+ if ! ln -s "$_rqn_target" "$_rqn_dest" 2>/dev/null || ! [ -L "$_rqn_dest" ]; then
+ _rqn_nested="$_rqn_dest/${_rqn_target##*/}"
+ if [ -L "$_rqn_nested" ] && [ "$(readlink "$_rqn_nested" 2>/dev/null)" = "$_rqn_target" ]; then
+ rm -f "$_rqn_nested"
+ fi
+ return 1
+ fi
+ rm -f "$_rqn_src"
+ return 0
+ fi
+ if [ -d "$_rqn_src" ]; then
+ mkdir "$_rqn_dest" 2>/dev/null || return 1
+ _rqn_failed=0
+ for _rqn_child in "$_rqn_src"/* "$_rqn_src"/.[!.]* "$_rqn_src"/..?*; do
+ [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue
+ if ! mv "$_rqn_child" "$_rqn_dest/"; then
+ _rqn_failed=1
+ break
+ fi
+ done
+ if [ "$_rqn_failed" -eq 0 ]; then
+ rmdir "$_rqn_src" 2>/dev/null || return 1
+ return 0
+ fi
+ for _rqn_child in "$_rqn_dest"/* "$_rqn_dest"/.[!.]* "$_rqn_dest"/..?*; do
+ [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue
+ mv "$_rqn_child" "$_rqn_src/" || return 1
+ done
+ rmdir "$_rqn_dest" 2>/dev/null || return 1
+ return 1
+ fi
+ if [ -f "$_rqn_src" ]; then
+ # Shell `ln` treats an occupied directory as a container. Refuse that
+ # dest and drop any nested name instead of leaving a hardlink inside it.
+ if ! ln "$_rqn_src" "$_rqn_dest" 2>/dev/null || [ -d "$_rqn_dest" ]; then
+ _rqn_nested="$_rqn_dest/$(basename "$_rqn_src")"
+ [ -f "$_rqn_nested" ] && rm -f "$_rqn_nested"
+ return 1
+ fi
+ rm -f "$_rqn_src"
+ return 0
+ fi
+ return 1
+}
+
+# quarantine_retract_or_restore [expected-link-target]
+# Prove ownership at dest first (inode plus child names for directories).
+# Quarantining before that check would move a concurrent replacement off its
+# original path. Linux overlayfs recycles inodes, so inode alone is not
+# enough — a dest whose children no longer match the recorded publication
+# stays put. After a matching dest is claimed, identity is re-checked in
+# quarantine; a mismatch is restored with no-replace.
+quarantine_retract_or_restore() {
+ _qrr_dest="$1"
+ _qrr_inode="$2"
+ _qrr_link_target="${3-}"
+ if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then
+ return 0
+ fi
+ _qrr_owned=0
+ if [ -n "$_qrr_link_target" ]; then
+ if [ -L "$_qrr_dest" ] &&
+ [ "$(readlink "$_qrr_dest" 2>/dev/null)" = "$_qrr_link_target" ] &&
+ [ "$(skill_link_inode "$_qrr_dest")" = "$_qrr_inode" ]; then
+ _qrr_owned=1
+ fi
+ elif [ "$(skill_dir_identity "$_qrr_dest")" = "$_qrr_inode" ]; then
+ _qrr_owned=1
+ fi
+ if [ "$_qrr_owned" -eq 0 ]; then
+ printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_qrr_dest"
+ return 1
+ fi
+ _qrr_parent="$(dirname "$_qrr_dest")"
+ _qrr_base="$(basename "$_qrr_dest")"
+ _qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1
+ _qrr_payload="$_qrr_root/payload"
+ if ! mv "$_qrr_dest" "$_qrr_payload"; then
+ rmdir "$_qrr_root" 2>/dev/null || rm -rf "$_qrr_root"
+ if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then
+ return 0
+ fi
+ printf ' ⚠️ 无法隔离待回滚 Skill: %s\n' "$_qrr_dest"
+ return 1
+ fi
+ _qrr_owned=0
+ if [ -n "$_qrr_link_target" ]; then
+ if [ -L "$_qrr_payload" ] &&
+ [ "$(readlink "$_qrr_payload" 2>/dev/null)" = "$_qrr_link_target" ] &&
+ [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then
+ _qrr_owned=1
+ fi
+ elif [ "$(skill_dir_identity "$_qrr_payload")" = "$_qrr_inode" ]; then
+ _qrr_owned=1
+ fi
+ if [ "$_qrr_owned" -eq 1 ]; then
+ rm -rf "$_qrr_root" || return 1
+ return 0
+ fi
+ if restore_quarantine_no_replace "$_qrr_payload" "$_qrr_dest"; then
+ rmdir "$_qrr_root" 2>/dev/null || true
+ printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_qrr_dest"
+ return 1
+ fi
+ printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s(并发对象保留于 %s)\n' "$_qrr_dest" "$_qrr_payload"
+ return 1
+}
+
# restore_multi_skill_set
# Removes partial new publications, then restores every old directory from
-# its exact backup path. Paths containing newlines are outside the supported
+# its exact backup path. Dest is claimed into quarantine first; identity is
+# re-checked there so a concurrent replacement is renamed back instead of
+# being deleted by path. Paths containing newlines are outside the supported
# installer path contract; spaces are preserved.
restore_multi_skill_set() {
_rms_published="$1"
_rms_backups="$2"
_rms_ok=1
if [ -f "$_rms_published" ]; then
- while IFS= read -r _rms_dest; do
- [ -n "$_rms_dest" ] || continue
- rm -rf "$_rms_dest" || _rms_ok=0
+ while IFS= read -r _rms_entry; do
+ [ -n "$_rms_entry" ] || continue
+ _rms_dest="${_rms_entry%:*}"
+ _rms_inode="${_rms_entry##*:}"
+ [ "$_rms_dest" != "$_rms_entry" ] || { printf ' ⚠️ 跳过格式异常的发布记录: %s\n' "$_rms_entry"; _rms_ok=0; continue; }
+ quarantine_retract_or_restore "$_rms_dest" "$_rms_inode" || _rms_ok=0
done < "$_rms_published"
fi
if [ -f "$_rms_backups" ]; then
while IFS= read -r _rms_original && IFS= read -r _rms_backup; do
[ -n "$_rms_backup" ] || continue
- if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
+ if ! mkdir -p "$(dirname "$_rms_original")" || ! restore_quarantine_no_replace "$_rms_backup" "$_rms_original"; then
printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup"
_rms_ok=0
fi
@@ -215,6 +490,135 @@ restore_multi_skill_set() {
[ "$_rms_ok" -eq 1 ]
}
+# A link publication manifest stores destination/target/inode triples. Rollback
+# only removes links that still have the identity created by this transaction;
+# a path concurrently replaced with a file, directory, or new link is preserved.
+skill_link_inode() {
+ _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1
+ printf '%s\n' "$_sli_entry" | awk '{print $1}'
+}
+
+# Directory publication identity is inode, a stable child-name list, and a
+# recursive content digest. Inode reuse on Linux overlayfs would otherwise
+# make a concurrent directory look owned, and an in-place content edit after
+# publish (same inode, same names) would otherwise be retracted as this
+# transaction's object. The token has no colon so dest:identity manifest
+# parsing stays dest="${entry%:*}" / identity="${entry##*:}".
+skill_dir_identity() {
+ _sdi_inode="$(skill_link_inode "$1")" || return 1
+ if [ -L "$1" ] || [ ! -d "$1" ]; then
+ printf '%s\n' "$_sdi_inode"
+ return 0
+ fi
+ _sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')"
+ _sdi_digest="$(skill_dir_content_digest "$1")" || return 1
+ printf '%s,%s,%s\n' "$_sdi_inode" "$_sdi_names" "$_sdi_digest"
+}
+
+# Recursive content digest over a published tree, mirroring the Go
+# fingerprint: sorted relative paths emit type+permission bits plus either a
+# content sha256 or the symlink target, and the whole line stream feeds
+# sha256_stdin. Failure detection is asymmetric and best-effort: with no hash
+# tool installed sha256_stdin itself returns 1 and the identity genuinely
+# fails, but an unreadable file only exits the left-hand subshell — /bin/sh
+# has no pipefail, so the pipeline returns sha256_stdin's status and the
+# digest is computed over a truncated stream instead of failing.
+skill_dir_content_digest() {
+ (
+ cd "$1" 2>/dev/null || exit 1
+ find . -print 2>/dev/null |
+ LC_ALL=C sort |
+ while IFS= read -r _sdcd_path; do
+ _sdcd_mode="$(ls -ld "$_sdcd_path" 2>/dev/null | cut -c1-10)"
+ if [ -L "$_sdcd_path" ]; then
+ printf '%s|%s|link|%s\n' "$_sdcd_path" "$_sdcd_mode" "$(readlink "$_sdcd_path" 2>/dev/null)"
+ elif [ -d "$_sdcd_path" ]; then
+ printf '%s|%s|dir\n' "$_sdcd_path" "$_sdcd_mode"
+ elif [ -f "$_sdcd_path" ]; then
+ _sdcd_hash="$(sha256_file "$_sdcd_path")" || exit 1
+ printf '%s|%s|file|%s\n' "$_sdcd_path" "$_sdcd_mode" "$_sdcd_hash"
+ else
+ printf '%s|%s|other\n' "$_sdcd_path" "$_sdcd_mode"
+ fi
+ done
+ ) | sha256_stdin
+}
+
+skill_link_matches() {
+ [ -L "$1" ] || return 1
+ _slm_target="$(readlink "$1" 2>/dev/null)" || return 1
+ [ "$_slm_target" = "$2" ] || return 1
+ _slm_expected_inode="${3-}"
+ [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ]
+}
+
+restore_linked_skill_set() {
+ _rls_published="$1"
+ _rls_backups="$2"
+ _rls_ok=1
+ if [ -f "$_rls_published" ]; then
+ while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do
+ [ -n "$_rls_dest" ] || continue
+ quarantine_retract_or_restore "$_rls_dest" "$_rls_inode" "$_rls_target" || _rls_ok=0
+ done < "$_rls_published"
+ fi
+ restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0
+ [ "$_rls_ok" -eq 1 ]
+}
+
+cleanup_nested_staged_link() {
+ _cnsl_nested="$1/$2"
+ _cnsl_target="$3"
+ _cnsl_inode="${4-}"
+ if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then
+ rm -f "$_cnsl_nested"
+ fi
+}
+
+# publish_skill_dir_no_replace
+# Publishes a staged Skill directory with an atomic no-replace claim: mkdir
+# fails with EEXIST when anything a concurrent writer created occupies the
+# destination, so the claim itself is the existence check — plain `mv` after a
+# backup could still replace a concurrently created object. Staged children
+# then move into the claim one by one (each rename targets a path inside a
+# directory this transaction owns, so no step replaces a foreign object; the
+# staging directory is created under the same umask as the claim, so their
+# modes match by construction). A failed child move relocates the children
+# back and removes only the claim. On success the destination's inode is
+# recorded as : so rollback only ever
+# deletes this transaction's object even when the filesystem recycles the
+# claim inode or the published files are edited in place afterwards.
+publish_skill_dir_no_replace() {
+ _psd_stage="$1"
+ _psd_dest="$2"
+ _psd_manifest="$3"
+
+ if ! mkdir "$_psd_dest" 2>/dev/null; then
+ return 1
+ fi
+ _psd_failed=0
+ for _psd_child in "$_psd_stage"/* "$_psd_stage"/.[!.]* "$_psd_stage"/..?*; do
+ [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue
+ if ! mv "$_psd_child" "$_psd_dest/"; then
+ _psd_failed=1
+ break
+ fi
+ done
+ if [ "$_psd_failed" -eq 0 ]; then
+ _psd_inode="$(skill_dir_identity "$_psd_dest")" || _psd_failed=1
+ if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then
+ return 0
+ fi
+ _psd_failed=1
+ fi
+ for _psd_child in "$_psd_dest"/* "$_psd_dest"/.[!.]* "$_psd_dest"/..?*; do
+ [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue
+ mv "$_psd_child" "$_psd_stage/" || return 1
+ done
+ rmdir "$_psd_dest" 2>/dev/null || return 1
+ return 1
+}
+
# publish_skill_cache
# Stages a complete sibling cache before publishing it. Any copy or publish
# failure leaves the previous cache in place (or in the reported recovery dir
@@ -408,16 +812,99 @@ multi_tree_has_skills() {
return 1
}
-# Move DWS-owned copies out of the generic root once a concrete Agent root is
-# active. This prevents Agents such as Codex from discovering duplicates.
-retire_generic_skill_root() {
+# Exact upstream registry (76 IDs): id|classification|effective-global-root.
+# `-` means no global directory; `.agents/skills` means canonical-direct.
+# Not readonly: the library must stay re-sourceable inside one shell.
+DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills'
+upstream_agent_registry() {
+ for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done
+}
+
+# DWS-only compatibility roots, same id|classification|root format. Qoderwork
+# stays a real non-universal install target; the other four are global paths
+# older DWS installers wrote by mistake, so they are retired like universal
+# roots. Kept separate so the upstream pin above stays byte-comparable.
+DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills'
+legacy_agent_roots() {
+ for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done
+}
+
+# ~/.agents/skills is the canonical store under the universal convention.
+# Universal Agents read it directly; other Agents receive relative links and
+# fall back to copies only when links are unavailable.
+#
+# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned
+# registries above, so a registry edit changes real install behaviour instead of
+# only a test count, and pin/behaviour drift is impossible by construction.
+is_universal_agent_dir() {
+ for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do
+ [ "${_iua_record##*|}" = "$1" ] || continue
+ case "$_iua_record" in
+ *"|U|"*) return 0 ;;
+ esac
+ done
+ return 1
+}
+
+# Effective global roots, in pinned registry order, de-duplicated. Records
+# without a global root (`-`) and canonical-direct records (`.agents/skills`)
+# are not per-Agent targets and are skipped.
+agent_skill_dirs() {
+ { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' '
+ $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }'
+}
+
+resolve_agent_skill_base() {
+ _ras_root="$1"; _ras_agent="$2"
+ case "$_ras_agent" in
+ ".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;;
+ ".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;;
+ ".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;;
+ ".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;;
+ ".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;;
+ ".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;;
+ ".openclaw/skills")
+ for _ras_name in .openclaw .clawdbot .moltbot; do
+ [ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; }
+ done ;;
+ ".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;;
+ ".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;;
+ ".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;;
+ ".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;;
+ ".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;;
+ ".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;;
+ esac
+ printf '%s\n' "$_ras_root/$_ras_agent"
+}
+
+agent_skill_base_detected() {
+ _asbd_agent="$1"; _asbd_base="$2"
+ case "$_asbd_agent" in
+ ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;;
+ ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;;
+ ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
+ *) [ -d "$(dirname "$_asbd_base")" ] ;;
+ esac
+}
+
+same_physical_skill_root() {
+ [ -d "$1" ] && [ -d "$2" ] || return 1
+ # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved
+ # directory into the command substitution, which would silently report two
+ # identical roots as different.
+ _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
+ _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
+ [ "$_sps_left" = "$_sps_right" ]
+}
+
+retire_agent_skill_root() {
_rgs_root="$1"
- _rgs_base="$_rgs_root/.agents/skills"
- _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1
+ _rgs_base="$2"
+ _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1
_rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
- [ -d "$_rgs_victim" ] || continue
+ [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue
if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then
continue
fi
@@ -430,76 +917,162 @@ retire_generic_skill_root() {
rm -rf "$_rgs_stage"
}
+# link_canonical_skills_to_base [bundle-src]
+# is the installed multi bundle and is REQUIRED in multi mode: the
+# link set must come from the bundle, never from the whole canonical store.
+# ~/.agents/skills is now shared, so third-party/user Skills live there too and
+# must not be published into every detected Agent root (they are absent from
+# skills-state.json, so nothing could ever reclaim those links). Go
+# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list
+# the same way.
+link_canonical_skills_to_base() {
+ _lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"; _lcs_bundle="${4-}"
+ _lcs_canonical="$_lcs_root/.agents/skills"
+ mkdir -p "$_lcs_base" || return 1
+ same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
+ if [ "$_lcs_mode" = "mono" ]; then
+ _lcs_names="dws"
+ else
+ [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1
+ _lcs_names=""
+ for _lcs_skill in "$_lcs_bundle"/*/; do
+ [ -f "${_lcs_skill}SKILL.md" ] || continue
+ _lcs_names="$_lcs_names $(basename "$_lcs_skill")"
+ done
+ [ -n "$_lcs_names" ] || return 1
+ fi
+ # Collision pre-flight: the victim loop below deliberately refuses to back up
+ # entries that are not DWS-managed, and publication must never replace them.
+ # Report the exact colliding paths before touching anything, so the fall back
+ # to the legacy full-copy layout is observable and actionable.
+ _lcs_collision=0
+ for _lcs_name in $_lcs_names; do
+ _lcs_dest="$_lcs_base/$_lcs_name"
+ { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue
+ same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue
+ [ "$_lcs_name" = "dws" ] && continue
+ is_managed_multi_skill_dir "$_lcs_dest" && continue
+ printf ' ⚠️ %s 已存在且不是 DWS 安装的 Skill,无法在此建立指向 %s 的共享链接\n' "$_lcs_dest" "$_lcs_canonical/$_lcs_name"
+ _lcs_collision=1
+ done
+ if [ "$_lcs_collision" -eq 1 ]; then
+ printf ' ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;\n'
+ printf ' 移走或改名后重新运行安装即可切换为共享 %s 布局。\n' "$_lcs_canonical"
+ return 1
+ fi
+ _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1
+ _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1
+ _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published"
+ _lcs_stage_token="$(basename "$_lcs_stage")"
+ : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; }
+ : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; }
+ _lcs_publish_names=""
+ for _lcs_name in $_lcs_names; do
+ if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi
+ _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; }
+ _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null)" || {
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
+ }
+ # Publish by creating the link directly at the destination: symlink(2)
+ # refuses an occupied path with EEXIST, so the creation itself is the
+ # atomic no-replace check. `mv` after any pre-check could still replace a
+ # file or symlink a concurrent writer created in between, and `ln -P`
+ # stays unusable (absent from BusyBox `ln`, which silently degraded every
+ # non-universal Agent to the copy layout on Alpine and most containers).
+ if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
+ fi
+ # A directory that appeared at the destination turns `ln -s` into a
+ # container: the link lands inside it under the target basename. Remove
+ # exactly our link after an identity check and roll back; the foreign
+ # directory stays untouched.
+ if [ ! -L "$_lcs_dest" ]; then
+ _lcs_nested_name="${_lcs_link_target##*/}"
+ _lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode=""
+ cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
+ fi
+ _lcs_inode="$(skill_link_inode "$_lcs_dest")" || {
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
+ }
+ if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
+ fi
+ if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then
+ if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1
+ fi
+ printf ' ↪ Skills → %s\n' "$_lcs_dest"
+ done
+ rm -rf "$_lcs_stage"
+}
+
# Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
install_multi_skills_to_root() {
multi_src="$1"
root="$2"
installed=0
- attempted=0
+ attempted=1
failed=0
- idx=0
- specific_agents=0
- for specific_dir in \
- ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
- ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
- ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
- ".trae/skills" ".openclaw/skills" ".hermes/skills"
+ retire_failed=0
+ if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi
+ [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
+ for agent_dir in $(agent_skill_dirs)
do
- [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
- done
- for agent_dir in \
- ".agents/skills" \
- ".claude/skills" \
- ".cursor/skills" \
- ".qoder/skills" \
- ".qoderwork/skills" \
- ".gemini/skills" \
- ".codex/skills" \
- ".zcode/skills" \
- ".github/skills" \
- ".windsurf/skills" \
- ".augment/skills" \
- ".cline/skills" \
- ".amp/skills" \
- ".kiro/skills" \
- ".trae/skills" \
- ".openclaw/skills" \
- ".hermes/skills"
- do
- if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
- idx=$((idx + 1))
- continue
- fi
- base_dir="$root/$agent_dir"
- parent_gate="$(dirname "$base_dir")"
- if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
- idx=$((idx + 1))
- continue
- fi
+ base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
+ agent_skill_base_detected "$agent_dir" "$base_dir" || continue
+ same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
- if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
+ if is_universal_agent_dir "$agent_dir"; then
+ retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
+ continue
+ fi
+ if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then
installed=$((installed + 1))
else
- failed=$((failed + 1))
- printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir"
+ if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
+ printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir"
+ installed=$((installed + 1))
+ else
+ failed=$((failed + 1))
+ fi
fi
- idx=$((idx + 1))
done
- if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
- retire_generic_skill_root "$root" || failed=$((failed + 1))
- fi
- if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
- installed=$((installed + 1))
- fi
if [ "$installed" -eq 0 ]; then
printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'
return 1
fi
+ if [ "$retire_failed" -gt 0 ]; then
+ printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
+ fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装失败\n' "$failed"
return 1
fi
write_skills_state "$multi_src" "install-skills.sh" || return 1
+ printf ' ✅ DWS Skills 安装完成\n'
+ printf ' 统一安装位置:%s/.agents/skills\n' "$root"
+ printf ' 已自动适配本机上检测到的 Agent\n'
+ printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n'
}
_install_multi_to_base() {
@@ -574,12 +1147,7 @@ _install_multi_to_base() {
[ -f "${skill_dir}SKILL.md" ] || continue
_name="$(basename "$skill_dir")"
_dest="$_base/$_name"
- printf '%s\n' "$_dest" >> "$_ms_published" || {
- restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
- rm -rf "$_ms_stage"
- return 1
- }
- if ! mv "$_ms_stage/$_name" "$_dest"; then
+ if ! publish_skill_dir_no_replace "$_ms_stage/$_name" "$_dest" "$_ms_published"; then
printf ' ⚠️ multi Skill 集合发布失败,正在恢复原集合: %s\n' "$_dest"
restore_multi_skill_set "$_ms_published" "$_ms_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n'
rm -rf "$_ms_stage"
@@ -633,12 +1201,7 @@ _install_mono_to_base() {
done
_mono_dest="$_mono_base/$SKILL_NAME"
- printf '%s\n' "$_mono_dest" >> "$_mono_published" || {
- restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
- rm -rf "$_mono_stage"
- return 1
- }
- if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then
+ if ! publish_skill_dir_no_replace "$_mono_stage/$SKILL_NAME" "$_mono_dest" "$_mono_published"; then
printf ' ⚠️ mono Skill 集合发布失败,正在恢复原集合: %s\n' "$_mono_dest"
restore_multi_skill_set "$_mono_published" "$_mono_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n'
rm -rf "$_mono_stage"
@@ -654,84 +1217,48 @@ install_skills_to_root() {
skill_src="$1"
root="$2"
installed=0
- attempted=0
+ attempted=1
failed=0
- idx=0
- specific_agents=0
- for specific_dir in \
- ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
- ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
- ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
- ".trae/skills" ".openclaw/skills" ".hermes/skills"
+ retire_failed=0
+ if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi
+ [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; }
+ for agent_dir in $(agent_skill_dirs)
do
- [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
- done
- for agent_dir in \
- ".agents/skills" \
- ".claude/skills" \
- ".cursor/skills" \
- ".qoder/skills" \
- ".qoderwork/skills" \
- ".gemini/skills" \
- ".codex/skills" \
- ".zcode/skills" \
- ".github/skills" \
- ".windsurf/skills" \
- ".augment/skills" \
- ".cline/skills" \
- ".amp/skills" \
- ".kiro/skills" \
- ".trae/skills" \
- ".openclaw/skills" \
- ".hermes/skills"
- do
- if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
- idx=$((idx + 1))
- continue
- fi
- base_dir="$root/$agent_dir"
- parent_gate="$(dirname "$base_dir")"
- if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
- idx=$((idx + 1))
- continue
- fi
+ base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
+ agent_skill_base_detected "$agent_dir" "$base_dir" || continue
+ same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
- if [ "$root" = "$HOME" ]; then
- label="~/$agent_dir/$SKILL_NAME"
- else
- label="$root/$agent_dir/$SKILL_NAME"
+ if is_universal_agent_dir "$agent_dir"; then
+ retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
+ continue
fi
- if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
+ if link_canonical_skills_to_base "$root" "$base_dir" mono; then
installed=$((installed + 1))
else
- failed=$((failed + 1))
+ if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then
+ printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir"
+ installed=$((installed + 1))
+ else
+ failed=$((failed + 1))
+ fi
fi
- idx=$((idx + 1))
done
- if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
- retire_generic_skill_root "$root" || failed=$((failed + 1))
- fi
- if [ "$attempted" -eq 0 ]; then
- if [ "$root" = "$HOME" ]; then
- flabel="~/.agents/skills/$SKILL_NAME"
- else
- flabel="$root/.agents/skills/$SKILL_NAME"
- fi
- if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
- installed=$((installed + 1))
- else
- failed=$((failed + 1))
- fi
- fi
if [ "$installed" -eq 0 ]; then
printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'
return 1
fi
+ if [ "$retire_failed" -gt 0 ]; then
+ printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
+ fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装 mono Skill 失败\n' "$failed"
return 1
fi
rm -f "$DWS_STATE_ROOT/skills-state.json"
+ printf ' ✅ DWS Skills 安装完成\n'
+ printf ' 统一安装位置:%s/.agents/skills\n' "$root"
+ printf ' 已自动适配本机上检测到的 Agent\n'
+ printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n'
}
# ── Main ─────────────────────────────────────────────────────────────────────
@@ -755,6 +1282,7 @@ main() {
[ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; }
printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION"
curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip"
+ verify_release_asset_checksum "dws-skills.zip" "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK"
extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted"
# Prefer the explicit mono/ subtree; fall back to legacy nested or zip root.
@@ -803,6 +1331,10 @@ main() {
fi
fi
+ # Every transaction of this run has finished, so old stamped archives can no
+ # longer be needed for a rollback.
+ prune_skill_backups
+
printf '\n'
printf ' 📖 Skill includes:\n'
printf ' • SKILL.md — Main skill with product overview and intent routing\n'
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index d9c62a1b..93fdf168 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -52,27 +52,103 @@ $LegacyOfficialMultiSkills = @(
"dingtalk-todo", "dingtalk-wiki", "dws-shared"
)
-# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS).
-$AgentDirs = @(
- ".agents\skills",
- ".claude\skills",
- ".cursor\skills",
- ".qoder\skills",
- ".qoderwork\skills",
- ".gemini\skills",
- ".codex\skills",
- ".zcode\skills",
- ".github\skills",
- ".windsurf\skills",
- ".augment\skills",
- ".cline\skills",
- ".amp\skills",
- ".kiro\skills",
- ".trae\skills",
- ".openclaw\skills",
- ".hermes\skills"
+# Agent registry synchronized with vercel-labs/skills agents.ts (c6f69c6).
+# Universal agents read the canonical store directly; their separate historical
+# global directories are cleanup-only. Non-universal agents get junctions to
+# canonical (or complete copies when junction creation is unavailable).
+$AgentRegistry = @(
+ [pscustomobject]@{ Id = "aider-desk"; Universal = $false; Dir = ".aider-desk\skills" },
+ [pscustomobject]@{ Id = "amp"; Universal = $true; Dir = ".config\agents\skills" },
+ [pscustomobject]@{ Id = "antigravity"; Universal = $true; Dir = ".gemini\antigravity\skills" },
+ [pscustomobject]@{ Id = "antigravity-cli"; Universal = $true; Dir = ".gemini\antigravity-cli\skills" },
+ [pscustomobject]@{ Id = "astrbot"; Universal = $false; Dir = ".astrbot\data\skills" },
+ [pscustomobject]@{ Id = "autohand-code"; Universal = $false; Dir = ".autohand\skills" },
+ [pscustomobject]@{ Id = "augment"; Universal = $false; Dir = ".augment\skills" },
+ [pscustomobject]@{ Id = "bob"; Universal = $false; Dir = ".bob\skills" },
+ [pscustomobject]@{ Id = "claude-code"; Universal = $false; Dir = ".claude\skills" },
+ [pscustomobject]@{ Id = "openclaw"; Universal = $false; Dir = ".openclaw\skills" },
+ [pscustomobject]@{ Id = "cline"; Universal = $true; Dir = ".agents\skills" },
+ [pscustomobject]@{ Id = "codearts-agent"; Universal = $false; Dir = ".codeartsdoer\skills" },
+ [pscustomobject]@{ Id = "codebuddy"; Universal = $false; Dir = ".codebuddy\skills" },
+ [pscustomobject]@{ Id = "codemaker"; Universal = $false; Dir = ".codemaker\skills" },
+ [pscustomobject]@{ Id = "codestudio"; Universal = $false; Dir = ".codestudio\skills" },
+ [pscustomobject]@{ Id = "codex"; Universal = $true; Dir = ".codex\skills" },
+ [pscustomobject]@{ Id = "command-code"; Universal = $false; Dir = ".commandcode\skills" },
+ [pscustomobject]@{ Id = "continue"; Universal = $false; Dir = ".continue\skills" },
+ [pscustomobject]@{ Id = "cortex"; Universal = $false; Dir = ".snowflake\cortex\skills" },
+ [pscustomobject]@{ Id = "crush"; Universal = $false; Dir = ".config\crush\skills" },
+ [pscustomobject]@{ Id = "cursor"; Universal = $true; Dir = ".cursor\skills" },
+ [pscustomobject]@{ Id = "deepagents"; Universal = $true; Dir = ".deepagents\agent\skills" },
+ [pscustomobject]@{ Id = "devin"; Universal = $false; Dir = ".config\devin\skills" },
+ [pscustomobject]@{ Id = "dexto"; Universal = $true; Dir = ".agents\skills" },
+ [pscustomobject]@{ Id = "droid"; Universal = $false; Dir = ".factory\skills" },
+ [pscustomobject]@{ Id = "eve"; Universal = $false; Dir = $null },
+ [pscustomobject]@{ Id = "firebender"; Universal = $true; Dir = ".firebender\skills" },
+ [pscustomobject]@{ Id = "forgecode"; Universal = $false; Dir = ".forge\skills" },
+ [pscustomobject]@{ Id = "gemini-cli"; Universal = $true; Dir = ".gemini\skills" },
+ [pscustomobject]@{ Id = "github-copilot"; Universal = $true; Dir = ".copilot\skills" },
+ [pscustomobject]@{ Id = "goose"; Universal = $false; Dir = ".config\goose\skills" },
+ [pscustomobject]@{ Id = "grok"; Universal = $false; Dir = ".grok\skills" },
+ [pscustomobject]@{ Id = "hermes-agent"; Universal = $false; Dir = ".hermes\skills" },
+ [pscustomobject]@{ Id = "inference-sh"; Universal = $false; Dir = ".inferencesh\skills" },
+ [pscustomobject]@{ Id = "jazz"; Universal = $false; Dir = ".jazz\skills" },
+ [pscustomobject]@{ Id = "junie"; Universal = $false; Dir = ".junie\skills" },
+ [pscustomobject]@{ Id = "iflow-cli"; Universal = $false; Dir = ".iflow\skills" },
+ [pscustomobject]@{ Id = "kilo"; Universal = $false; Dir = ".kilocode\skills" },
+ [pscustomobject]@{ Id = "kimchi"; Universal = $false; Dir = ".config\kimchi\harness\skills" },
+ [pscustomobject]@{ Id = "kimi-code-cli"; Universal = $true; Dir = ".agents\skills" },
+ [pscustomobject]@{ Id = "kiro-cli"; Universal = $false; Dir = ".kiro\skills" },
+ [pscustomobject]@{ Id = "kode"; Universal = $false; Dir = ".kode\skills" },
+ [pscustomobject]@{ Id = "lingma"; Universal = $false; Dir = ".lingma\skills" },
+ [pscustomobject]@{ Id = "loaf"; Universal = $true; Dir = ".agents\skills" },
+ [pscustomobject]@{ Id = "mcpjam"; Universal = $false; Dir = ".mcpjam\skills" },
+ [pscustomobject]@{ Id = "minimax-code"; Universal = $false; Dir = ".minimax\skills" },
+ [pscustomobject]@{ Id = "mistral-vibe"; Universal = $false; Dir = ".vibe\skills" },
+ [pscustomobject]@{ Id = "moxby"; Universal = $false; Dir = ".moxby\skills" },
+ [pscustomobject]@{ Id = "mux"; Universal = $false; Dir = ".mux\skills" },
+ [pscustomobject]@{ Id = "opencode"; Universal = $true; Dir = ".config\opencode\skills" },
+ [pscustomobject]@{ Id = "openhands"; Universal = $false; Dir = ".openhands\skills" },
+ [pscustomobject]@{ Id = "ona"; Universal = $false; Dir = ".ona\skills" },
+ [pscustomobject]@{ Id = "pi"; Universal = $false; Dir = ".pi\agent\skills" },
+ [pscustomobject]@{ Id = "qoder"; Universal = $false; Dir = ".qoder\skills" },
+ [pscustomobject]@{ Id = "qoder-cn"; Universal = $false; Dir = ".qoder-cn\skills" },
+ [pscustomobject]@{ Id = "qwen-code"; Universal = $false; Dir = ".qwen\skills" },
+ [pscustomobject]@{ Id = "replit"; Universal = $true; Dir = ".config\agents\skills" },
+ [pscustomobject]@{ Id = "reasonix"; Universal = $false; Dir = ".reasonix\skills" },
+ [pscustomobject]@{ Id = "rovodev"; Universal = $false; Dir = ".rovodev\skills" },
+ [pscustomobject]@{ Id = "roo"; Universal = $false; Dir = ".roo\skills" },
+ [pscustomobject]@{ Id = "tabnine-cli"; Universal = $false; Dir = ".tabnine\agent\skills" },
+ [pscustomobject]@{ Id = "terramind"; Universal = $false; Dir = ".terramind\skills" },
+ [pscustomobject]@{ Id = "tinycloud"; Universal = $false; Dir = ".tinycloud\skills" },
+ [pscustomobject]@{ Id = "trae"; Universal = $false; Dir = ".trae\skills" },
+ [pscustomobject]@{ Id = "trae-cn"; Universal = $false; Dir = ".trae-cn\skills" },
+ [pscustomobject]@{ Id = "warp"; Universal = $true; Dir = ".agents\skills" },
+ [pscustomobject]@{ Id = "windsurf"; Universal = $false; Dir = ".codeium\windsurf\skills" },
+ [pscustomobject]@{ Id = "zed"; Universal = $true; Dir = ".agents\skills" },
+ [pscustomobject]@{ Id = "zcode"; Universal = $false; Dir = ".zcode\skills" },
+ [pscustomobject]@{ Id = "zencoder"; Universal = $false; Dir = ".zencoder\skills" },
+ [pscustomobject]@{ Id = "zenflow"; Universal = $false; Dir = ".zencoder\skills" },
+ [pscustomobject]@{ Id = "neovate"; Universal = $false; Dir = ".neovate\skills" },
+ [pscustomobject]@{ Id = "pochi"; Universal = $false; Dir = ".pochi\skills" },
+ [pscustomobject]@{ Id = "promptscript"; Universal = $true; Dir = $null },
+ [pscustomobject]@{ Id = "adal"; Universal = $false; Dir = ".adal\skills" },
+ [pscustomobject]@{ Id = "universal"; Universal = $true; Dir = ".config\agents\skills" }
)
+# DWS compatibility targets that are not part of the upstream registry.
+# Qoderwork remains a non-universal install target; the other entries are
+# migration cleanup targets only and intentionally do not count as agents.
+$LegacyAgentCleanupTargets = @(
+ [pscustomobject]@{ Id = "dws-qoderwork"; Universal = $false; Dir = ".qoderwork\skills" },
+ [pscustomobject]@{ Id = "dws-legacy-github"; Universal = $true; Dir = ".github\skills" },
+ [pscustomobject]@{ Id = "dws-legacy-amp"; Universal = $true; Dir = ".amp\skills" },
+ [pscustomobject]@{ Id = "dws-legacy-cline"; Universal = $true; Dir = ".cline\skills" },
+ [pscustomobject]@{ Id = "dws-legacy-windsurf"; Universal = $true; Dir = ".windsurf\skills" }
+)
+
+# Kept as a compatibility surface for policy tests and downstream packagers.
+$AgentDirs = @($AgentRegistry | Where-Object { $null -ne $_.Dir } | ForEach-Object { $_.Dir })
+
# ── Helpers ──────────────────────────────────────────────────────────────────
function Write-Say {
@@ -182,7 +258,255 @@ function Move-SkillPath {
[string]$Source,
[string]$Destination
)
- Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop
+ if (Test-SkillPathLexically -Path $Destination) {
+ throw "Skill move destination already exists: $Destination"
+ }
+ $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
+ if ($sourceItem.PSIsContainer) {
+ # Move-Item treats an existing directory as a container and nests the
+ # source below it. Directory.Move has the exact rename semantics this
+ # transaction requires: an occupied destination fails without touching
+ # either path.
+ [System.IO.Directory]::Move($Source, $Destination)
+ } else {
+ # File.Move replaces an occupied dest on Windows. Copy without
+ # overwrite refuses that dest, then the source is removed only after
+ # the exclusive copy succeeds.
+ [System.IO.File]::Copy($Source, $Destination, $false)
+ try {
+ Remove-SkillPathLexically -Path $Source
+ } catch {
+ $removeErr = $_
+ try {
+ Remove-SkillPathLexically -Path $Destination
+ } catch {
+ throw "Skill move state uncertain; source $Source and dest $Destination retained: $removeErr; retract failed: $_"
+ }
+ throw $removeErr
+ }
+ }
+}
+
+function Test-CrossDeviceMoveError {
+ param([System.Management.Automation.ErrorRecord]$Record)
+ $exception = $Record.Exception
+ while ($null -ne $exception) {
+ # Win32 ERROR_NOT_SAME_DEVICE is 17 (0x11). Move-Item surfaces it as
+ # the low word of an IOException HRESULT.
+ if (($exception.HResult -band 0xffff) -eq 17) { return $true }
+ $exception = $exception.InnerException
+ }
+ return $false
+}
+
+function Copy-SkillPathMetadata {
+ param($SourceItem, [string]$Destination)
+ (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes
+ $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
+ if ($nativeWindows) {
+ Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop
+ } else {
+ $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName)
+ [System.IO.File]::SetUnixFileMode($Destination, $mode)
+ }
+}
+
+function Get-SkillPathPermissionFingerprint {
+ param([string]$Path)
+ $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop"
+ if ($nativeWindows) {
+ return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl
+ }
+ return [string][System.IO.File]::GetUnixFileMode($Path)
+}
+
+function Copy-SkillPathLexically {
+ param([string]$Source, [string]$Destination)
+ $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
+ if ($item.LinkType) {
+ $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" }
+ New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null
+ return
+ }
+ if ($item.PSIsContainer) {
+ New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null
+ foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) {
+ Copy-SkillPathLexically -Source $child.FullName -Destination (Join-Path $Destination $child.Name)
+ }
+ Copy-SkillPathMetadata -SourceItem $item -Destination $Destination
+ return
+ }
+ if ($item -isnot [System.IO.FileInfo]) {
+ throw "不支持复制特殊 Skill 路径 $Source"
+ }
+ [System.IO.File]::Copy($Source, $Destination, $false)
+ Copy-SkillPathMetadata -SourceItem $item -Destination $Destination
+}
+
+function Assert-SkillPathCopy {
+ param([string]$Source, [string]$Destination)
+ $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop
+ $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop
+ if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or
+ $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) {
+ throw "Skill 路径类型不一致: $Source != $Destination"
+ }
+ if ($sourceItem.LinkType) {
+ if ($sourceItem.LinkType -ne $destinationItem.LinkType -or
+ ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) {
+ throw "Skill 链接目标不一致: $Source != $Destination"
+ }
+ return
+ }
+ if ((Get-SkillPathPermissionFingerprint -Path $Source) -ne
+ (Get-SkillPathPermissionFingerprint -Path $Destination)) {
+ throw "Skill 路径权限不一致: $Source != $Destination"
+ }
+ if ($sourceItem.PSIsContainer) {
+ $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name)
+ $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name)
+ if ($sourceChildren.Count -ne $destinationChildren.Count) {
+ throw "Skill 目录项数量不一致: $Source != $Destination"
+ }
+ for ($i = 0; $i -lt $sourceChildren.Count; $i++) {
+ if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) {
+ throw "Skill 目录项不一致: $Source != $Destination"
+ }
+ Assert-SkillPathCopy -Source $sourceChildren[$i].FullName -Destination $destinationChildren[$i].FullName
+ }
+ return
+ }
+ if ($sourceItem.Length -ne $destinationItem.Length) {
+ throw "Skill 文件大小不一致: $Source != $Destination"
+ }
+ $sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash
+ $destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash
+ if ($sourceHash -ne $destinationHash) {
+ throw "Skill 文件内容摘要不一致: $Source != $Destination"
+ }
+}
+
+function Remove-SkillPathLexically {
+ param([string]$Path)
+ $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -and !$item.LinkType) {
+ Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
+ } else {
+ Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
+ }
+}
+
+# Removes a staging directory that may hold unpublished junction/symlink
+# children. Every link child must be removed non-recursively first: Windows
+# PowerShell 5.1 (the advertised irm|iex surface) can follow reparse points
+# during Remove-Item -Recurse and would delete the canonical store's contents
+# through the staged link. CI runs pwsh 7, which masks this behavior.
+function Remove-LinkStageRoot {
+ param([string]$StageRoot)
+ if (!(Test-Path -LiteralPath $StageRoot)) { return $true }
+ $ok = $true
+ foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) {
+ try { Remove-SkillPathLexically -Path $child.FullName } catch { $ok = $false }
+ }
+ try {
+ Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop
+ } catch {
+ $ok = $false
+ }
+ return $ok
+}
+
+# Resolves a path to its physical location, dereferencing junction/symlink
+# reparse points at any depth. Resolve-Path alone is lexical and never
+# recognizes DWS's own junctions, so every rerun would back them up and
+# recreate them. Mirrors EvalSymlinks (Go), realpathSync (npm), and cd -P
+# (shell) on the other installer surfaces.
+function Get-PhysicalSkillPath {
+ param([string]$Path, [int]$Depth = 0)
+ if ($Depth -gt 40) { return $null }
+ $parent = Split-Path $Path -Parent
+ if ([string]::IsNullOrWhiteSpace($parent)) { return $Path }
+ $parentPhysical = Get-PhysicalSkillPath -Path $parent -Depth ($Depth + 1)
+ if ($null -eq $parentPhysical) { return $null }
+ $leaf = Split-Path $Path -Leaf
+ if ([string]::IsNullOrEmpty($leaf)) { return $parentPhysical }
+ $candidate = Join-Path $parentPhysical $leaf
+ try { $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop } catch { return $null }
+ $target = @($item.Target) | Where-Object { $_ } | Select-Object -First 1
+ if ($item.LinkType -and $target) {
+ $targetPath = ""
+ if ([System.IO.Path]::IsPathRooted([string]$target)) {
+ $targetPath = [string]$target
+ } else {
+ $targetPath = Join-Path $parentPhysical ([string]$target)
+ }
+ # Resolve the target recursively as well. A custom HOME or canonical
+ # root can itself sit below another junction, which must compare equal
+ # to the fully physical path just like EvalSymlinks/realpath do.
+ return Get-PhysicalSkillPath -Path ([System.IO.Path]::GetFullPath($targetPath)) -Depth ($Depth + 1)
+ }
+ return $candidate
+}
+
+# Same-volume moves remain atomic. For a cross-volume backup/restore, stage on
+# the destination filesystem, copy links lexically, verify, publish, then
+# remove the source. Before source removal every failure keeps the original;
+# a removal failure deliberately leaves both verified copies and fails loud.
+function Move-SkillPathRecoverably {
+ param([string]$Source, [string]$Destination)
+ if (Test-SkillPathLexically -Path $Destination) { throw "移动目标已存在: $Destination" }
+ $destinationParent = Split-Path $Destination -Parent
+ New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null
+ try {
+ Move-SkillPath -Source $Source -Destination $Destination
+ return
+ } catch {
+ if (!(Test-CrossDeviceMoveError -Record $_)) { throw }
+ }
+
+ $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N"))
+ $stage = Join-Path $stageRoot "payload"
+ New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null
+ $published = $false
+ try {
+ Copy-SkillPathLexically -Source $Source -Destination $stage
+ Assert-SkillPathCopy -Source $Source -Destination $stage
+ Move-SkillPath -Source $stage -Destination $Destination
+ $published = $true
+ $publication = [pscustomobject]@{ Path = $Destination; Source = $Source }
+ try {
+ Assert-SkillPathCopy -Source $Source -Destination $Destination
+ if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) {
+ throw "Skill staging 清理失败: $stageRoot"
+ }
+ } catch {
+ $postErr = $_
+ try {
+ Remove-PublishedSkillPathSafely -Record $publication
+ } catch {
+ throw "Skill 移动状态不确定:$postErr;撤回目标 $Destination 失败: $_;源 $Source 与目标 $Destination 均保留"
+ }
+ throw "Skill 移动失败,目标已撤回,原路径保留 ${Source}: $postErr"
+ }
+ try {
+ Remove-SkillPathLexically -Path $Source
+ } catch {
+ throw "Skill 目标已发布但源路径删除失败(源 $Source 与目标 $Destination 均保留): $_"
+ }
+ if (Test-SkillPathLexically -Path $Source) {
+ throw "Skill 目标已发布但源路径仍存在(源 $Source 与目标 $Destination 均保留)"
+ }
+ } catch {
+ $failure = $_
+ if (Test-Path -LiteralPath $stageRoot) {
+ if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) {
+ if (-not $published) {
+ throw "$failure;跨设备 Skill staging 清理失败 $stageRoot(备份与原路径均保留)"
+ }
+ }
+ }
+ throw $failure
+ }
}
function Get-Arch {
@@ -264,6 +588,26 @@ function Get-GiteeAssetUrl {
return ""
}
+function Assert-ReleaseAssetChecksum {
+ param([string]$AssetPath, [string]$AssetName, [string]$TempDir)
+ if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
+ if (-not $checksumUrl) { Write-Err "Could not resolve checksums.txt for $Version." }
+ $checksumPath = Join-Path $TempDir "checksums.txt"
+ try {
+ Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing -ErrorAction Stop
+ } catch {
+ Write-Err "Could not download checksums.txt for $Version; refusing unverified $AssetName."
+ }
+ $expectedLine = Get-Content -LiteralPath $checksumPath | Where-Object {
+ $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$"
+ } | Select-Object -First 1
+ if (-not $expectedLine) { Write-Err "$AssetName is missing from checksums.txt." }
+ $expected = ($expectedLine -split '\s+')[0].ToLowerInvariant()
+ $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
+ if ($actual -ne $expected) { Write-Err "SHA256 checksum mismatch for $AssetName. Expected $expected, got $actual." }
+ Write-Say "✅ SHA256 checksum verified: $AssetName"
+}
+
function Resolve-Source {
# Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable.
if ($GiteeRepo -ne "") { return }
@@ -389,6 +733,116 @@ function Publish-SkillCache {
}
}
+# Get-SkillBackupName encodes the HOME-relative path of a backed-up Skill
+# directory ('.codex\skills\dingtalk-chat' → '.codex-skills-dingtalk-chat') so
+# copies retired from different Agent roots stay distinguishable inside one
+# stamp. Mirrors build/npm/install.js and internal/upgrade/paths.go. Paths
+# outside HOME fall back to the bare leaf.
+function Get-SkillBackupName {
+ param([string]$Dir)
+ $leaf = Split-Path $Dir -Leaf
+ try {
+ $full = [System.IO.Path]::GetFullPath($Dir)
+ $root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/'))
+ } catch {
+ return $leaf
+ }
+ if ([string]::IsNullOrWhiteSpace($root)) { return $leaf }
+ foreach ($sep in @('\', '/')) {
+ $prefix = $root + $sep
+ if ($full.Length -gt $prefix.Length -and
+ $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
+ $rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/'))
+ if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') }
+ }
+ }
+ return $leaf
+}
+
+# SkillBackupKeep bounds $HOME\.dws\skill-backups growth: only the newest
+# stamped backup directories are kept, matching skillBackupKeep and
+# pruneSkillBackups in internal/upgrade/paths.go.
+$SkillBackupKeep = 5
+$script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+
+# Ownership marker: every stamp root DWS creates carries .dws-skill-backup
+# with exactly "dws skill backup v1" + LF — the same bytes internal/upgrade/
+# paths.go, install.sh, and build/npm/install.js write. A stamp-shaped name
+# alone is not ownership proof, so pruning only deletes directories whose
+# marker content verifies.
+$SkillBackupMarkerFile = ".dws-skill-backup"
+$SkillBackupMarkerBody = "dws skill backup v1"
+
+# Write-SkillBackupMarker stamps a freshly created stamp root as DWS-owned.
+# [IO.File]::WriteAllText pins the exact LF-terminated bytes (Set-Content
+# would append a platform newline on Windows PowerShell 5.1).
+function Write-SkillBackupMarker {
+ param([string]$Root)
+ [System.IO.File]::WriteAllText(
+ (Join-Path $Root $SkillBackupMarkerFile),
+ "$SkillBackupMarkerBody`n",
+ [System.Text.UTF8Encoding]::new($false))
+}
+
+# Test-SkillBackupMarker reports whether a stamp root carries the ownership
+# marker. The check normalizes CRLF→LF and drops trailing newlines before
+# comparing, so this surface accepts every surface's exact-LF bytes (writer
+# and checker agree); any other content, or a missing/unreadable marker,
+# means foreign data.
+function Test-SkillBackupMarker {
+ param([string]$Dir)
+ try {
+ $marker = Join-Path $Dir $SkillBackupMarkerFile
+ if (![System.IO.File]::Exists($marker)) { return $false }
+ $body = [System.IO.File]::ReadAllText($marker).Replace("`r`n", "`n").TrimEnd("`r", "`n")
+ return ($body -eq $SkillBackupMarkerBody)
+ } catch {
+ return $false
+ }
+}
+
+# Removes a whole stamp root child-first without ever following a reparse
+# point: link children are deleted non-recursively, real directories are
+# recursed the same way, and only an emptied directory is removed. Backup
+# trees can contain junctions/symlinks (victims are collected before the
+# physical-equality filter), and Windows PowerShell 5.1 can follow reparse
+# points during Remove-Item -Recurse — the invariant Remove-LinkStageRoot
+# enforces for staging roots, applied at every depth here.
+function Remove-SkillBackupTreeLexically {
+ param([string]$Path)
+ $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
+ if ($item.PSIsContainer -and !$item.LinkType) {
+ foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) {
+ Remove-SkillBackupTreeLexically -Path $child.FullName
+ }
+ }
+ Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
+}
+
+# Remove-OldSkillBackups deletes the oldest stamped backup directories once
+# more than $SkillBackupKeep remain. Stamps sort lexicographically in
+# chronological order, so name order is age order. Roots created during this
+# run are never pruned: an in-flight transaction still needs them to roll
+# back. Best-effort — a prune failure never fails the install.
+function Remove-OldSkillBackups {
+ $root = Join-Path $HOME ".dws\skill-backups"
+ if (!(Test-Path -LiteralPath $root -PathType Container)) { return }
+ # Only directories whose names match the DWS backup stamp format (UTC
+ # yyyyMMdd-HHmmss, optional -N collision suffix) AND whose ownership
+ # marker verifies are candidates; anything else is foreign data —
+ # preserved and never counted against $SkillBackupKeep.
+ $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' -and (Test-SkillBackupMarker -Dir $_.FullName) } |
+ Sort-Object -Property Name)
+ $excess = $dirs.Count - $SkillBackupKeep
+ foreach ($dir in $dirs) {
+ if ($excess -le 0) { break }
+ if ($script:SkillBackupRootsThisRun.Contains($dir.FullName)) { continue }
+ try { Remove-SkillBackupTreeLexically -Path $dir.FullName } catch { }
+ $excess--
+ }
+}
+
# Backup-SkillDir moves $Dir into $HOME\.dws\skill-backups\\
# instead of destroying it (non-interactive installs cannot confirm, so
# removals must stay reversible). Missing paths are a no-op success. On any
@@ -400,14 +854,21 @@ function Backup-SkillDir {
[ref]$BackupPath
)
if ($null -ne $BackupPath) { $BackupPath.Value = "" }
- if (!(Test-Path $Dir -PathType Container)) { return $true }
+ if (!(Test-SkillPathLexically -Path $Dir)) { return $true }
$backupRoot = Join-Path $HOME ".dws\skill-backups"
$stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss")
- $name = Split-Path $Dir -Leaf
- $target = Join-Path (Join-Path $backupRoot $stamp) $name
+ $name = Get-SkillBackupName -Dir $Dir
+ $targetRoot = Join-Path $backupRoot $stamp
+ $target = Join-Path $targetRoot $name
$i = 1
- while (Test-Path $target) {
- $target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name
+ # Bump not only when \ is taken but also when the stamp root
+ # itself exists without a verified ownership marker: a same-second
+ # foreign directory must never be stamped DWS-owned and pruned later. A
+ # marker-verified root from this run's same second stays reusable.
+ while ((Test-SkillPathLexically -Path $target) -or
+ ((Test-SkillPathLexically -Path $targetRoot) -and !(Test-SkillBackupMarker -Dir $targetRoot))) {
+ $targetRoot = Join-Path $backupRoot "$stamp-$i"
+ $target = Join-Path $targetRoot $name
$i++
if ($i -gt 1000) {
Write-Say "⚠️ 备份目录冲突,保留原目录 $Dir"
@@ -416,16 +877,97 @@ function Backup-SkillDir {
}
try {
New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null
- Move-SkillPath -Source $Dir -Destination $target
+ # Stamp ownership immediately after creating the stamp root and
+ # before any skill directory moves into it, so an interrupted backup
+ # can never leave an unmarked (never-prunable) stamp behind.
+ Write-SkillBackupMarker -Root $targetRoot
} catch {
- Write-Say "⚠️ 备份失败,保留原目录 $Dir"
+ # The removal stays non-recursive so a pre-existing non-empty root
+ # (foreign data) is never destroyed; a failed marker write must not
+ # leave an empty unowned stamp root behind either.
+ Remove-Item -LiteralPath $targetRoot -Force -ErrorAction SilentlyContinue
+ Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_"
+ return $false
+ }
+ try {
+ Move-SkillPathRecoverably -Source $Dir -Destination $target
+ } catch {
+ Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_"
return $false
}
if ($null -ne $BackupPath) { $BackupPath.Value = $target }
+ try {
+ $script:SkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($targetRoot)) | Out-Null
+ Remove-OldSkillBackups
+ } catch {
+ Write-Say "⚠️ 旧备份清理失败(备份本身已成功): $_"
+ }
Write-Say " × 已备份并移除 $Dir → $target"
return $true
}
+function Get-SkillLinkSignature {
+ param([string]$Path)
+ try {
+ $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
+ } catch {
+ return $null
+ }
+ if (!$item.LinkType) { return $null }
+ $targets = @($item.Target) | ForEach-Object { [string]$_ }
+ return ([string]$item.LinkType + "`0" + ($targets -join "`0"))
+}
+
+function New-PublishedSkillLinkRecord {
+ param([string]$Path)
+ $signature = Get-SkillLinkSignature -Path $Path
+ if ([string]::IsNullOrEmpty($signature)) {
+ throw "published Skill path is not a link: $Path"
+ }
+ return [pscustomobject]@{ Path = $Path; LinkSignature = $signature }
+}
+
+function New-PublishedSkillCopyRecord {
+ param([string]$Path, [string]$Source)
+ Assert-SkillPathCopy -Source $Source -Destination $Path
+ return [pscustomobject]@{ Path = $Path; Source = $Source }
+}
+
+function Remove-PublishedSkillPathSafely {
+ param($Record)
+ $path = [string]$Record.Path
+ $parent = Split-Path $path -Parent
+ $quarantine = Join-Path $parent (".dws-link-rollback-" + [guid]::NewGuid().ToString("N"))
+
+ # Claim the current directory entry with an exact same-filesystem rename.
+ # If another process replaced our link, verification below fails and that
+ # object is moved back instead of ever being recursively removed.
+ Move-SkillPath -Source $path -Destination $quarantine
+ try {
+ if ($Record.LinkSignature) {
+ if ((Get-SkillLinkSignature -Path $quarantine) -ne [string]$Record.LinkSignature) {
+ throw "发布链接已被其他进程替换,拒绝删除"
+ }
+ } elseif ($Record.Source) {
+ Assert-SkillPathCopy -Source ([string]$Record.Source) -Destination $quarantine
+ } else {
+ throw "发布路径缺少事务身份,拒绝删除"
+ }
+ Remove-SkillPathLexically -Path $quarantine
+ } catch {
+ $failure = $_
+ if (Test-SkillPathLexically -Path $quarantine) {
+ try {
+ if (Test-SkillPathLexically -Path $path) { throw "原路径已被占用" }
+ Move-SkillPath -Source $quarantine -Destination $path
+ } catch {
+ throw "$failure;并发对象保留于 $quarantine`: $_"
+ }
+ }
+ throw $failure
+ }
+}
+
function Restore-MultiSkillSet {
param(
[array]$Published,
@@ -433,23 +975,29 @@ function Restore-MultiSkillSet {
)
$ok = $true
for ($i = $Published.Count - 1; $i -ge 0; $i--) {
+ $publishedItem = $Published[$i]
+ $publishedPath = if ($publishedItem -is [string]) { $publishedItem } else { [string]$publishedItem.Path }
try {
- if (Test-Path $Published[$i]) {
- Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop
+ if (Test-SkillPathLexically -Path $publishedPath) {
+ if (!($publishedItem -is [string])) {
+ Remove-PublishedSkillPathSafely -Record $publishedItem
+ } else {
+ Remove-SkillPathLexically -Path $publishedPath
+ }
}
} catch {
- Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_"
+ Write-Say "⚠️ 无法移除失败发布目录 $publishedPath`: $_"
$ok = $false
}
}
for ($i = $Backups.Count - 1; $i -ge 0; $i--) {
$item = $Backups[$i]
try {
- if (Test-Path $item.Original) {
+ if (Test-SkillPathLexically -Path $item.Original) {
throw "恢复目标仍存在"
}
New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null
- Move-SkillPath -Source $item.Backup -Destination $item.Original
+ Move-SkillPathRecoverably -Source $item.Backup -Destination $item.Original
} catch {
Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_"
$ok = $false
@@ -458,13 +1006,81 @@ function Restore-MultiSkillSet {
return $ok
}
-function Move-GenericSkillRootToBackup {
- param([string]$Root)
+function Test-SkillPathLexically {
+ param([string]$Path)
+ if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
+ try {
+ Get-Item -LiteralPath $Path -Force -ErrorAction Stop | Out-Null
+ return $true
+ } catch {
+ $parent = Split-Path $Path -Parent
+ $leaf = Split-Path $Path -Leaf
+ if ([string]::IsNullOrWhiteSpace($parent) -or !(Test-Path $parent -PathType Container)) { return $false }
+ return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -eq $leaf } | Select-Object -First 1)
+ }
+}
+
+function Resolve-AgentSkillBase {
+ param([string]$Root, $Agent)
+ if ($null -eq $Agent.Dir) { return $null }
+ switch ($Agent.Id) {
+ "autohand-code" { if ($env:AUTOHAND_HOME) { return (Join-Path $env:AUTOHAND_HOME "skills") } }
+ "claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return (Join-Path $env:CLAUDE_CONFIG_DIR "skills") } }
+ "codex" { if ($env:CODEX_HOME) { return (Join-Path $env:CODEX_HOME "skills") } }
+ "grok" { if ($env:GROK_HOME) { return (Join-Path $env:GROK_HOME "skills") } }
+ "hermes-agent" { if ($env:HERMES_HOME) { return (Join-Path $env:HERMES_HOME "skills") } }
+ "mistral-vibe" { if ($env:VIBE_HOME) { return (Join-Path $env:VIBE_HOME "skills") } }
+ "openclaw" {
+ foreach ($name in @(".openclaw", ".clawdbot", ".moltbot")) {
+ $candidate = Join-Path $Root $name
+ if (Test-Path $candidate -PathType Container) { return (Join-Path $candidate "skills") }
+ }
+ }
+ { $_ -in @("amp", "replit", "universal") } {
+ $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
+ return (Join-Path $configHome "agents\skills")
+ }
+ { $_ -in @("crush", "devin", "goose", "opencode") } {
+ $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
+ $child = switch ($Agent.Id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; default { "opencode\skills" } }
+ return (Join-Path $configHome $child)
+ }
+ "kimchi" {
+ $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" }
+ return (Join-Path $configHome "kimchi\harness\skills")
+ }
+ }
+ return (Join-Path $Root $Agent.Dir)
+}
+
+function Test-AgentSkillBaseDetected {
+ param([string]$BaseDir, $Agent)
+ $parent = Split-Path $BaseDir -Parent
+ switch ($Agent.Id) {
+ "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
+ "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container }
+ "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) }
+ "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) }
+ default { return Test-Path $parent -PathType Container }
+ }
+}
+
+function Test-SamePhysicalSkillRoot {
+ param([string]$Left, [string]$Right)
+ if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false }
+ $leftPhysical = Get-PhysicalSkillPath -Path $Left
+ $rightPhysical = Get-PhysicalSkillPath -Path $Right
+ if ($null -eq $leftPhysical -or $null -eq $rightPhysical) { return $false }
+ return $leftPhysical.TrimEnd([char[]]@('\', '/')) -ieq $rightPhysical.TrimEnd([char[]]@('\', '/'))
+}
+
+function Move-AgentSkillRootToBackup {
+ param([string]$Root, [string]$BaseDir)
- $baseDir = Join-Path $Root ".agents\skills"
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $baseDir $SkillName))
- foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) {
+ foreach ($existing in Get-ChildItem -Path $baseDir -Force -ErrorAction SilentlyContinue) {
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) {
$victims.Add($existing.FullName)
}
@@ -476,7 +1092,7 @@ function Move-GenericSkillRootToBackup {
if (!$seen.Add($victim)) { continue }
$backupPath = ""
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
- throw "通用 Skill 副本备份失败: $victim"
+ throw "Agent Skill 旧副本备份失败: $victim"
}
if ($backupPath) {
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
@@ -485,11 +1101,81 @@ function Move-GenericSkillRootToBackup {
return $true
} catch {
Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null
- Write-Say "⚠️ 通用 Skill 副本迁移失败,已回滚: $_"
+ Write-Say "⚠️ Agent Skill 旧副本迁移失败,已回滚: $_"
return $false
}
}
+function Publish-CanonicalSkillLinks {
+ param([string]$Root, [string]$BaseDir, [string]$Mode, [string[]]$BundleNames = @())
+ $canonical = Join-Path $Root ".agents\skills"
+ if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null }
+ if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true }
+ $stageRoot = Join-Path $BaseDir (".dws-link-set-" + [guid]::NewGuid().ToString("N"))
+ $backups = @()
+ $published = @()
+ try {
+ New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
+ if ($Mode -eq "mono") {
+ $names = @($SkillName)
+ } elseif ($BundleNames.Count -gt 0) {
+ # The canonical store is SHARED: enumerate the link set from the
+ # installed bundle, never from ~\.agents\skills, or third-party and
+ # user skills would be republished into every Agent root.
+ $names = @($BundleNames)
+ } else {
+ # No bundle list means we cannot tell DWS skills apart from the
+ # user's own entries in the shared store. Fail this Agent (the
+ # caller degrades to a copy install) instead of guessing, matching
+ # link_canonical_skills_to_base in scripts/install-skills.sh.
+ throw "multi mode requires the installed bundle skill names"
+ }
+ $publishNames = @()
+ foreach ($name in $names) {
+ if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue }
+ $absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))
+ New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget -ErrorAction Stop | Out-Null
+ $publishNames += $name
+ }
+ $victims = [System.Collections.Generic.List[string]]::new()
+ $victims.Add((Join-Path $BaseDir $SkillName))
+ foreach ($existing in Get-ChildItem -Path $BaseDir -Force -ErrorAction SilentlyContinue) {
+ if ($existing.FullName -eq $stageRoot) { continue }
+ if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) }
+ }
+ # Every published name replaces whatever occupies its destination, even
+ # when that copy predates central ownership metadata; the loop below
+ # still skips destinations that are already correct links.
+ foreach ($name in $names) {
+ $victims.Add((Join-Path $BaseDir $name))
+ }
+ $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($victim in $victims) {
+ if (!$seen.Add($victim)) { continue }
+ if (Test-SamePhysicalSkillRoot -Left $victim -Right (Join-Path $canonical (Split-Path $victim -Leaf))) { continue }
+ $backupPath = ""
+ if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { throw "Skill 备份失败: $victim" }
+ if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } }
+ }
+ foreach ($name in $publishNames) {
+ $dest = Join-Path $BaseDir $name
+ Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest
+ # Only record a publication after the staged junction has occupied
+ # the exact destination. Rollback also checks this identity before
+ # deleting, so a concurrent user directory is never removed.
+ $published += New-PublishedSkillLinkRecord -Path $dest
+ Write-Say "↪ Skills → $dest"
+ }
+ return $true
+ } catch {
+ Restore-MultiSkillSet -Published $published -Backups $backups | Out-Null
+ Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)"
+ return $false
+ } finally {
+ Remove-LinkStageRoot -StageRoot $stageRoot | Out-Null
+ }
+}
+
function Copy-SkillToDir {
param([string]$SkillSrc, [string]$Dest, [string]$Label)
@@ -616,26 +1302,7 @@ function Install-Binary {
$archivePath = Join-Path $tmpDir $archiveName
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing
- # Download and verify SHA256 checksum
- if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
- try {
- $checksumPath = Join-Path $tmpDir "checksums.txt"
- Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing
- $checksumContent = Get-Content $checksumPath
- $expectedLine = $checksumContent | Where-Object { $_ -match [regex]::Escape($archiveName) }
- if ($expectedLine) {
- $expected = ($expectedLine -split '\s+')[0]
- $actual = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower()
- if ($actual -ne $expected.ToLower()) {
- Write-Err "SHA256 checksum mismatch! Expected $expected, got $actual. Aborting."
- }
- Write-Say "✅ SHA256 checksum verified"
- } else {
- Write-Say "⚠️ Archive not found in checksums.txt; skipping verification"
- }
- } catch {
- Write-Say "⚠️ Could not download checksums.txt; skipping verification"
- }
+ Assert-ReleaseAssetChecksum -AssetPath $archivePath -AssetName $archiveName -TempDir $tmpDir
Write-Say "📦 Extracting..."
Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force
@@ -784,8 +1451,8 @@ function Install-MonoToBase {
}
}
- $published += $dest
Move-SkillPath -Source $stagedSkill -Destination $dest
+ $published += New-PublishedSkillCopyRecord -Path $dest -Source $SkillSrc
} catch {
$transactionError = $_
if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) {
@@ -811,46 +1478,37 @@ function Install-SkillsToHomes {
)
$installed = 0
- $attempted = 0
+ $attempted = 1
$failed = 0
- $specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
- Test-Path (Split-Path (Join-Path $Root $_) -Parent)
- })
- for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
- if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
- $agentDir = $AgentDirs[$i]
- $baseDir = Join-Path $Root $agentDir
- $parentGate = Split-Path $baseDir -Parent
- if ($i -gt 0 -and !(Test-Path $parentGate)) {
+ $canonical = Join-Path $Root ".agents\skills"
+ if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ }
+ if ($installed -eq 0) { return $false }
+ $seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) {
+ $baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent
+ if ([string]::IsNullOrWhiteSpace($baseDir)) { continue }
+ $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/'))
+ if (!$seenBases.Add($baseKey)) { continue }
+ if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue }
+ if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
+ $attempted++
+ if ($agent.Universal) {
+ # Cleanup-only migration: universal Agents read the canonical store
+ # directly, so nothing is installed here. Leaving an obsolete copy
+ # behind is a warning, never a reason to fail an install whose
+ # canonical store and links all succeeded.
+ if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) {
+ Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir"
+ }
continue
}
- $attempted++
- if ($Root -eq $HOME) {
- $label = "~\$agentDir\$SkillName"
- } else {
- $label = Join-Path $Root (Join-Path $agentDir $SkillName)
- }
- $copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label
- if ($copied) {
+ if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") {
$installed++
} else {
- $failed++
- }
- }
- if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
- if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
- }
- if ($attempted -eq 0) {
- $fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName
- if ($Root -eq $HOME) {
- $flabel = "~\.agents\skills\$SkillName"
- } else {
- $flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName)
- }
- if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) {
- $installed++
- } else {
- $failed++
+ if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) {
+ Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用"
+ $installed++
+ } else { $failed++ }
}
}
if ($installed -eq 0) {
@@ -862,6 +1520,10 @@ function Install-SkillsToHomes {
return $false
}
Remove-Item -LiteralPath (Join-Path $SkillStateRoot "skills-state.json") -Force -ErrorAction SilentlyContinue
+ Write-Say "✅ DWS Skills 安装完成"
+ Write-Say " 统一安装位置:$canonical"
+ Write-Say " 已自动适配本机上检测到的 Agent"
+ Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
return $true
}
@@ -889,35 +1551,40 @@ function Install-MultiSkillsToHomes {
)
$installed = 0
- $attempted = 0
+ $attempted = 1
$failed = 0
- $specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
- Test-Path (Split-Path (Join-Path $Root $_) -Parent)
- })
- for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
- if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
- $agentDir = $AgentDirs[$i]
- $baseDir = Join-Path $Root $agentDir
- $parentGate = Split-Path $baseDir -Parent
- if ($i -gt 0 -and !(Test-Path $parentGate)) {
+ $canonical = Join-Path $Root ".agents\skills"
+ # The link set must come from the installed bundle, not from the shared
+ # canonical store (which may also hold user/third-party skills).
+ $bundleNames = @(Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue | Where-Object {
+ Test-Path (Join-Path $_.FullName "SKILL.md")
+ } | ForEach-Object { $_.Name })
+ if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ }
+ if ($installed -eq 0) { return $false }
+ $seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) {
+ $baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent
+ if ([string]::IsNullOrWhiteSpace($baseDir)) { continue }
+ $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/'))
+ if (!$seenBases.Add($baseKey)) { continue }
+ if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue }
+ if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue }
+ $attempted++
+ if ($agent.Universal) {
+ # Cleanup-only migration (see Install-SkillsToHomes): a retire
+ # failure must not fail an otherwise complete install.
+ if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) {
+ Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir"
+ }
continue
}
- $attempted++
- if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) {
+ if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi" -BundleNames $bundleNames) {
$installed++
} else {
- Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)"
- $failed++
- }
- }
- if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
- if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
- }
- if ($attempted -eq 0) {
- if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") {
- $installed++
- } else {
- $failed++
+ if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agent.Dir) {
+ Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用"
+ $installed++
+ } else { $failed++ }
}
}
if ($installed -eq 0) {
@@ -929,6 +1596,10 @@ function Install-MultiSkillsToHomes {
return $false
}
Write-SkillsState -MultiSrc $MultiSrc
+ Write-Say "✅ DWS Skills 安装完成"
+ Write-Say " 统一安装位置:$canonical"
+ Write-Say " 已自动适配本机上检测到的 Agent"
+ Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
return $true
}
@@ -990,8 +1661,8 @@ function Install-MultiToBase {
foreach ($skillDir in $skillDirs) {
$dest = Join-Path $BaseDir $skillDir.Name
- $published += $dest
Move-SkillPath -Source (Join-Path $stageRoot $skillDir.Name) -Destination $dest
+ $published += New-PublishedSkillCopyRecord -Path $dest -Source $skillDir.FullName
}
} catch {
$transactionError = $_
@@ -1072,6 +1743,7 @@ function Install-Skills {
Write-Err "Cannot download skills from GitHub and no local source checkout found."
}
}
+ Assert-ReleaseAssetChecksum -AssetPath $zipPath -AssetName "dws-skills.zip" -TempDir $tmpDir
$extractRoot = Join-Path $tmpDir "skills"
Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force
diff --git a/scripts/install.sh b/scripts/install.sh
index 016055fe..9505ff13 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -71,24 +71,54 @@ DWS_LAST_SKILL_BACKUP=""
backup_and_remove_skill_dir() {
_bed_dir="$1"
DWS_LAST_SKILL_BACKUP=""
- [ -d "$_bed_dir" ] || return 0
+ [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0
_bed_root="${HOME}/.dws/skill-backups"
_bed_stamp="$(date -u +%Y%m%d-%H%M%S)"
_bed_name="$(basename "$_bed_dir")"
- _bed_target="$_bed_root/$_bed_stamp/$_bed_name"
+ _bed_stamp_root="$_bed_root/$_bed_stamp"
+ _bed_target="$_bed_stamp_root/$_bed_name"
_bed_i=1
- while [ -e "$_bed_target" ]; do
- _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
+ # Bump not only when the payload path is taken but also when the stamp
+ # root exists without a verified ownership marker: a same-second foreign
+ # directory must never be stamped DWS-owned and made prunable. A
+ # marker-verified root from this run's same second stays reusable.
+ while [ -e "$_bed_target" ] || [ -L "$_bed_target" ] ||
+ { [ -d "$_bed_stamp_root" ] && ! is_current_run_backup_stamp "$_bed_stamp_root" &&
+ [ "$(cat "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do
+ _bed_stamp_root="$_bed_root/$_bed_stamp-$_bed_i"
+ _bed_target="$_bed_stamp_root/$_bed_name"
_bed_i=$((_bed_i + 1))
if [ "$_bed_i" -gt 1000 ]; then
say " ⚠️ 备份目录冲突,保留原目录 $_bed_dir"
return 1
fi
done
- mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || {
+ _bed_stamp_root="$(dirname "$_bed_target")"
+ record_current_run_backup_stamp "$_bed_stamp_root"
+ # Freshness must be sampled before mkdir: the collision loop tests the
+ # payload path, so a second backup in the same stamp second reuses this
+ # root while a sibling payload from this run already lives in it.
+ _bed_fresh=1
+ [ ! -d "$_bed_stamp_root" ] || _bed_fresh=0
+ mkdir -p "$_bed_stamp_root" 2>/dev/null || {
say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir"
return 1
}
+ # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a
+ # stamp-shaped name alone is not evidence, so pruning only ever removes
+ # roots carrying this marker — it must exist before any payload moves in.
+ printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || {
+ # Non-recursive cleanup, sibling protection: only a root this call
+ # created may be dropped, and only the marker plus an empty root (rmdir
+ # refuses a non-empty directory) — rm -rf here would destroy a completed
+ # same-second sibling backup whose original is already moved away.
+ if [ "$_bed_fresh" -eq 1 ]; then
+ rm -f "$_bed_stamp_root/.dws-skill-backup"
+ rmdir "$_bed_stamp_root" 2>/dev/null || true
+ fi
+ say " ⚠️ 无法写入备份所有权标记,保留原目录 $_bed_dir"
+ return 1
+ }
if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then
DWS_LAST_SKILL_BACKUP="$_bed_target"
say " × 已备份并移除 $_bed_dir → $_bed_target"
@@ -98,6 +128,88 @@ backup_and_remove_skill_dir() {
return 1
}
+# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped
+# backup directories from earlier runs under $HOME/.dws/skill-backups,
+# matching Go's skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots
+# carrying the .dws-skill-backup marker with the exact expected content are
+# counted or removed: foreign data with a stamp-like name never consumes a
+# keep slot and is never deleted, silently. Stamp directories created
+# by the current process are never pruned (mirroring Go's run-root registry
+# and install.js's currentRunBackupRoots), so a migration that retires more
+# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal
+# failure is reported, never fatal.
+DWS_SKILL_BACKUP_KEEP=5
+
+# Stamp directories created by this run, recorded by
+# backup_and_remove_skill_dir so pruning can never delete a backup the
+# running migration may still need to roll back to.
+DWS_CURRENT_RUN_BACKUP_STAMPS=""
+
+record_current_run_backup_stamp() {
+ case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
+ *" $1 "*) return 0 ;;
+ esac
+ DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1"
+}
+
+# is_current_run_backup_stamp reports whether the stamp root was created by
+# this very process. Such a root is ours by construction and stays reusable
+# even when its marker cannot be re-verified mid-run (for example a
+# permission failure after the first payload moved in).
+is_current_run_backup_stamp() {
+ case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
+ *" $1 "*) return 0 ;;
+ esac
+ return 1
+}
+
+# is_skill_backup_stamp accepts only directory names with the stamp shape DWS
+# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix.
+# Shape is necessary but not sufficient — pruning additionally verifies the
+# .dws-skill-backup ownership marker — while any other entry in the backup
+# root is foreign (user data, unrelated tooling) and is preserved so pruning
+# can never remove a directory it cannot prove DWS created.
+is_skill_backup_stamp() {
+ case "$1" in
+ [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9])
+ return 0 ;;
+ [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*)
+ # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip
+ # those 16 chars and require the remainder to be all digits.
+ _isbs_suffix="${1#????????????????}"
+ case "$_isbs_suffix" in
+ ""|*[!0-9]*) return 1 ;;
+ esac
+ return 0 ;;
+ esac
+ return 1
+}
+
+prune_skill_backups() {
+ _psb_root="${HOME}/.dws/skill-backups"
+ [ -d "$_psb_root" ] || return 0
+ _psb_total=0
+ for _psb_entry in "$_psb_root"/*; do
+ [ -d "$_psb_entry" ] || continue
+ is_skill_backup_stamp "${_psb_entry##*/}" || continue
+ [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
+ _psb_total=$((_psb_total + 1))
+ done
+ [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0
+ _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP))
+ for _psb_entry in "$_psb_root"/*; do
+ [ "$_psb_drop" -gt 0 ] || break
+ [ -d "$_psb_entry" ] || continue
+ is_skill_backup_stamp "${_psb_entry##*/}" || continue
+ [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue
+ case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in
+ *" $_psb_entry "*) continue ;;
+ esac
+ rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry"
+ _psb_drop=$((_psb_drop - 1))
+ done
+}
+
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
# use it too. Ownership comes from the centralized skills-state.json.
is_managed_multi_skill_dir() {
@@ -141,6 +253,40 @@ sha256_stdin() {
fi
}
+# sha256_file : content digest used by skill_dir_content_digest. Never
+# emits on failure. Callers that capture its status directly detect that
+# failure; skill_dir_content_digest cannot — see the pipeline caveat there:
+# POSIX sh returns only the right-hand pipeline status, so a subshell exit on
+# its left side is discarded and digest-failure detection stays best-effort.
+sha256_file() {
+ if need_cmd sha256sum; then
+ sha256sum "$1" 2>/dev/null | awk '{print $1}'
+ elif need_cmd shasum; then
+ shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'
+ elif need_cmd openssl; then
+ openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}'
+ else
+ return 1
+ fi
+}
+
+verify_release_asset_checksum() {
+ _checksum_asset="$1"
+ _checksum_path="$2"
+ _checksum_dir="$3"
+ _checksum_url="$(asset_url checksums.txt)"
+ [ -n "$_checksum_url" ] || err "Could not resolve checksums.txt for ${VERSION}."
+ download "$_checksum_url" "$_checksum_dir/checksums.txt" 2>/dev/null || \
+ err "Could not download checksums.txt for ${VERSION}; refusing unverified ${_checksum_asset}."
+ _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")"
+ [ -n "$_checksum_expected" ] || err "${_checksum_asset} is missing from checksums.txt."
+ _checksum_actual="$(sha256_stdin < "$_checksum_path")" || \
+ err "Could not compute SHA256 for ${_checksum_asset}; install sha256sum, shasum, or openssl."
+ [ "$_checksum_actual" = "$_checksum_expected" ] || \
+ err "SHA256 checksum mismatch for ${_checksum_asset}. Expected ${_checksum_expected}, got ${_checksum_actual}."
+ say "✅ SHA256 checksum verified: ${_checksum_asset}"
+}
+
digest_skill_dir() {
_digest_dir="$1"
_digest="$({
@@ -208,31 +354,155 @@ backup_and_record_skill_dir() {
backup_and_remove_skill_dir "$_bars_victim" || return 1
if [ -n "$DWS_LAST_SKILL_BACKUP" ]; then
if ! printf '%s\n%s\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP" >> "$_bars_manifest"; then
- mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || say " ⚠️ 备份记录失败且无法自动恢复: $_bars_victim(备份位于 $DWS_LAST_SKILL_BACKUP)"
+ mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || say " ⚠️ 备份记录失败且无法自动恢复: ${_bars_victim}(备份位于 ${DWS_LAST_SKILL_BACKUP})"
return 1
fi
fi
}
+# restore_quarantine_no_replace
+# Puts an unmatched quarantined object back onto dest without replacing
+# anything that occupied dest after the claim. Directories use mkdir-claim
+# plus child moves; links are created at dest; files hard-link then drop
+# the quarantine copy.
+restore_quarantine_no_replace() {
+ _rqn_src="$1"
+ _rqn_dest="$2"
+ if [ -L "$_rqn_src" ]; then
+ _rqn_target="$(readlink "$_rqn_src")" || return 1
+ if ! ln -s "$_rqn_target" "$_rqn_dest" 2>/dev/null || ! [ -L "$_rqn_dest" ]; then
+ _rqn_nested="$_rqn_dest/${_rqn_target##*/}"
+ if [ -L "$_rqn_nested" ] && [ "$(readlink "$_rqn_nested" 2>/dev/null)" = "$_rqn_target" ]; then
+ rm -f "$_rqn_nested"
+ fi
+ return 1
+ fi
+ rm -f "$_rqn_src"
+ return 0
+ fi
+ if [ -d "$_rqn_src" ]; then
+ mkdir "$_rqn_dest" 2>/dev/null || return 1
+ _rqn_failed=0
+ for _rqn_child in "$_rqn_src"/* "$_rqn_src"/.[!.]* "$_rqn_src"/..?*; do
+ [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue
+ if ! mv "$_rqn_child" "$_rqn_dest/"; then
+ _rqn_failed=1
+ break
+ fi
+ done
+ if [ "$_rqn_failed" -eq 0 ]; then
+ rmdir "$_rqn_src" 2>/dev/null || return 1
+ return 0
+ fi
+ for _rqn_child in "$_rqn_dest"/* "$_rqn_dest"/.[!.]* "$_rqn_dest"/..?*; do
+ [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue
+ mv "$_rqn_child" "$_rqn_src/" || return 1
+ done
+ rmdir "$_rqn_dest" 2>/dev/null || return 1
+ return 1
+ fi
+ if [ -f "$_rqn_src" ]; then
+ # Shell `ln` treats an occupied directory as a container. Refuse that
+ # dest and drop any nested name instead of leaving a hardlink inside it.
+ if ! ln "$_rqn_src" "$_rqn_dest" 2>/dev/null || [ -d "$_rqn_dest" ]; then
+ _rqn_nested="$_rqn_dest/$(basename "$_rqn_src")"
+ [ -f "$_rqn_nested" ] && rm -f "$_rqn_nested"
+ return 1
+ fi
+ rm -f "$_rqn_src"
+ return 0
+ fi
+ return 1
+}
+
+# quarantine_retract_or_restore [expected-link-target]
+# Prove ownership at dest first (inode plus child names for directories).
+# Quarantining before that check would move a concurrent replacement off its
+# original path. Linux overlayfs recycles inodes, so inode alone is not
+# enough — a dest whose children no longer match the recorded publication
+# stays put. After a matching dest is claimed, identity is re-checked in
+# quarantine; a mismatch is restored with no-replace.
+quarantine_retract_or_restore() {
+ _qrr_dest="$1"
+ _qrr_inode="$2"
+ _qrr_link_target="${3-}"
+ if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then
+ return 0
+ fi
+ _qrr_owned=0
+ if [ -n "$_qrr_link_target" ]; then
+ if [ -L "$_qrr_dest" ] &&
+ [ "$(readlink "$_qrr_dest" 2>/dev/null)" = "$_qrr_link_target" ] &&
+ [ "$(skill_link_inode "$_qrr_dest")" = "$_qrr_inode" ]; then
+ _qrr_owned=1
+ fi
+ elif [ "$(skill_dir_identity "$_qrr_dest")" = "$_qrr_inode" ]; then
+ _qrr_owned=1
+ fi
+ if [ "$_qrr_owned" -eq 0 ]; then
+ say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest"
+ return 1
+ fi
+ _qrr_parent="$(dirname "$_qrr_dest")"
+ _qrr_base="$(basename "$_qrr_dest")"
+ _qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1
+ _qrr_payload="$_qrr_root/payload"
+ if ! mv "$_qrr_dest" "$_qrr_payload"; then
+ rmdir "$_qrr_root" 2>/dev/null || rm -rf "$_qrr_root"
+ if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then
+ return 0
+ fi
+ say " ⚠️ 无法隔离待回滚 Skill: $_qrr_dest"
+ return 1
+ fi
+ _qrr_owned=0
+ if [ -n "$_qrr_link_target" ]; then
+ if [ -L "$_qrr_payload" ] &&
+ [ "$(readlink "$_qrr_payload" 2>/dev/null)" = "$_qrr_link_target" ] &&
+ [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then
+ _qrr_owned=1
+ fi
+ elif [ "$(skill_dir_identity "$_qrr_payload")" = "$_qrr_inode" ]; then
+ _qrr_owned=1
+ fi
+ if [ "$_qrr_owned" -eq 1 ]; then
+ rm -rf "$_qrr_root" || return 1
+ return 0
+ fi
+ if restore_quarantine_no_replace "$_qrr_payload" "$_qrr_dest"; then
+ rmdir "$_qrr_root" 2>/dev/null || true
+ say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest"
+ return 1
+ fi
+ say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest(并发对象保留于 $_qrr_payload)"
+ return 1
+}
+
# restore_multi_skill_set
# Removes partial new publications, then restores every old directory from
-# its exact backup path. Paths containing newlines are outside the supported
-# installer path contract; spaces are preserved.
+# its exact backup path. Publication manifest entries are :
+# pairs recorded at publish time. Dest is claimed into quarantine first;
+# identity is re-checked there so a concurrent replacement is renamed back
+# instead of being deleted by path. Paths containing newlines are outside
+# the supported installer path contract; spaces are preserved.
restore_multi_skill_set() {
_rms_published="$1"
_rms_backups="$2"
_rms_ok=1
if [ -f "$_rms_published" ]; then
- while IFS= read -r _rms_dest; do
- [ -n "$_rms_dest" ] || continue
- rm -rf "$_rms_dest" || _rms_ok=0
+ while IFS= read -r _rms_entry; do
+ [ -n "$_rms_entry" ] || continue
+ _rms_dest="${_rms_entry%:*}"
+ _rms_inode="${_rms_entry##*:}"
+ [ "$_rms_dest" != "$_rms_entry" ] || { say " ⚠️ 跳过格式异常的发布记录: $_rms_entry"; _rms_ok=0; continue; }
+ quarantine_retract_or_restore "$_rms_dest" "$_rms_inode" || _rms_ok=0
done < "$_rms_published"
fi
if [ -f "$_rms_backups" ]; then
while IFS= read -r _rms_original && IFS= read -r _rms_backup; do
[ -n "$_rms_backup" ] || continue
- if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
- say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)"
+ if ! mkdir -p "$(dirname "$_rms_original")" || ! restore_quarantine_no_replace "$_rms_backup" "$_rms_original"; then
+ say " ⚠️ 无法恢复原 Skill: ${_rms_original}(备份保留于 ${_rms_backup})"
_rms_ok=0
fi
done < "$_rms_backups"
@@ -240,6 +510,137 @@ restore_multi_skill_set() {
[ "$_rms_ok" -eq 1 ]
}
+# A link publication manifest stores destination/target/inode triples. Rollback
+# only removes links that still have the identity created by this transaction;
+# a path concurrently replaced with a file, directory, or new link is preserved.
+skill_link_inode() {
+ _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1
+ printf '%s\n' "$_sli_entry" | awk '{print $1}'
+}
+
+# Directory publication identity is inode, a stable child-name list, and a
+# recursive content digest. Inode reuse on Linux overlayfs would otherwise
+# make a concurrent directory look owned, and an in-place content edit after
+# publish (same inode, same names) would otherwise be retracted as this
+# transaction's object. The token has no colon so dest:identity manifest
+# parsing stays dest="${entry%:*}" / identity="${entry##*:}".
+skill_dir_identity() {
+ _sdi_inode="$(skill_link_inode "$1")" || return 1
+ if [ -L "$1" ] || [ ! -d "$1" ]; then
+ printf '%s\n' "$_sdi_inode"
+ return 0
+ fi
+ _sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')"
+ _sdi_digest="$(skill_dir_content_digest "$1")" || return 1
+ printf '%s,%s,%s\n' "$_sdi_inode" "$_sdi_names" "$_sdi_digest"
+}
+
+# Recursive content digest over a published tree: for each path (LC_ALL=C
+# sorted, relative) emit path, type+permission bits from ls -ld, and either
+# the sha256 of the file content or the symlink target. Sorted-path lines are
+# piped through sha256_stdin, so one opaque token covers renames, mode flips,
+# content edits and link retargets — the same in-place-edit guarantee the Go
+# fingerprint provides. Failure detection is asymmetric and best-effort: with
+# no hash tool installed sha256_stdin itself returns 1 and the identity
+# genuinely fails, but an unreadable file only exits the left-hand subshell —
+# /bin/sh has no pipefail, so the pipeline returns sha256_stdin's status and
+# the digest is computed over a truncated stream instead of failing.
+skill_dir_content_digest() {
+ (
+ cd "$1" 2>/dev/null || exit 1
+ find . -print 2>/dev/null |
+ LC_ALL=C sort |
+ while IFS= read -r _sdcd_path; do
+ _sdcd_mode="$(ls -ld "$_sdcd_path" 2>/dev/null | cut -c1-10)"
+ if [ -L "$_sdcd_path" ]; then
+ printf '%s|%s|link|%s\n' "$_sdcd_path" "$_sdcd_mode" "$(readlink "$_sdcd_path" 2>/dev/null)"
+ elif [ -d "$_sdcd_path" ]; then
+ printf '%s|%s|dir\n' "$_sdcd_path" "$_sdcd_mode"
+ elif [ -f "$_sdcd_path" ]; then
+ _sdcd_hash="$(sha256_file "$_sdcd_path")" || exit 1
+ printf '%s|%s|file|%s\n' "$_sdcd_path" "$_sdcd_mode" "$_sdcd_hash"
+ else
+ printf '%s|%s|other\n' "$_sdcd_path" "$_sdcd_mode"
+ fi
+ done
+ ) | sha256_stdin
+}
+
+skill_link_matches() {
+ [ -L "$1" ] || return 1
+ _slm_target="$(readlink "$1" 2>/dev/null)" || return 1
+ [ "$_slm_target" = "$2" ] || return 1
+ _slm_expected_inode="${3-}"
+ [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ]
+}
+
+restore_linked_skill_set() {
+ _rls_published="$1"
+ _rls_backups="$2"
+ _rls_ok=1
+ if [ -f "$_rls_published" ]; then
+ while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do
+ [ -n "$_rls_dest" ] || continue
+ quarantine_retract_or_restore "$_rls_dest" "$_rls_inode" "$_rls_target" || _rls_ok=0
+ done < "$_rls_published"
+ fi
+ restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0
+ [ "$_rls_ok" -eq 1 ]
+}
+
+cleanup_nested_staged_link() {
+ _cnsl_nested="$1/$2"
+ _cnsl_target="$3"
+ _cnsl_inode="${4-}"
+ if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then
+ rm -f "$_cnsl_nested"
+ fi
+}
+
+# publish_skill_dir_no_replace
+# Publishes a staged Skill directory with an atomic no-replace claim: mkdir
+# fails with EEXIST when anything a concurrent writer created occupies the
+# destination, so the claim itself is the existence check — plain `mv` after a
+# backup could still replace a concurrently created object. Staged children
+# then move into the claim one by one (each rename targets a path inside a
+# directory this transaction owns, so no step replaces a foreign object; the
+# staging directory is created under the same umask as the claim, so their
+# modes match by construction). A failed child move relocates the children
+# back and removes only the claim. On success the destination's inode is
+# recorded as : so rollback only ever
+# deletes this transaction's object even when the filesystem recycles the
+# claim inode or the published files are edited in place afterwards.
+publish_skill_dir_no_replace() {
+ _psd_stage="$1"
+ _psd_dest="$2"
+ _psd_manifest="$3"
+
+ if ! mkdir "$_psd_dest" 2>/dev/null; then
+ return 1
+ fi
+ _psd_failed=0
+ for _psd_child in "$_psd_stage"/* "$_psd_stage"/.[!.]* "$_psd_stage"/..?*; do
+ [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue
+ if ! mv "$_psd_child" "$_psd_dest/"; then
+ _psd_failed=1
+ break
+ fi
+ done
+ if [ "$_psd_failed" -eq 0 ]; then
+ _psd_inode="$(skill_dir_identity "$_psd_dest")" || _psd_failed=1
+ if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then
+ return 0
+ fi
+ _psd_failed=1
+ fi
+ for _psd_child in "$_psd_dest"/* "$_psd_dest"/.[!.]* "$_psd_dest"/..?*; do
+ [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue
+ mv "$_psd_child" "$_psd_stage/" || return 1
+ done
+ rmdir "$_psd_dest" 2>/dev/null || return 1
+ return 1
+}
+
# publish_skill_cache
# Copies a complete cache into a sibling staging directory, then publishes it
# with rename. Copy/publish failures retain the previous cache; if restoration
@@ -627,12 +1028,7 @@ _install_mono_to_base() {
done
_mono_dest="$_mono_base/$SKILL_NAME"
- printf '%s\n' "$_mono_dest" >> "$_mono_published" || {
- restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
- rm -rf "$_mono_stage"
- return 1
- }
- if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then
+ if ! publish_skill_dir_no_replace "$_mono_stage/$SKILL_NAME" "$_mono_dest" "$_mono_published"; then
say " ⚠️ mono Skill 集合发布失败,正在恢复原集合: $_mono_dest"
restore_multi_skill_set "$_mono_published" "$_mono_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
rm -rf "$_mono_stage"
@@ -643,17 +1039,100 @@ _install_mono_to_base() {
say "✅ Skills → ${_mono_label} (${_mono_count} files)"
}
-# Move DWS-owned copies out of the generic root once a concrete Agent root is
-# active. This prevents Agents such as Codex from discovering the same Skill
-# through both ~/.agents/skills and ~/.codex/skills.
-retire_generic_skill_root() {
+# Exact upstream registry (76 IDs): id|classification|effective-global-root.
+# `-` means no global directory; `.agents/skills` means canonical-direct.
+# Not readonly: the library must stay re-sourceable inside one shell.
+DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills'
+upstream_agent_registry() {
+ for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done
+}
+
+# DWS-only compatibility roots, same id|classification|root format. Qoderwork
+# stays a real non-universal install target; the other four are global paths
+# older DWS installers wrote by mistake, so they are retired like universal
+# roots. Kept separate so the upstream pin above stays byte-comparable.
+DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills'
+legacy_agent_roots() {
+ for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done
+}
+
+# ~/.agents/skills is the canonical store under the universal convention.
+# Universal Agents read it directly; other Agents receive relative links and
+# fall back to copies only when links are unavailable.
+#
+# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned
+# registries above, so a registry edit changes real install behaviour instead of
+# only a test count, and pin/behaviour drift is impossible by construction.
+is_universal_agent_dir() {
+ for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do
+ [ "${_iua_record##*|}" = "$1" ] || continue
+ case "$_iua_record" in
+ *"|U|"*) return 0 ;;
+ esac
+ done
+ return 1
+}
+
+# Effective global roots from vercel-labs/skills, in pinned registry order and
+# de-duplicated (amp/replit/universal and zencoder/zenflow share a root).
+# Records without a global root (`-`) and canonical-direct records
+# (`.agents/skills`) are not per-Agent targets and are skipped.
+agent_skill_dirs() {
+ { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' '
+ $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }'
+}
+
+resolve_agent_skill_base() {
+ _ras_root="$1"; _ras_agent="$2"
+ case "$_ras_agent" in
+ ".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;;
+ ".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;;
+ ".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;;
+ ".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;;
+ ".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;;
+ ".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;;
+ ".openclaw/skills")
+ for _ras_name in .openclaw .clawdbot .moltbot; do
+ [ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; }
+ done ;;
+ ".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;;
+ ".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;;
+ ".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;;
+ ".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;;
+ ".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;;
+ ".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;;
+ esac
+ printf '%s\n' "$_ras_root/$_ras_agent"
+}
+
+agent_skill_base_detected() {
+ _asbd_agent="$1"; _asbd_base="$2"
+ case "$_asbd_agent" in
+ ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;;
+ ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;;
+ ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;;
+ *) [ -d "$(dirname "$_asbd_base")" ] ;;
+ esac
+}
+
+same_physical_skill_root() {
+ [ -d "$1" ] && [ -d "$2" ] || return 1
+ # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved
+ # directory into the command substitution, which would silently report two
+ # identical roots as different.
+ _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1
+ _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1
+ [ "$_sps_left" = "$_sps_right" ]
+}
+
+retire_agent_skill_root() {
_rgs_root="$1"
- _rgs_base="$_rgs_root/.agents/skills"
- _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1
+ _rgs_base="$2"
+ _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1
_rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
- [ -d "$_rgs_victim" ] || continue
+ [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue
if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then
continue
fi
@@ -666,6 +1145,136 @@ retire_generic_skill_root() {
rm -rf "$_rgs_stage"
}
+# link_canonical_skills_to_base [bundle-src]
+# is the installed multi bundle and is REQUIRED in multi mode: the
+# link set must come from the bundle, never from the whole canonical store.
+# ~/.agents/skills is now shared, so third-party/user Skills live there too and
+# must not be published into every detected Agent root (they are absent from
+# skills-state.json, so nothing could ever reclaim those links). Go
+# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list
+# the same way.
+link_canonical_skills_to_base() {
+ _lcs_root="$1"
+ _lcs_base="$2"
+ _lcs_mode="$3"
+ _lcs_bundle="${4-}"
+ _lcs_canonical="$_lcs_root/.agents/skills"
+ mkdir -p "$_lcs_base" || return 1
+ same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0
+ if [ "$_lcs_mode" = "mono" ]; then
+ _lcs_names="dws"
+ else
+ [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1
+ _lcs_names=""
+ for _lcs_skill in "$_lcs_bundle"/*/; do
+ [ -f "${_lcs_skill}SKILL.md" ] || continue
+ _lcs_names="$_lcs_names $(basename "$_lcs_skill")"
+ done
+ [ -n "$_lcs_names" ] || return 1
+ fi
+ # Collision pre-flight: the victim loop below deliberately refuses to back up
+ # entries that are not DWS-managed, and publication must never replace them.
+ # Report the exact colliding paths before touching anything, so the fall back
+ # to the legacy full-copy layout is observable and actionable.
+ _lcs_collision=0
+ for _lcs_name in $_lcs_names; do
+ _lcs_dest="$_lcs_base/$_lcs_name"
+ { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue
+ same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue
+ [ "$_lcs_name" = "dws" ] && continue
+ is_managed_multi_skill_dir "$_lcs_dest" && continue
+ say " ⚠️ $_lcs_dest 已存在且不是 DWS 安装的 Skill,无法在此建立指向 $_lcs_canonical/$_lcs_name 的共享链接"
+ _lcs_collision=1
+ done
+ if [ "$_lcs_collision" -eq 1 ]; then
+ say " ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;"
+ say " 移走或改名后重新运行安装即可切换为共享 $_lcs_canonical 布局。"
+ return 1
+ fi
+ _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1
+ _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1
+ _lcs_backups="$_lcs_stage/.backups"
+ _lcs_published="$_lcs_stage/.published"
+ _lcs_stage_token="$(basename "$_lcs_stage")"
+ : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; }
+ : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; }
+ _lcs_publish_names=""
+ for _lcs_name in $_lcs_names; do
+ if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then
+ continue
+ fi
+ _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; }
+ _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null)" || {
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
+ rm -rf "$_lcs_stage"
+ return 1
+ }
+ # Publish by creating the link directly at the destination: symlink(2)
+ # refuses an occupied path with EEXIST, so the creation itself is the
+ # atomic no-replace check. `mv` after any pre-check could still replace a
+ # file or symlink a concurrent writer created in between, and `ln -P`
+ # stays unusable (absent from BusyBox `ln`, which silently degraded every
+ # non-universal Agent to the copy layout on Alpine and most containers).
+ if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
+ rm -rf "$_lcs_stage"
+ return 1
+ fi
+ # A directory that appeared at the destination turns `ln -s` into a
+ # container: the link lands inside it under the target basename. Remove
+ # exactly our link after an identity check and roll back; the foreign
+ # directory stays untouched.
+ if [ ! -L "$_lcs_dest" ]; then
+ _lcs_nested_name="${_lcs_link_target##*/}"
+ _lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode=""
+ cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
+ rm -rf "$_lcs_stage"
+ return 1
+ fi
+ _lcs_inode="$(skill_link_inode "$_lcs_dest")" || {
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
+ rm -rf "$_lcs_stage"
+ return 1
+ }
+ if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
+ rm -rf "$_lcs_stage"
+ return 1
+ fi
+ if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then
+ if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi
+ restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true
+ rm -rf "$_lcs_stage"
+ return 1
+ fi
+ say " ↪ Skills → $_lcs_dest"
+ done
+ rm -rf "$_lcs_stage"
+}
+
# Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
# Installing mono removes proven DWS-managed multi leftovers for mutual exclusion,
# mirroring `dws skill setup --mode mono`.
@@ -673,90 +1282,48 @@ install_skills_to_homes() {
skill_src="$1"
root="${HOME}"
installed=0
- attempted=0
+ attempted=1
failed=0
- idx=0
- specific_agents=0
- for specific_dir in \
- ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
- ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
- ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
- ".trae/skills" ".openclaw/skills" ".hermes/skills"
+ retire_failed=0
+ if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi
+ [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; }
+ for agent_dir in $(agent_skill_dirs)
do
- [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
- done
- for agent_dir in \
- ".agents/skills" \
- ".claude/skills" \
- ".cursor/skills" \
- ".qoder/skills" \
- ".qoderwork/skills" \
- ".gemini/skills" \
- ".codex/skills" \
- ".zcode/skills" \
- ".github/skills" \
- ".windsurf/skills" \
- ".augment/skills" \
- ".cline/skills" \
- ".amp/skills" \
- ".kiro/skills" \
- ".trae/skills" \
- ".openclaw/skills" \
- ".hermes/skills"
- do
- if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
- idx=$((idx + 1))
- continue
- fi
- base_dir="$root/$agent_dir"
- parent_gate="$(dirname "$base_dir")"
- if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
- idx=$((idx + 1))
- continue
- fi
+ base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
+ agent_skill_base_detected "$agent_dir" "$base_dir" || continue
+ same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
- case "$root" in
- "$HOME")
- label="~/$agent_dir/$SKILL_NAME"
- ;;
- *)
- label="$root/$agent_dir/$SKILL_NAME"
- ;;
- esac
- if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
+ if is_universal_agent_dir "$agent_dir"; then
+ retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
+ continue
+ fi
+ if link_canonical_skills_to_base "$root" "$base_dir" mono; then
installed=$((installed + 1))
else
- failed=$((failed + 1))
+ if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then
+ say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用"
+ installed=$((installed + 1))
+ else
+ failed=$((failed + 1))
+ fi
fi
- idx=$((idx + 1))
done
- if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
- retire_generic_skill_root "$root" || failed=$((failed + 1))
- fi
- if [ "$attempted" -eq 0 ]; then
- case "$root" in
- "$HOME")
- flabel="~/.agents/skills/$SKILL_NAME"
- ;;
- *)
- flabel="$root/.agents/skills/$SKILL_NAME"
- ;;
- esac
- if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
- installed=$((installed + 1))
- else
- failed=$((failed + 1))
- fi
- fi
if [ "$installed" -eq 0 ]; then
say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"
return 1
fi
+ if [ "$retire_failed" -gt 0 ]; then
+ printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
+ fi
if [ "$failed" -gt 0 ]; then
say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败"
return 1
fi
rm -f "$SKILL_STATE_ROOT/skills-state.json"
+ say "✅ DWS Skills 安装完成"
+ say " 统一安装位置:$root/.agents/skills"
+ say " 已自动适配本机上检测到的 Agent"
+ say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
}
# multi_tree_has_skills returns 0 only when the given multi bundle directory
@@ -783,71 +1350,48 @@ install_multi_skills_to_homes() {
multi_src="$1"
root="${HOME}"
installed=0
- attempted=0
+ attempted=1
failed=0
- idx=0
- specific_agents=0
- for specific_dir in \
- ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
- ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
- ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
- ".trae/skills" ".openclaw/skills" ".hermes/skills"
+ retire_failed=0
+ if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi
+ [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; }
+ for agent_dir in $(agent_skill_dirs)
do
- [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
- done
- for agent_dir in \
- ".agents/skills" \
- ".claude/skills" \
- ".cursor/skills" \
- ".qoder/skills" \
- ".qoderwork/skills" \
- ".gemini/skills" \
- ".codex/skills" \
- ".zcode/skills" \
- ".github/skills" \
- ".windsurf/skills" \
- ".augment/skills" \
- ".cline/skills" \
- ".amp/skills" \
- ".kiro/skills" \
- ".trae/skills" \
- ".openclaw/skills" \
- ".hermes/skills"
- do
- if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
- idx=$((idx + 1))
- continue
- fi
- base_dir="$root/$agent_dir"
- parent_gate="$(dirname "$base_dir")"
- if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
- idx=$((idx + 1))
- continue
- fi
+ base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")"
+ agent_skill_base_detected "$agent_dir" "$base_dir" || continue
+ same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue
attempted=$((attempted + 1))
- if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
+ if is_universal_agent_dir "$agent_dir"; then
+ retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1))
+ continue
+ fi
+ if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then
installed=$((installed + 1))
else
- failed=$((failed + 1))
- say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)"
+ if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
+ say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用"
+ installed=$((installed + 1))
+ else
+ failed=$((failed + 1))
+ fi
fi
- idx=$((idx + 1))
done
- if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
- retire_generic_skill_root "$root" || failed=$((failed + 1))
- fi
- if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
- installed=$((installed + 1))
- fi
if [ "$installed" -eq 0 ]; then
say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"
return 1
fi
+ if [ "$retire_failed" -gt 0 ]; then
+ printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed"
+ fi
if [ "$failed" -gt 0 ]; then
say " ⚠️ 有 ${failed} 个 Agent 目标安装失败"
return 1
fi
write_skills_state "$multi_src" "install.sh" || return 1
+ say "✅ DWS Skills 安装完成"
+ say " 统一安装位置:$root/.agents/skills"
+ say " 已自动适配本机上检测到的 Agent"
+ say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效"
}
_install_multi_to_base() {
@@ -922,12 +1466,7 @@ _install_multi_to_base() {
[ -f "${skill_dir}SKILL.md" ] || continue
_name="$(basename "$skill_dir")"
_dest="$_base/$_name"
- printf '%s\n' "$_dest" >> "$_ms_published" || {
- restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
- rm -rf "$_ms_stage"
- return 1
- }
- if ! mv "$_ms_stage/$_name" "$_dest"; then
+ if ! publish_skill_dir_no_replace "$_ms_stage/$_name" "$_dest" "$_ms_published"; then
say " ⚠️ multi Skill 集合发布失败,正在恢复原集合: $_dest"
restore_multi_skill_set "$_ms_published" "$_ms_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
rm -rf "$_ms_stage"
@@ -1018,32 +1557,7 @@ install_binary() {
download "$download_url" "$tmpdir/$archive_name"
- # Download and verify SHA256 checksum
- checksum_url="$(asset_url checksums.txt)"
- if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then
- expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")"
- if [ -n "$expected" ]; then
- if need_cmd sha256sum; then
- actual="$(sha256sum "$tmpdir/$archive_name" | awk '{print $1}')"
- elif need_cmd shasum; then
- actual="$(shasum -a 256 "$tmpdir/$archive_name" | awk '{print $1}')"
- else
- actual=""
- fi
- if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then
- err "SHA256 checksum mismatch! Expected ${expected}, got ${actual}. Aborting."
- fi
- if [ -n "$actual" ]; then
- say "✅ SHA256 checksum verified"
- else
- say "⚠️ Could not compute checksum (sha256sum/shasum not found); skipping verification"
- fi
- else
- say "⚠️ Archive not found in checksums.txt; skipping verification"
- fi
- else
- say "⚠️ Could not download checksums.txt; skipping verification"
- fi
+ verify_release_asset_checksum "$archive_name" "$tmpdir/$archive_name" "$tmpdir"
say "📦 Extracting..."
tar xzf "$tmpdir/$archive_name" -C "$tmpdir"
@@ -1108,6 +1622,8 @@ install_skills() {
fi
fi
+ verify_release_asset_checksum "$skills_archive" "$tmpdir_skills/$skills_archive" "$tmpdir_skills"
+
extract_root="$tmpdir_skills/skills"
mkdir -p "$extract_root"
if ! extract_zip "$tmpdir_skills/$skills_archive" "$extract_root" 2>/dev/null; then
@@ -1206,6 +1722,10 @@ main() {
install_skills
fi
+ # Every transaction of this run has finished, so old stamped archives can no
+ # longer be needed for a rollback.
+ prune_skill_backups
+
printf '\n'
say "🎉 Installation complete!"
say ""
diff --git a/scripts/policy/check-authoritative-schema-compatibility.sh b/scripts/policy/check-authoritative-schema-compatibility.sh
index 16453ad4..f7a5b5b3 100755
--- a/scripts/policy/check-authoritative-schema-compatibility.sh
+++ b/scripts/policy/check-authoritative-schema-compatibility.sh
@@ -13,8 +13,13 @@ STABLE_REF=""
CANDIDATE_REF="HEAD"
SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go"
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
+COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json"
MIGRATIONS_REL="internal/interfacesnapshot/migrations.go"
+COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go"
+CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go"
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
+CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go"
+LEAF_ADAPTER_REL="internal/helpers/leaf.go"
usage() {
printf '%s\n' "usage: $0 --base-ref [--stable-ref ] [--candidate-ref ]" >&2
@@ -144,6 +149,17 @@ commit_path_is_regular_file() {
[ "$2" = blob ] && [ "$4" = "$tree_path" ]
}
+commit_path_blob() {
+ tree_commit="$1"
+ tree_path="$2"
+ tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
+ [ -n "$tree_entry" ] || return 1
+ set -- $tree_entry
+ [ "$#" -eq 4 ] || return 1
+ [ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1
+ printf '%s\n' "$3"
+}
+
has_modern_interface_helper() {
governance_commit="$1"
commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go &&
@@ -173,6 +189,94 @@ has_any_schema_migration_governance_artifact() {
return 1
}
+has_complete_schema_command_migration_governance() {
+ governance_commit="$1"
+ has_complete_schema_migration_governance "$governance_commit" &&
+ commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" &&
+ commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" &&
+ commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL"
+}
+
+has_any_schema_command_migration_governance_artifact() {
+ governance_commit="$1"
+ for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do
+ if commit_path_exists "$governance_commit" "$relative_path"; then
+ return 0
+ fi
+ done
+ return 1
+}
+
+check_candidate_const_params_source_policy() {
+ source_policy_failed=false
+ for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do
+ matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)"
+ [ -z "$matches" ] && continue
+ while IFS= read -r relative_path; do
+ [ -n "$relative_path" ] || continue
+ case "$relative_path" in
+ *_test.go)
+ continue
+ ;;
+ internal/corecmd/interface_const_params.go)
+ continue
+ ;;
+ internal/corecmd/corecmd.go)
+ [ "$token" = attachInterfaceBoolConstParams ] && continue
+ ;;
+ internal/interfacesnapshot/snapshot.go)
+ [ "$token" = InterfaceBoolConstParams ] && continue
+ ;;
+ esac
+ if [ "$source_policy_failed" = false ]; then
+ printf 'error: candidate production source may not access framework-owned bool ConstParams registry:\n' >&2
+ fi
+ printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2
+ source_policy_failed=true
+ done <&2
+ exit 2
+ fi
+}
+
+require_base_identical_command_migration_bridges() {
+ base_command_manifest_blob="$(commit_path_blob "$BASE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")"
+ candidate_command_manifest_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")"
+ [ "$base_command_manifest_blob" != "$candidate_command_manifest_blob" ] || return 0
+
+ for protected_bridge_path in "$CORECMD_BRIDGE_REL" "$CONST_PARAMS_REGISTRY_REL" "$LEAF_ADAPTER_REL"; do
+ base_protected_bridge_blob="$(commit_path_blob "$BASE_COMMIT" "$protected_bridge_path" || true)"
+ candidate_protected_bridge_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$protected_bridge_path" || true)"
+ if [ -z "$base_protected_bridge_blob" ] ||
+ [ "$candidate_protected_bridge_blob" != "$base_protected_bridge_blob" ]; then
+ printf 'candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: %s\n' \
+ "$protected_bridge_path" >&2
+ exit 2
+ fi
+ done
+}
+
check_candidate_alias_source_policy() {
source_policy_failed=false
for token in \
@@ -225,10 +329,12 @@ if ! has_complete_schema_migration_governance "$CANDIDATE_COMMIT"; then
exit 2
fi
check_candidate_alias_source_policy
+check_candidate_const_params_source_policy
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST"
USE_MIGRATION_GOVERNANCE=false
+USE_COMMAND_MIGRATION_GOVERNANCE=false
if has_complete_schema_migration_governance "$BASE_COMMIT"; then
USE_MIGRATION_GOVERNANCE=true
@@ -252,6 +358,27 @@ else
fi
fi
+if has_complete_schema_command_migration_governance "$BASE_COMMIT"; then
+ USE_COMMAND_MIGRATION_GOVERNANCE=true
+ APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
+ CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
+ require_complete_candidate_schema_command_governance
+ require_base_identical_command_migration_bridges
+elif has_any_schema_command_migration_governance_artifact "$BASE_COMMIT"; then
+ printf 'error: merge-base contains an incomplete Schema command migration governance artifact set: %s\n' \
+ "$BASE_REF" >&2
+ exit 2
+elif has_any_schema_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then
+ # Bootstrap keeps the old base-owned checker in control and therefore grants
+ # no command migration authorization in the governance PR itself.
+ require_complete_candidate_schema_command_governance
+fi
+
+if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
+ install_authority_const_params_registry "$BASE_WORKTREE" "$CANDIDATE_WORKTREE"
+ install_authority_const_params_registry "$BASE_WORKTREE" "$STABLE_WORKTREE"
+fi
+
BASE_BIN="$TMP_ROOT/base-dws"
STABLE_BIN="$TMP_ROOT/stable-dws"
CANDIDATE_BIN="$TMP_ROOT/candidate-dws"
@@ -276,6 +403,11 @@ CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
go build -o "$CANDIDATE_BIN" ./cmd
)
+CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=false
+if "$CHECKER" --help 2>&1 | grep -Fq -- 'migration-base-schema'; then
+ CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=true
+fi
+
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
@@ -337,16 +469,27 @@ generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT"
generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT"
generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT"
-check_schema_contract "PR merge-base" "$BASE_REF" "$BASELINE" \
- --approved-flag-migrations "$APPROVED_MANIFEST" \
- --candidate-flag-migrations "$CANDIDATE_MANIFEST" \
- --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
- --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
- --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
+check_with_migrations() {
+ historical_kind="$1"
+ historical_ref="$2"
+ historical_baseline="$3"
+ set -- \
+ --approved-flag-migrations "$APPROVED_MANIFEST" \
+ --candidate-flag-migrations "$CANDIDATE_MANIFEST" \
+ --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
+ --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
+ --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
+ if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
+ set -- "$@" \
+ --approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
+ --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST"
+ fi
+ if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ] &&
+ [ "$CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA" = true ]; then
+ set -- "$@" --migration-base-schema "$BASELINE"
+ fi
+ check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" "$@"
+}
-check_schema_contract "stable" "$STABLE_REF" "$STABLE_BASELINE" \
- --approved-flag-migrations "$APPROVED_MANIFEST" \
- --candidate-flag-migrations "$CANDIDATE_MANIFEST" \
- --migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
- --migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
- --migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
+check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE"
+check_with_migrations "stable" "$STABLE_REF" "$STABLE_BASELINE"
diff --git a/scripts/policy/check-command-compatibility.sh b/scripts/policy/check-command-compatibility.sh
index d26a7abd..2292bd77 100755
--- a/scripts/policy/check-command-compatibility.sh
+++ b/scripts/policy/check-command-compatibility.sh
@@ -12,7 +12,12 @@ BASE_REF=""
STABLE_REF=""
CANDIDATE_REF="HEAD"
MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json"
+COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json"
+COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go"
+CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go"
ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go"
+CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go"
+LEAF_ADAPTER_REL="internal/helpers/leaf.go"
usage() {
printf '%s\n' "usage: $0 --base-ref --stable-ref [--candidate-ref ]" >&2
@@ -130,6 +135,17 @@ commit_path_is_regular_file() {
[ "$2" = blob ] && [ "$4" = "$tree_path" ]
}
+commit_path_blob() {
+ tree_commit="$1"
+ tree_path="$2"
+ tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")"
+ [ -n "$tree_entry" ] || return 1
+ set -- $tree_entry
+ [ "$#" -eq 4 ] || return 1
+ [ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1
+ printf '%s\n' "$3"
+}
+
has_modern_interface_helper() {
governance_commit="$1"
commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go &&
@@ -166,6 +182,84 @@ require_complete_candidate_governance() {
fi
}
+has_complete_command_migration_governance() {
+ governance_commit="$1"
+ has_complete_migration_governance "$governance_commit" &&
+ commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" &&
+ commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" &&
+ commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL"
+}
+
+has_any_command_migration_governance_artifact() {
+ governance_commit="$1"
+ for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do
+ if commit_path_exists "$governance_commit" "$relative_path"; then
+ return 0
+ fi
+ done
+ return 1
+}
+
+check_candidate_const_params_source_policy() {
+ source_policy_failed=false
+ for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do
+ matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)"
+ [ -z "$matches" ] && continue
+ while IFS= read -r relative_path; do
+ [ -n "$relative_path" ] || continue
+ case "$relative_path" in
+ *_test.go)
+ continue
+ ;;
+ internal/corecmd/interface_const_params.go)
+ continue
+ ;;
+ internal/corecmd/corecmd.go)
+ [ "$token" = attachInterfaceBoolConstParams ] && continue
+ ;;
+ internal/interfacesnapshot/snapshot.go)
+ [ "$token" = InterfaceBoolConstParams ] && continue
+ ;;
+ esac
+ if [ "$source_policy_failed" = false ]; then
+ printf 'candidate production source may not access framework-owned bool ConstParams registry:\n' >&2
+ fi
+ printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2
+ source_policy_failed=true
+ done <&2
+ exit 2
+ fi
+}
+
+require_base_identical_command_migration_bridges() {
+ base_command_manifest_blob="$(commit_path_blob "$BASE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")"
+ candidate_command_manifest_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")"
+ [ "$base_command_manifest_blob" != "$candidate_command_manifest_blob" ] || return 0
+
+ for protected_bridge_path in "$CORECMD_BRIDGE_REL" "$CONST_PARAMS_REGISTRY_REL" "$LEAF_ADAPTER_REL"; do
+ base_protected_bridge_blob="$(commit_path_blob "$BASE_COMMIT" "$protected_bridge_path" || true)"
+ candidate_protected_bridge_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$protected_bridge_path" || true)"
+ if [ -z "$base_protected_bridge_blob" ] ||
+ [ "$candidate_protected_bridge_blob" != "$base_protected_bridge_blob" ]; then
+ printf 'candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: %s\n' \
+ "$protected_bridge_path" >&2
+ exit 2
+ fi
+ done
+}
+
check_candidate_alias_source_policy() {
source_policy_failed=false
for token in \
@@ -222,9 +316,36 @@ install_authority_helper() {
fi
}
+install_authority_const_params_registry() {
+ source_root="$1"
+ worktree="$2"
+
+ [ "$source_root" = "$worktree" ] && return
+ mkdir -p "$worktree/$(dirname "$CONST_PARAMS_REGISTRY_REL")"
+ rm -f "$worktree/$CONST_PARAMS_REGISTRY_REL"
+ cp "$source_root/$CONST_PARAMS_REGISTRY_REL" "$worktree/$CONST_PARAMS_REGISTRY_REL"
+}
+
EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json"
printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST"
USE_MIGRATION_GOVERNANCE=false
+USE_COMMAND_MIGRATION_GOVERNANCE=false
+
+if has_complete_command_migration_governance "$BASE_COMMIT"; then
+ USE_COMMAND_MIGRATION_GOVERNANCE=true
+ APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
+ CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL"
+ require_complete_candidate_command_governance
+ require_base_identical_command_migration_bridges
+elif has_any_command_migration_governance_artifact "$BASE_COMMIT"; then
+ printf 'merge-base contains an incomplete command migration governance artifact set: %s\n' \
+ "$BASE_REF" >&2
+ exit 2
+elif has_any_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then
+ # Bootstrap is safe because the base-owned comparator receives no new
+ # authorization input and still performs the ordinary compatibility check.
+ require_complete_candidate_command_governance
+fi
if has_complete_migration_governance "$BASE_COMMIT"; then
USE_MIGRATION_GOVERNANCE=true
@@ -233,6 +354,7 @@ if has_complete_migration_governance "$BASE_COMMIT"; then
CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
require_complete_candidate_governance
check_candidate_alias_source_policy
+ check_candidate_const_params_source_policy
install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" true
else
@@ -252,6 +374,7 @@ else
AUTHORITY_ROOT="$BASE_WORKTREE"
require_complete_candidate_governance
check_candidate_alias_source_policy
+ check_candidate_const_params_source_policy
BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL"
if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then
printf 'initial flag migration governance requires the canonical empty manifest: %s\n' "$BOOTSTRAP_MANIFEST" >&2
@@ -262,6 +385,11 @@ fi
install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE"
install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE"
+if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
+ install_authority_const_params_registry "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE"
+ install_authority_const_params_registry "$AUTHORITY_ROOT" "$BASE_WORKTREE"
+ install_authority_const_params_registry "$AUTHORITY_ROOT" "$STABLE_WORKTREE"
+fi
generate_snapshot() {
worktree="$1"
@@ -280,7 +408,19 @@ generate_snapshot "$CANDIDATE_WORKTREE" "$CANDIDATE"
generate_snapshot "$BASE_WORKTREE" "$BASELINE"
generate_snapshot "$STABLE_WORKTREE" "$STABLE"
-if [ "$USE_MIGRATION_GOVERNANCE" = true ]; then
+if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
+ (
+ cd "$AUTHORITY_ROOT"
+ go run ./cmd/interface-snapshot compare \
+ --current "$CANDIDATE" \
+ --base "$BASELINE" \
+ --stable "$STABLE" \
+ --approved-flag-migrations "$APPROVED_MANIFEST" \
+ --candidate-flag-migrations "$CANDIDATE_MANIFEST" \
+ --approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
+ --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST"
+ )
+elif [ "$USE_MIGRATION_GOVERNANCE" = true ]; then
(
cd "$AUTHORITY_ROOT"
go run ./cmd/interface-snapshot compare \
diff --git a/scripts/policy/check-release-fragments.sh b/scripts/policy/check-release-fragments.sh
index a93f113a..a617831e 100755
--- a/scripts/policy/check-release-fragments.sh
+++ b/scripts/policy/check-release-fragments.sh
@@ -68,7 +68,28 @@ if [ "$archive_changed" = true ]; then
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
- ' >"$tmp_root/actual-notes"
+ ' >"$tmp_root/actual-section"
+ case "$release_version" in
+ *-beta.*)
+ cp "$tmp_root/actual-section" "$tmp_root/actual-notes"
+ ;;
+ *)
+ stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")"
+ [ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
+ printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2
+ exit 1
+ }
+ . "$ROOT/scripts/release/release-lib.sh"
+ [ "$(release_core_tag "$stable_beta")" = "v$release_version" ] || {
+ printf 'error: stable post-beta fragment boundary %s does not match release v%s\n' "$stable_beta" "$release_version" >&2
+ exit 1
+ }
+ awk '
+ found { print }
+ /^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 }
+ ' "$tmp_root/actual-section" >"$tmp_root/actual-notes"
+ ;;
+ esac
normalize_notes() {
awk '
/^[[:space:]]*$/ && !started { next }
diff --git a/scripts/policy/commandcompat/authority_integration_test.go b/scripts/policy/commandcompat/authority_integration_test.go
index 9f1ddd8b..494a50e2 100644
--- a/scripts/policy/commandcompat/authority_integration_test.go
+++ b/scripts/policy/commandcompat/authority_integration_test.go
@@ -51,6 +51,8 @@ func main() {
base := flags.String("base", "", "base snapshot")
stable := flags.String("stable", "", "stable snapshot")
_ = flags.String("approved-flag-migrations", "", "approved manifest")
+ _ = flags.String("approved-command-migrations", "", "approved command manifest")
+ _ = flags.String("candidate-command-migrations", "", "candidate command manifest")
_ = flags.Parse(os.Args[2:])
for _, path := range []string{*current, *base, *stable} {
content, err := os.ReadFile(path)
@@ -97,6 +99,27 @@ var (
func AuthorityMarker() string { return "BASE" }
`
+const commandGovernedSnapshotSource = `package interfacesnapshot
+
+import (
+ "example.com/dws-command-compat-authority-test/internal/corecmd"
+ "example.com/dws-command-compat-authority-test/internal/corecmd/runtimeannotate"
+)
+
+var (
+ _ = runtimeannotate.AnnotationFlagAliasOf
+ _ = runtimeannotate.AnnotationFlagAliasOrigin
+ _ = runtimeannotate.FlagAliasOriginCorecmdV1
+)
+
+func AuthorityMarker() string {
+ if corecmd.InterfaceBoolConstParams() != "BASE" {
+ return "UNTRUSTED_CONST_PARAMS"
+ }
+ return "BASE"
+}
+`
+
const baseSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "BASE" }
@@ -121,6 +144,54 @@ const (
)
`
+func constParamsRegistrySource(marker string) string {
+ return fmt.Sprintf(`package corecmd
+
+type constParamsRegistry struct{}
+
+func (constParamsRegistry) Store(any, any) {}
+
+var interfaceBoolConstParamsRegistry constParamsRegistry
+
+func attachInterfaceBoolConstParams() {}
+
+func InterfaceBoolConstParams() string { return %q }
+`, marker)
+}
+
+const baseCorecmdBridgeSource = `package corecmd
+
+func boolConstParams(map[string]any) map[string]bool {
+ return map[string]bool{"convThreadEnabled": true}
+}
+
+func installConstParamsEvidence() { attachInterfaceBoolConstParams() }
+`
+
+const forgedCorecmdBridgeSource = `package corecmd
+
+func boolConstParams(map[string]any) map[string]bool {
+ return map[string]bool{"convThreadEnabled": false}
+}
+
+func installConstParamsEvidence() { attachInterfaceBoolConstParams() }
+`
+
+const baseLeafAdapterSource = `package helpers
+
+func forwardToolArgs(toolArgs map[string]any) map[string]any {
+ return toolArgs
+}
+`
+
+const forgedLeafAdapterSource = `package helpers
+
+func forwardToolArgs(toolArgs map[string]any) map[string]any {
+ delete(toolArgs, "convThreadEnabled")
+ return toolArgs
+}
+`
+
const candidateNoopSource = `package main
import "fmt"
@@ -131,11 +202,13 @@ func main() {
`
type authorityScenario struct {
- name string
- baseGovernance string
- candidateMutation string
- want string
- wantAuthorityDecision bool
+ name string
+ baseGovernance string
+ commandGovernance string
+ commandManifestChanged bool
+ candidateMutation string
+ want string
+ wantAuthorityDecision bool
}
func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
@@ -150,6 +223,85 @@ func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *test
baseGovernance: "none",
wantAuthorityDecision: true,
},
+ {
+ name: "command governed base owns bool ConstParams registry",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "change-const-params-protocol",
+ wantAuthorityDecision: true,
+ },
+ {
+ name: "command governance bootstrap preserves candidate bool ConstParams registry",
+ baseGovernance: "complete",
+ commandGovernance: "bootstrap",
+ wantAuthorityDecision: true,
+ },
+ {
+ name: "command governed candidate cannot delete bool ConstParams registry",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "delete-const-params-contract",
+ want: "must preserve the complete command migration governance artifact set",
+ },
+ {
+ name: "same package candidate cannot call private ConstParams writer",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "forge-const-params-token",
+ want: "may not access framework-owned bool ConstParams registry",
+ },
+ {
+ name: "candidate cannot reopen private ConstParams reader",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "forge-const-params-reader",
+ want: "may not access framework-owned bool ConstParams registry",
+ },
+ {
+ name: "same package candidate cannot store directly in ConstParams registry",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "forge-const-params-registry-store",
+ want: "may not access framework-owned bool ConstParams registry",
+ },
+ {
+ name: "changed command manifest rejects forged corecmd bool ConstParams bridge",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ commandManifestChanged: true,
+ candidateMutation: "forge-corecmd-bool-const-params",
+ want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go",
+ },
+ {
+ name: "changed command manifest rejects tampered ConstParams protocol",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ commandManifestChanged: true,
+ candidateMutation: "change-const-params-protocol",
+ want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go",
+ },
+ {
+ name: "changed command manifest rejects tampered leaf adapter",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ commandManifestChanged: true,
+ candidateMutation: "forge-leaf-adapter",
+ want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go",
+ },
+ {
+ name: "changed command manifest allows base identical bridges",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ commandManifestChanged: true,
+ wantAuthorityDecision: true,
+ },
+ {
+ name: "unchanged command manifest allows independent corecmd change",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "forge-corecmd-bool-const-params",
+ wantAuthorityDecision: true,
+ },
{
name: "partial base fails closed",
baseGovernance: "partial",
@@ -274,6 +426,19 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
default:
t.Fatalf("unknown base governance state %q", test.baseGovernance)
}
+ switch test.commandGovernance {
+ case "":
+ case "complete":
+ writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), commandGovernedSnapshotSource, 0o644)
+ writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644)
+ writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), baseCorecmdBridgeSource, 0o644)
+ writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("BASE"), 0o644)
+ writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), baseLeafAdapterSource, 0o644)
+ writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
+ case "bootstrap":
+ default:
+ t.Fatalf("unknown command governance state %q", test.commandGovernance)
+ }
run(t, fixtureRoot, "git", "add", ".")
run(t, fixtureRoot, "git", "commit", "-m", "base authority")
baseRef := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD"))
@@ -302,6 +467,17 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
0o644,
)
}
+ if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" {
+ writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644)
+ if test.commandGovernance == "bootstrap" {
+ writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644)
+ }
+ commandManifest := "{\"version\":1,\"migrations\":[]}\n"
+ if test.commandManifestChanged {
+ commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n"
+ }
+ writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644)
+ }
switch test.candidateMutation {
case "":
case "delete-alias-contract":
@@ -314,6 +490,34 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) {
"package evil\n\nconst forged = \"dws.compat.alias_of\"\n",
0o644,
)
+ case "delete-const-params-contract":
+ if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil {
+ t.Fatalf("remove candidate bool ConstParams registry: %v", err)
+ }
+ case "forge-const-params-token":
+ writeFile(t,
+ filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"),
+ "package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n",
+ 0o644,
+ )
+ case "forge-const-params-reader":
+ writeFile(t,
+ filepath.Join(fixtureRoot, "internal", "evil", "forged.go"),
+ "package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n",
+ 0o644,
+ )
+ case "forge-const-params-registry-store":
+ writeFile(t,
+ filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"),
+ "package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n",
+ 0o644,
+ )
+ case "forge-corecmd-bool-const-params":
+ writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), forgedCorecmdBridgeSource, 0o644)
+ case "change-const-params-protocol":
+ writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644)
+ case "forge-leaf-adapter":
+ writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), forgedLeafAdapterSource, 0o644)
case "symlink-helper-parent":
helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot")
alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt")
diff --git a/scripts/policy/interface-migrations/approved-command-migrations-v1.json b/scripts/policy/interface-migrations/approved-command-migrations-v1.json
new file mode 100644
index 00000000..52a6bff1
--- /dev/null
+++ b/scripts/policy/interface-migrations/approved-command-migrations-v1.json
@@ -0,0 +1,91 @@
+{
+ "version": 1,
+ "migrations": [
+ {
+ "kind": "flag_extraction",
+ "legacy": {
+ "command": "dws chat group create",
+ "before": {"present": true, "runnable": true},
+ "after": {"present": true, "runnable": true}
+ },
+ "replacement": {
+ "command": "dws chat topic create",
+ "before": {"present": false},
+ "after": {"present": true, "runnable": true}
+ },
+ "legacy_flag": {
+ "name": "thread",
+ "before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"},
+ "after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"}
+ },
+ "schema": {
+ "product_id": "chat",
+ "source_tool_id": "chat.create_group_conversation",
+ "replacement_tool_id": "chat.create_topic_conversation",
+ "parameters": [
+ {"from": "name", "to": "name"},
+ {"from": "type", "to": "type"},
+ {"from": "users", "to": "users"},
+ {
+ "from": "thread",
+ "replacement_constant": {
+ "property": "convThreadEnabled",
+ "value": true
+ }
+ }
+ ]
+ },
+ "state": "pending",
+ "reason": "Move topic-conversation creation to the dedicated chat topic command while retaining the legacy --thread execution path."
+ },
+ {
+ "kind": "command_move",
+ "legacy": {
+ "command": "dws chat message list-topic-replies",
+ "before": {"present": true, "runnable": true},
+ "after": {"present": true, "runnable": true, "hidden": true}
+ },
+ "replacement": {
+ "command": "dws chat topic list-replies",
+ "before": {"present": false},
+ "after": {"present": true, "runnable": true}
+ },
+ "schema": {
+ "product_id": "chat",
+ "source_tool_id": "chat.list_topic_replies",
+ "replacement_tool_id": "chat.list_topic_replies",
+ "parameters": [
+ {"from": "conversation-id", "to": "open-topic-id"},
+ {"from": "topic-id", "to": "open-conv-thread-id"}
+ ]
+ },
+ "state": "pending",
+ "reason": "Move topic reply listing from chat message to the dedicated chat topic command while preserving the legacy executable command."
+ },
+ {
+ "kind": "command_move",
+ "legacy": {
+ "command": "dws chat message forward-topic",
+ "before": {"present": true, "runnable": true},
+ "after": {"present": true, "runnable": true, "hidden": true}
+ },
+ "replacement": {
+ "command": "dws chat topic forward",
+ "before": {"present": false},
+ "after": {"present": true, "runnable": true}
+ },
+ "schema": {
+ "product_id": "chat",
+ "source_tool_id": "chat.forward_topic",
+ "replacement_tool_id": "chat.forward_topic",
+ "parameters": [
+ {"from": "src-conversation-id", "to": "src-open-topic-id"},
+ {"from": "src-thread-id", "to": "src-open-conv-thread-id"},
+ {"from": "dest-conversation-id", "to": "dest-open-conversation-id"}
+ ]
+ },
+ "state": "pending",
+ "reason": "Move topic forwarding from chat message to the dedicated chat topic command while preserving the legacy executable command."
+ }
+ ]
+}
diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json
index 6ddbafb7..e00cf936 100644
--- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json
+++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json
@@ -1,39 +1,6 @@
{
"version": 1,
"migrations": [
- {
- "command": "dws chat group audit-join-validation",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
{
"command": "dws chat group bots",
"legacy": {
@@ -67,1065 +34,6 @@
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
- {
- "command": "dws chat group dismiss",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group invite-url",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group notice create",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group notice edit",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group notice get",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group notice list",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group quit",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group set-admin",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group set-history",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group transfer-owner",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group update-alias",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group update-icon",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group update-nick",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group update-settings",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group upgrade-to-external",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group-role add",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group-role list",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group-role query-user",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group-role remove-user",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group-role remove",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group-role set-user",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat group-role update",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat message edit",
- "legacy": {
- "name": "msg-id",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "message-id"
- }
- },
- "canonical": {
- "name": "message-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat message forward",
- "legacy": {
- "name": "msg-id",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "message-id"
- }
- },
- "canonical": {
- "name": "message-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat message list-topic-replies",
- "legacy": {
- "name": "group",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "conversation-id"
- }
- },
- "canonical": {
- "name": "conversation-id",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat message recall",
- "legacy": {
- "name": "msg-id",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "message-id"
- }
- },
- "canonical": {
- "name": "message-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat message send",
- "legacy": {
- "name": "uuid",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "idempotency-key"
- }
- },
- "canonical": {
- "name": "idempotency-key",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": false
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将幂等参数规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat message set-pin-msg",
- "legacy": {
- "name": "msg-id",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "message-id"
- }
- },
- "canonical": {
- "name": "message-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat message set-top-msg",
- "legacy": {
- "name": "msg-id",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "message-id"
- }
- },
- "canonical": {
- "name": "message-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat message unset-pin-msg",
- "legacy": {
- "name": "msg-id",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "message-id"
- }
- },
- "canonical": {
- "name": "message-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat message unset-top-msg",
- "legacy": {
- "name": "msg-id",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "message-id"
- }
- },
- "canonical": {
- "name": "message-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
- {
- "command": "dws chat message update-text-emotion",
- "legacy": {
- "name": "msg-id",
- "before": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "hidden": true,
- "alias_of": "message-id"
- }
- },
- "canonical": {
- "name": "message-id",
- "before": {
- "present": false
- },
- "after": {
- "present": true,
- "type": "string",
- "scope": "local",
- "required": true
- }
- },
- "state": "consumed",
- "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
- },
{
"command": "dws ding message send-by-message",
"legacy": {
@@ -1159,227 +67,33 @@
"state": "pending",
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
},
- {
- "command": "dws aisearch",
- "legacy": {
- "name": "keyword",
- "before": {"present": true, "type": "string", "shorthand": "w", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "shorthand": "w", "scope": "local", "alias_of": "query"}
- },
- "canonical": {
- "name": "query",
- "before": {"present": true, "type": "string", "hidden": true, "scope": "local"},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws aisearch person",
- "legacy": {
- "name": "keyword",
- "before": {"present": true, "type": "string", "shorthand": "w", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "shorthand": "w", "scope": "local", "alias_of": "query"}
- },
- "canonical": {
- "name": "query",
- "before": {"present": true, "type": "string", "hidden": true, "scope": "local"},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat +broadcast",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat +dm",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat +messages-batch-send-by-bot",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat +messages-reply",
- "legacy": {
- "name": "conversation-id",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "group"}
- },
- "canonical": {
- "name": "group",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat +messages-reply",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat +messages-send-by-bot",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat +messages-send-by-webhook",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat +send-to-group",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat message reply",
- "legacy": {
- "name": "conversation-id",
- "before": {"present": true, "type": "string", "required": true, "scope": "local"},
- "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "group"}
- },
- "canonical": {
- "name": "group",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "required": true, "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat message reply",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "required": true, "scope": "local"},
- "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "required": true, "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat message send",
- "legacy": {
- "name": "file-path",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"}
- },
- "canonical": {
- "name": "file",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat message send",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": true, "type": "string", "hidden": true, "scope": "local"},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
{
"command": "dws chat message send-by-bot",
"legacy": {
"name": "file-path",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"}
+ "before": {
+ "present": true,
+ "type": "string",
+ "scope": "local"
+ },
+ "after": {
+ "present": true,
+ "type": "string",
+ "hidden": true,
+ "scope": "local",
+ "alias_of": "file"
+ }
},
"canonical": {
"name": "file",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
+ "before": {
+ "present": false
+ },
+ "after": {
+ "present": true,
+ "type": "string",
+ "scope": "local"
+ }
},
"state": "pending",
"reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
@@ -1388,91 +102,32 @@
"command": "dws chat message send-by-bot",
"legacy": {
"name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
+ "before": {
+ "present": true,
+ "type": "string",
+ "scope": "local"
+ },
+ "after": {
+ "present": true,
+ "type": "string",
+ "hidden": true,
+ "scope": "local",
+ "alias_of": "content"
+ }
},
"canonical": {
"name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
+ "before": {
+ "present": false
+ },
+ "after": {
+ "present": true,
+ "type": "string",
+ "scope": "local"
+ }
},
"state": "pending",
"reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws chat message send-by-webhook",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "required": true, "scope": "local"},
- "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "required": true, "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws doc +doc-append",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws doc block insert",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws doc block update",
- "legacy": {
- "name": "text",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"}
- },
- "canonical": {
- "name": "content",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
- },
- {
- "command": "dws todo task add-attachment",
- "legacy": {
- "name": "file-path",
- "before": {"present": true, "type": "string", "scope": "local"},
- "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"}
- },
- "canonical": {
- "name": "file",
- "before": {"present": false},
- "after": {"present": true, "type": "string", "scope": "local"}
- },
- "state": "consumed",
- "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
}
]
}
diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go
new file mode 100644
index 00000000..613d449d
--- /dev/null
+++ b/scripts/policy/schema-compat/command_migrations_test.go
@@ -0,0 +1,1588 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
+)
+
+func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ current := schemaCommandMigrationContract(true)
+ migrations := schemaCommandMigrationAuthorizations()
+ normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations)
+ if err != nil {
+ t.Fatalf("normalize exact command migrations: %v", err)
+ }
+ if failures := checkCompatibility(normalized, current); len(failures) != 0 {
+ t.Fatalf("exact command migrations remained incompatible: %v", failures)
+ }
+
+ unrelated := cloneContract(current)
+ product := unrelated.Products["chat"]
+ tool := product.Tools["chat.move"]
+ delete(tool.Parameters, "keep")
+ product.Tools["chat.move"] = tool
+ unrelated.Products["chat"] = product
+ normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, migrations)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, `lost parameter "keep"`) {
+ t.Fatalf("unrelated parameter removal was hidden: %s", failures)
+ }
+}
+
+func TestCrossPlatformCoverageSchemaCommandMigrationsFailClosedOnDrift(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ migrations := schemaCommandMigrationAuthorizations()
+
+ parameterDrift := schemaCommandMigrationContract(true)
+ product := parameterDrift.Products["chat"]
+ tool := product.Tools["chat.move"]
+ parameter := tool.Parameters["new-id"]
+ parameter.Property = "differentProperty"
+ tool.Parameters["new-id"] = parameter
+ product.Tools["chat.move"] = tool
+ parameterDrift.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, parameterDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed a non-name field") {
+ t.Fatalf("parameter drift error=%v", err)
+ }
+
+ safetyDrift := schemaCommandMigrationContract(true)
+ product = safetyDrift.Products["chat"]
+ replacement := product.Tools["chat.create_topic"]
+ replacement.Risk = "high"
+ product.Tools["chat.create_topic"] = replacement
+ safetyDrift.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, safetyDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed interface or safety identity") {
+ t.Fatalf("extraction safety drift error=%v", err)
+ }
+
+ missingReplacement := schemaCommandMigrationContract(true)
+ product = missingReplacement.Products["chat"]
+ delete(product.Tools, "chat.create_topic")
+ missingReplacement.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacement, migrations); err == nil || !strings.Contains(err.Error(), "lacks replacement Schema tool") {
+ t.Fatalf("missing replacement error=%v", err)
+ }
+}
+
+func TestCrossPlatformCoverageSchemaCommandMigrationRejectsUnregisteredRequiredParameters(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ migrations := schemaCommandMigrationAuthorizations()
+
+ for _, test := range []struct {
+ name string
+ parameter parameterSchema
+ }{
+ {name: "required", parameter: parameterSchema{Type: `"string"`, Property: "must", Required: true}},
+ {name: "cli_required", parameter: parameterSchema{Type: `"string"`, Property: "must", CLIRequired: true}},
+ {name: "required_when", parameter: parameterSchema{Type: `"string"`, Property: "must", RequiredWhen: "mode=forced"}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ current := schemaCommandMigrationContract(true)
+ product := current.Products["chat"]
+ tool := product.Tools["chat.move"]
+ tool.Parameters["must"] = test.parameter
+ product.Tools["chat.move"] = tool
+ current.Products["chat"] = product
+
+ if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil ||
+ !strings.Contains(err.Error(), `introduced unregistered required Schema parameter "must"`) {
+ t.Fatalf("unregistered required parameter error=%v", err)
+ }
+ })
+ }
+
+ current := schemaCommandMigrationContract(true)
+ product := current.Products["chat"]
+ tool := product.Tools["chat.move"]
+ tool.Parameters["optional"] = parameterSchema{Type: `"string"`, Property: "optional"}
+ product.Tools["chat.move"] = tool
+ current.Products["chat"] = product
+ normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations)
+ if err != nil {
+ t.Fatalf("optional addition should remain compatible: %v", err)
+ }
+ if failures := checkCompatibility(normalized, current); len(failures) != 0 {
+ t.Fatalf("optional addition should remain compatible: %v", failures)
+ }
+}
+
+func TestCrossPlatformCoverageSchemaCommandMigrationRejectsParameterTargetCollision(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ current := schemaCommandMigrationContract(true)
+ migrations := schemaCommandMigrationAuthorizations()
+ migrations[0].Schema.Parameters = []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "keep"}}
+
+ product := current.Products["chat"]
+ tool := product.Tools["chat.move"]
+ delete(tool.Parameters, "new-id")
+ tool.Parameters["keep"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"]
+ tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints
+ product.Tools["chat.move"] = tool
+ current.Products["chat"] = product
+
+ normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations)
+ if err == nil {
+ if failures := checkCompatibility(normalized, current); len(failures) != 0 {
+ t.Fatalf("hostile target-collision fixture should otherwise pass compatibility: %v", failures)
+ }
+ t.Fatal("parameter target collision was accepted")
+ }
+ if !strings.Contains(err.Error(), `target "keep" already exists in historical Schema tool`) {
+ t.Fatalf("parameter target collision error=%v", err)
+ }
+}
+
+func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ current := schemaCommandMigrationContract(true)
+ migrations := schemaCommandMigrationAuthorizations()
+
+ missingHistorical := cloneContract(baseline)
+ product := missingHistorical.Products["chat"]
+ delete(product.Tools, "chat.move")
+ missingHistorical.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(missingHistorical, current, migrations); err != nil {
+ t.Fatalf("missing historical tool should be a no-op: %v", err)
+ }
+
+ missingCurrent := cloneContract(current)
+ product = missingCurrent.Products["chat"]
+ delete(product.Tools, "chat.move")
+ missingCurrent.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, missingCurrent, migrations); err != nil {
+ t.Fatalf("missing current source should remain for ordinary checker: %v", err)
+ }
+
+ alreadyAfter := schemaCommandMigrationContract(true)
+ if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, migrations[:1]); err != nil {
+ t.Fatalf("already-after baseline should be a no-op: %v", err)
+ }
+
+ wrongHistoricalPath := cloneContract(baseline)
+ product = wrongHistoricalPath.Products["chat"]
+ tool := product.Tools["chat.move"]
+ tool.PrimaryCLIPath = "chat unrelated"
+ product.Tools["chat.move"] = tool
+ wrongHistoricalPath.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(wrongHistoricalPath, current, migrations); err == nil || !strings.Contains(err.Error(), "historical Schema tool") {
+ t.Fatalf("wrong historical path error=%v", err)
+ }
+
+ wrongCurrentPath := cloneContract(current)
+ product = wrongCurrentPath.Products["chat"]
+ tool = product.Tools["chat.move"]
+ tool.PrimaryCLIPath = "chat unrelated"
+ product.Tools["chat.move"] = tool
+ wrongCurrentPath.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, wrongCurrentPath, migrations); err != nil {
+ t.Fatalf("wrong current path should remain for ordinary checker: %v", err)
+ }
+
+ missingHistoricalParameter := cloneContract(baseline)
+ product = missingHistoricalParameter.Products["chat"]
+ tool = product.Tools["chat.move"]
+ delete(tool.Parameters, "old-id")
+ product.Tools["chat.move"] = tool
+ missingHistoricalParameter.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(missingHistoricalParameter, current, migrations); err == nil || !strings.Contains(err.Error(), "lacks parameter") {
+ t.Fatalf("missing historical parameter error=%v", err)
+ }
+
+ legacyPublished := cloneContract(current)
+ product = legacyPublished.Products["chat"]
+ tool = product.Tools["chat.move"]
+ tool.Parameters["old-id"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"]
+ product.Tools["chat.move"] = tool
+ legacyPublished.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, legacyPublished, migrations); err == nil || !strings.Contains(err.Error(), "still publishes legacy") {
+ t.Fatalf("legacy parameter error=%v", err)
+ }
+
+ missingReplacementParameter := cloneContract(current)
+ product = missingReplacementParameter.Products["chat"]
+ tool = product.Tools["chat.move"]
+ delete(tool.Parameters, "new-id")
+ product.Tools["chat.move"] = tool
+ missingReplacementParameter.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacementParameter, migrations); err == nil || !strings.Contains(err.Error(), "does not publish replacement") {
+ t.Fatalf("missing replacement parameter error=%v", err)
+ }
+
+ constraintDrift := cloneContract(current)
+ product = constraintDrift.Products["chat"]
+ tool = product.Tools["chat.move"]
+ tool.Constraints = `{"require_one_of":[["new-id"]]}`
+ product.Tools["chat.move"] = tool
+ constraintDrift.Products["chat"] = product
+ normalized, err := normalizeSchemaCommandMigrations(baseline, constraintDrift, migrations)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failures := strings.Join(checkCompatibility(normalized, constraintDrift), "\n"); !strings.Contains(failures, "changed constraints") {
+ t.Fatalf("constraint drift was hidden: %s", failures)
+ }
+
+ unchangedLegacyConstraints := cloneContract(current)
+ product = unchangedLegacyConstraints.Products["chat"]
+ tool = product.Tools["chat.move"]
+ tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints
+ product.Tools["chat.move"] = tool
+ unchangedLegacyConstraints.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, unchangedLegacyConstraints, migrations); err == nil ||
+ !strings.Contains(err.Error(), "still reference legacy Schema constraint parameter") {
+ t.Fatalf("unchanged legacy constraints error=%v", err)
+ }
+
+ mixedLegacyConstraints := cloneContract(current)
+ product = mixedLegacyConstraints.Products["chat"]
+ tool = product.Tools["chat.move"]
+ tool.Constraints = `{"require_together":[["keep","new-id","old-id"]]}`
+ product.Tools["chat.move"] = tool
+ mixedLegacyConstraints.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, mixedLegacyConstraints, migrations); err == nil ||
+ !strings.Contains(err.Error(), "still reference legacy Schema constraint parameter") {
+ t.Fatalf("mixed legacy constraints error=%v", err)
+ }
+
+ malformedHistoricalConstraints := cloneContract(baseline)
+ product = malformedHistoricalConstraints.Products["chat"]
+ tool = product.Tools["chat.move"]
+ tool.Constraints = "{"
+ product.Tools["chat.move"] = tool
+ malformedHistoricalConstraints.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(malformedHistoricalConstraints, current, migrations); err == nil ||
+ !strings.Contains(err.Error(), "historical Schema constraints are not canonicalizable") {
+ t.Fatalf("malformed historical constraints error=%v", err)
+ }
+
+ malformedCurrentConstraints := cloneContract(current)
+ product = malformedCurrentConstraints.Products["chat"]
+ tool = product.Tools["chat.move"]
+ tool.Constraints = "{"
+ product.Tools["chat.move"] = tool
+ malformedCurrentConstraints.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, malformedCurrentConstraints, migrations); err == nil ||
+ !strings.Contains(err.Error(), "current Schema constraints are not canonicalizable") {
+ t.Fatalf("malformed current constraints error=%v", err)
+ }
+ if source, found := migratedConstraintSourceParameter("{", map[string]string{"legacy": "canonical"}); found || source != "" {
+ t.Fatalf("malformed migrated constraint source = %q, %v", source, found)
+ }
+
+ extractionWrongSource := cloneContract(current)
+ product = extractionWrongSource.Products["chat"]
+ tool = product.Tools["chat.create_group"]
+ tool.PrimaryCLIPath = "chat unrelated"
+ product.Tools["chat.create_group"] = tool
+ extractionWrongSource.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, extractionWrongSource, migrations[1:]); err != nil {
+ t.Fatalf("wrong extraction source path should remain for ordinary checker: %v", err)
+ }
+
+ extractionHistoricalMissing := cloneContract(baseline)
+ product = extractionHistoricalMissing.Products["chat"]
+ tool = product.Tools["chat.create_group"]
+ delete(tool.Parameters, "thread")
+ product.Tools["chat.create_group"] = tool
+ extractionHistoricalMissing.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(extractionHistoricalMissing, current, migrations[1:]); err == nil || !strings.Contains(err.Error(), "historical Schema tool lacks") {
+ t.Fatalf("missing extracted historical parameter error=%v", err)
+ }
+
+ extractionStillPublished := cloneContract(current)
+ product = extractionStillPublished.Products["chat"]
+ tool = product.Tools["chat.create_group"]
+ tool.Parameters["thread"] = baseline.Products["chat"].Tools["chat.create_group"].Parameters["thread"]
+ product.Tools["chat.create_group"] = tool
+ extractionStillPublished.Products["chat"] = product
+ if _, err := normalizeSchemaCommandMigrations(baseline, extractionStillPublished, migrations[1:]); err == nil || !strings.Contains(err.Error(), "still publishes extracted") {
+ t.Fatalf("still-published extraction error=%v", err)
+ }
+}
+
+func TestCrossPlatformCoverageSchemaFlagExtractionRequiresCompleteReplacementProjection(t *testing.T) {
+ t.Run("missing historical mapping", func(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ current := schemaCommandMigrationContract(true)
+ migrations := schemaCommandMigrationAuthorizations()
+ parameters := make([]interfacesnapshot.CommandParameterMigration, 0, len(migrations[1].Schema.Parameters)-1)
+ for _, parameter := range migrations[1].Schema.Parameters {
+ if parameter.From != "users" {
+ parameters = append(parameters, parameter)
+ }
+ }
+ migrations[1].Schema.Parameters = parameters
+
+ if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil ||
+ !strings.Contains(err.Error(), `does not map historical Schema parameter "users"`) {
+ t.Fatalf("missing historical mapping error=%v", err)
+ }
+ })
+
+ for _, test := range []struct {
+ name string
+ mutate func([]interfacesnapshot.CommandMigration)
+ want string
+ }{
+ {
+ name: "duplicate historical mapping",
+ mutate: func(migrations []interfacesnapshot.CommandMigration) {
+ migrations[1].Schema.Parameters = append(
+ migrations[1].Schema.Parameters,
+ migrations[1].Schema.Parameters[0],
+ )
+ },
+ want: "maps historical Schema parameter \"name\" more than once",
+ },
+ {
+ name: "legacy mapping has two targets",
+ mutate: func(migrations []interfacesnapshot.CommandMigration) {
+ migrations[1].Schema.Parameters[3].To = "thread"
+ },
+ want: "must map to one replacement constant",
+ },
+ {
+ name: "shared mapping has no target",
+ mutate: func(migrations []interfacesnapshot.CommandMigration) {
+ migrations[1].Schema.Parameters[0].To = ""
+ },
+ want: "must map to one replacement parameter",
+ },
+ {
+ name: "duplicate replacement target",
+ mutate: func(migrations []interfacesnapshot.CommandMigration) {
+ migrations[1].Schema.Parameters[1].To = "name"
+ },
+ want: "is mapped from both \"name\" and \"type\"",
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ current := schemaCommandMigrationContract(true)
+ migrations := schemaCommandMigrationAuthorizations()
+ test.mutate(migrations)
+ if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil ||
+ !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("invalid mapping error=%v, want %q", err, test.want)
+ }
+ })
+ }
+
+ t.Run("replacement removes historical dry run", func(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ mutateSchemaCommandMigrationTool(&baseline, "chat.create_group", func(tool *toolSchema) {
+ tool.DryRun = `{"mode":"native"}`
+ })
+ current := schemaCommandMigrationContract(true)
+ mutateSchemaCommandMigrationTool(¤t, "chat.create_group", func(tool *toolSchema) {
+ tool.DryRun = `{"mode":"native"}`
+ })
+
+ if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil ||
+ !strings.Contains(err.Error(), "changed or removed dry_run") {
+ t.Fatalf("replacement dry_run error=%v, want monotonic preservation rejection", err)
+ }
+ })
+
+ t.Run("replacement adds dry run", func(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ current := schemaCommandMigrationContract(true)
+ mutateSchemaCommandMigrationTool(¤t, "chat.create_topic", func(tool *toolSchema) {
+ tool.DryRun = `{"mode":"native"}`
+ })
+
+ if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err != nil {
+ t.Fatalf("additive replacement dry_run was rejected: %v", err)
+ }
+ })
+
+ for _, test := range []struct {
+ name string
+ mutate func(*schemaContract)
+ want string
+ }{
+ {
+ name: "missing shared parameter",
+ mutate: func(current *schemaContract) {
+ mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
+ delete(tool.Parameters, "users")
+ })
+ },
+ want: `does not publish mapped Schema parameter "users"`,
+ },
+ {
+ name: "shared parameter field drift",
+ mutate: func(current *schemaContract) {
+ mutateSchemaCommandMigrationParameter(current, "chat.create_topic", "users", func(parameter *parameterSchema) {
+ parameter.Property = "differentUserIds"
+ })
+ },
+ want: "changed a non-name field",
+ },
+ {
+ name: "extra replacement parameter",
+ mutate: func(current *schemaContract) {
+ mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
+ tool.Parameters["extra"] = parameterSchema{Type: `"string"`, Property: "extra"}
+ })
+ },
+ want: `publishes unmapped Schema parameter "extra"`,
+ },
+ {
+ name: "constraints drift",
+ mutate: func(current *schemaContract) {
+ mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
+ tool.Constraints = `{"require_one_of":[["name","type"]]}`
+ })
+ },
+ want: "changed constraints",
+ },
+ {
+ name: "positionals drift",
+ mutate: func(current *schemaContract) {
+ mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
+ tool.Positionals[0].Name = "topic-name"
+ })
+ },
+ want: "changed positionals",
+ },
+ {
+ name: "positionals length drift",
+ mutate: func(current *schemaContract) {
+ mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
+ tool.Positionals = append(tool.Positionals, positionalSchema{Name: "type", Index: 1, Type: "string"})
+ })
+ },
+ want: "changed positionals",
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ current := schemaCommandMigrationContract(true)
+ test.mutate(¤t)
+ if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil ||
+ !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("replacement projection error=%v, want %q", err, test.want)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageSchemaFlagExtractionValidatesBooleanConstant(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ mutate func(*schemaContract, *schemaContract, []interfacesnapshot.CommandMigration)
+ want string
+ }{
+ {
+ name: "constant property mismatch",
+ mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) {
+ migrations[1].Schema.Parameters[3].ReplacementConstant.Property = "differentProperty"
+ },
+ want: "constant property",
+ },
+ {
+ name: "constant false",
+ mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) {
+ migrations[1].Schema.Parameters[3].ReplacementConstant.Value = false
+ },
+ want: "constant true",
+ },
+ {
+ name: "legacy type",
+ mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
+ parameter.Type = `"string"`
+ })
+ },
+ want: "must have boolean type",
+ },
+ {
+ name: "legacy required",
+ mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
+ parameter.Required = true
+ })
+ },
+ want: "must remain optional",
+ },
+ {
+ name: "legacy cli required",
+ mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
+ parameter.CLIRequired = true
+ })
+ },
+ want: "must remain optional",
+ },
+ {
+ name: "legacy required when",
+ mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
+ parameter.RequiredWhen = "mode=topic"
+ })
+ },
+ want: "must not declare required_when",
+ },
+ {
+ name: "legacy default true",
+ mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
+ parameter.Default = "true"
+ })
+ },
+ want: "default must be absent or false",
+ },
+ {
+ name: "legacy interface default true",
+ mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
+ parameter.InterfaceDefault = "true"
+ })
+ },
+ want: "interface_default must be absent or false",
+ },
+ {
+ name: "legacy interface type",
+ mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
+ parameter.InterfaceType = "string"
+ })
+ },
+ want: "interface_type must be empty or boolean",
+ },
+ {
+ name: "legacy enum excludes true",
+ mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
+ parameter.Enum = []string{"false"}
+ })
+ },
+ want: "enum must allow true",
+ },
+ {
+ name: "legacy format",
+ mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) {
+ parameter.Format = "custom-bool"
+ })
+ },
+ want: "format must be empty",
+ },
+ {
+ name: "replacement publishes constant property",
+ mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) {
+ tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"}
+ })
+ },
+ want: "still publishes replacement constant property",
+ },
+ {
+ name: "source renames constant property",
+ mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(current, "chat.create_group", func(tool *toolSchema) {
+ tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"}
+ })
+ },
+ want: "source Schema tool still publishes replacement constant property",
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ baseline := schemaCommandMigrationContract(false)
+ current := schemaCommandMigrationContract(true)
+ migrations := schemaCommandMigrationAuthorizations()
+ test.mutate(&baseline, ¤t, migrations)
+ if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil ||
+ !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("replacement constant error=%v, want %q", err, test.want)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T) {
+ directory := t.TempDir()
+ baselinePath := filepath.Join(directory, "baseline.json")
+ currentPath := filepath.Join(directory, "current.json")
+ approvedPath := filepath.Join(directory, "approved.json")
+ candidatePath := filepath.Join(directory, "candidate.json")
+ currentSnapshotPath := filepath.Join(directory, "current-snapshot.json")
+ baseSnapshotPath := filepath.Join(directory, "base-snapshot.json")
+ stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json")
+
+ writeSchemaContractFile(t, baselinePath, schemaCommandMigrationContract(false))
+ writeRawSchemaContractFile(t, currentPath, schemaCommandMigrationContract(true))
+ writeCommandMigrationManifestFile(t, approvedPath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationPending))
+ writeCommandMigrationManifestFile(t, candidatePath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationConsumed))
+ writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandMigrationSnapshot(true))
+ writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandMigrationSnapshot(false))
+ writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandMigrationSnapshot(false))
+
+ args := []string{
+ "--check", baselinePath,
+ "--current", currentPath,
+ "--approved-command-migrations", approvedPath,
+ "--candidate-command-migrations", candidatePath,
+ "--migration-current-snapshot", currentSnapshotPath,
+ "--migration-base-snapshot", baseSnapshotPath,
+ "--migration-stable-snapshot", stableSnapshotPath,
+ }
+ var stdout, stderr strings.Builder
+ if code := run(args, &stdout, &stderr); code != 0 {
+ t.Fatalf("command migration run code=%d stderr=%s", code, stderr.String())
+ }
+
+ paths := []string{approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath}
+ for index := range paths {
+ invalid := append([]string(nil), paths...)
+ invalid[index] = filepath.Join(directory, "missing.json")
+ if _, err := authorizeSchemaCommandMigrations(invalid[0], invalid[1], invalid[2], invalid[3], invalid[4]); err == nil {
+ t.Fatalf("missing command migration input %d was accepted", index)
+ }
+ }
+
+ stderr.Reset()
+ if code := run([]string{"--check", baselinePath, "--current", currentPath, "--approved-command-migrations", approvedPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both command manifests") {
+ t.Fatalf("partial command pair code=%d stderr=%q", code, stderr.String())
+ }
+ stderr.Reset()
+ if code := run([]string{
+ "--check", baselinePath,
+ "--current", currentPath,
+ "--approved-command-migrations", approvedPath,
+ "--candidate-command-migrations", candidatePath,
+ }, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all three interface snapshots") {
+ t.Fatalf("missing command snapshots code=%d stderr=%q", code, stderr.String())
+ }
+ stderr.Reset()
+ if code := run([]string{"--check", baselinePath, "--current", currentPath, "--migration-current-snapshot", currentSnapshotPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "require a flag or command") {
+ t.Fatalf("orphan snapshot code=%d stderr=%q", code, stderr.String())
+ }
+
+ badArgs := append([]string(nil), args...)
+ badArgs[5] = filepath.Join(directory, "missing-approved.json")
+ stderr.Reset()
+ if code := run(badArgs, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "authorize Schema command migrations") {
+ t.Fatalf("command authorization error code=%d stderr=%q", code, stderr.String())
+ }
+
+ legacyCurrent := schemaCommandMigrationContract(true)
+ product := legacyCurrent.Products["chat"]
+ tool := product.Tools["chat.move"]
+ tool.Parameters["old-id"] = schemaCommandMigrationContract(false).Products["chat"].Tools["chat.move"].Parameters["old-id"]
+ product.Tools["chat.move"] = tool
+ legacyCurrent.Products["chat"] = product
+ writeRawSchemaContractFile(t, currentPath, legacyCurrent)
+ stderr.Reset()
+ if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "normalize approved Schema command migrations") {
+ t.Fatalf("command normalization error code=%d stderr=%q", code, stderr.String())
+ }
+
+ requiredAddition := schemaCommandMigrationContract(true)
+ product = requiredAddition.Products["chat"]
+ tool = product.Tools["chat.move"]
+ tool.Parameters["must"] = parameterSchema{Type: `"string"`, Property: "must", Required: true, CLIRequired: true}
+ product.Tools["chat.move"] = tool
+ requiredAddition.Products["chat"] = product
+ writeRawSchemaContractFile(t, currentPath, requiredAddition)
+ stderr.Reset()
+ if code := run(args, &stdout, &stderr); code != 2 ||
+ !strings.Contains(stderr.String(), `introduced unregistered required Schema parameter "must"`) {
+ t.Fatalf("unregistered required addition code=%d stderr=%q", code, stderr.String())
+ }
+}
+
+func TestCrossPlatformCoverageSchemaCommandMigrationComposesHistoricalFlagLineage(t *testing.T) {
+ directory := t.TempDir()
+ stableContract, baseContract, currentContract := schemaCommandLineageContracts()
+ baselinePath := filepath.Join(directory, "stable-schema.json")
+ baseSchemaPath := filepath.Join(directory, "base-schema.json")
+ currentPath := filepath.Join(directory, "current-schema.json")
+ approvedFlagPath := filepath.Join(directory, "approved-flags.json")
+ candidateFlagPath := filepath.Join(directory, "candidate-flags.json")
+ approvedCommandPath := filepath.Join(directory, "approved-commands.json")
+ candidateCommandPath := filepath.Join(directory, "candidate-commands.json")
+ currentSnapshotPath := filepath.Join(directory, "current-snapshot.json")
+ baseSnapshotPath := filepath.Join(directory, "base-snapshot.json")
+ stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json")
+
+ writeSchemaContractFile(t, baselinePath, stableContract)
+ writeSchemaContractFile(t, baseSchemaPath, baseContract)
+ writeRawSchemaContractFile(t, currentPath, currentContract)
+ writeFlagMigrationManifestFile(t, approvedFlagPath, schemaCommandLineageFlagManifest())
+ writeFlagMigrationManifestFile(t, candidateFlagPath, schemaCommandLineageFlagManifest())
+ writeCommandMigrationManifestFile(t, approvedCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending))
+ writeCommandMigrationManifestFile(t, candidateCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed))
+ writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandLineageSnapshot(true, true))
+ writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandLineageSnapshot(true, false))
+ writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandLineageSnapshot(false, false))
+
+ args := []string{
+ "--check", baselinePath,
+ "--current", currentPath,
+ "--migration-base-schema", baseSchemaPath,
+ "--approved-flag-migrations", approvedFlagPath,
+ "--candidate-flag-migrations", candidateFlagPath,
+ "--approved-command-migrations", approvedCommandPath,
+ "--candidate-command-migrations", candidateCommandPath,
+ "--migration-current-snapshot", currentSnapshotPath,
+ "--migration-base-snapshot", baseSnapshotPath,
+ "--migration-stable-snapshot", stableSnapshotPath,
+ }
+ var stdout, stderr strings.Builder
+ if code := run(args, &stdout, &stderr); code != 0 {
+ t.Fatalf("pending command lineage code=%d stderr=%s", code, stderr.String())
+ }
+
+ withoutBaseSchema := append([]string(nil), args[:4]...)
+ withoutBaseSchema = append(withoutBaseSchema, args[6:]...)
+ stderr.Reset()
+ if code := run(withoutBaseSchema, &stdout, &stderr); code != 2 ||
+ !strings.Contains(stderr.String(), "requires --migration-base-schema") {
+ t.Fatalf("missing migration base Schema code=%d stderr=%q", code, stderr.String())
+ }
+ stderr.Reset()
+ if code := run([]string{
+ "--check", baselinePath,
+ "--current", currentPath,
+ "--migration-base-schema", baseSchemaPath,
+ }, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "requires both flag and command") {
+ t.Fatalf("orphan migration base Schema code=%d stderr=%q", code, stderr.String())
+ }
+ missingBaseSchema := append([]string(nil), args...)
+ missingBaseSchema[5] = filepath.Join(directory, "missing-base-schema.json")
+ stderr.Reset()
+ if code := run(missingBaseSchema, &stdout, &stderr); code != 2 ||
+ !strings.Contains(stderr.String(), "read migration merge-base Schema contract") {
+ t.Fatalf("unreadable migration base Schema code=%d stderr=%q", code, stderr.String())
+ }
+
+ // After the command move is merged, the merge-base Schema is already at the
+ // final name. The two consumed receipts must keep the stable lineage durable
+ // until the stable release itself reaches the after state.
+ writeSchemaContractFile(t, baseSchemaPath, currentContract)
+ writeCommandMigrationManifestFile(t, approvedCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed))
+ writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandLineageSnapshot(true, true))
+ stdout.Reset()
+ stderr.Reset()
+ if code := run(args, &stdout, &stderr); code != 0 {
+ t.Fatalf("consumed command lineage code=%d stderr=%s", code, stderr.String())
+ }
+}
+
+func TestCrossPlatformCoverageSchemaCommandMigrationLineageFailsClosed(t *testing.T) {
+ tests := []struct {
+ name string
+ commandState string
+ mutate func(*schemaContract, *schemaContract, *schemaContract, *[]interfacesnapshot.FlagMigration, *[]interfacesnapshot.CommandMigration)
+ want string
+ }{
+ {
+ name: "pending merge-base missing intermediate",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
+ delete(tool.Parameters, "conversation-id")
+ })
+ },
+ want: "merge-base Schema lacks intermediate parameter",
+ },
+ {
+ name: "merge-base missing source tool",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ product := base.Products["chat"]
+ delete(product.Tools, "chat.list_topic_replies")
+ base.Products["chat"] = product
+ },
+ want: "merge-base Schema lacks source tool",
+ },
+ {
+ name: "pending merge-base parameter drift",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(base, "chat.list_topic_replies", "conversation-id", func(parameter *parameterSchema) {
+ parameter.Property = "differentProperty"
+ })
+ },
+ want: "changed a non-migration field",
+ },
+ {
+ name: "pending merge-base wrong path",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.PrimaryCLIPath = "chat unrelated"
+ })
+ },
+ want: "merge-base Schema source tool has primary_cli_path",
+ },
+ {
+ name: "pending merge-base already publishes final",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["open-topic-id"] = tool.Parameters["conversation-id"]
+ })
+ },
+ want: "already publishes final parameter",
+ },
+ {
+ name: "pending flag receipt",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, _, _ *schemaContract, flags *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ (*flags)[0].State = interfacesnapshot.FlagMigrationPending
+ },
+ want: "requires a consumed flag migration receipt",
+ },
+ {
+ name: "flag receipt belongs to another command",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, _, _ *schemaContract, flags *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ (*flags)[0].Command = "dws chat message other"
+ },
+ want: `historical Schema tool lacks parameter "conversation-id"`,
+ },
+ {
+ name: "current retains predecessor",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(stable, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["group"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
+ })
+ },
+ want: "current Schema still publishes predecessor parameter",
+ },
+ {
+ name: "pending current constraints retain predecessor",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Constraints = `{"require_together":[["group","open-conv-thread-id","open-topic-id"]]}`
+ })
+ },
+ want: "current Schema constraints still reference predecessor parameter",
+ },
+ {
+ name: "consumed current constraints retain predecessor",
+ commandState: interfacesnapshot.CommandMigrationConsumed,
+ mutate: func(_, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Constraints = `{"require_together":[["group","open-conv-thread-id","open-topic-id"]]}`
+ })
+ },
+ want: "current Schema constraints still reference predecessor parameter",
+ },
+ {
+ name: "current retains intermediate",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, base, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["conversation-id"] = base.Products["chat"].Tools["chat.list_topic_replies"].Parameters["conversation-id"]
+ })
+ },
+ want: `still publishes legacy Schema parameter "conversation-id"`,
+ },
+ {
+ name: "consumed merge-base final drift",
+ commandState: interfacesnapshot.CommandMigrationConsumed,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationParameter(base, "chat.list_topic_replies", "open-topic-id", func(parameter *parameterSchema) {
+ parameter.Required = false
+ })
+ },
+ want: "changed a non-name field",
+ },
+ {
+ name: "consumed merge-base wrong path",
+ commandState: interfacesnapshot.CommandMigrationConsumed,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.PrimaryCLIPath = "chat unrelated"
+ })
+ },
+ want: "consumed command migration",
+ },
+ {
+ name: "consumed merge-base missing final",
+ commandState: interfacesnapshot.CommandMigrationConsumed,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
+ delete(tool.Parameters, "open-topic-id")
+ })
+ },
+ want: "merge-base Schema lacks final parameter",
+ },
+ {
+ name: "consumed merge-base retains intermediate",
+ commandState: interfacesnapshot.CommandMigrationConsumed,
+ mutate: func(stable, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["conversation-id"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
+ })
+ },
+ want: "still publishes intermediate parameter",
+ },
+ {
+ name: "pending constraints drift",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Constraints = `{"require_one_of":[["conversation-id"]]}`
+ })
+ },
+ want: "changed merge-base Schema constraints",
+ },
+ {
+ name: "pending current keeps intermediate constraints",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, base, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Constraints = base.Products["chat"].Tools["chat.list_topic_replies"].Constraints
+ })
+ },
+ want: "still reference legacy Schema constraint parameter",
+ },
+ {
+ name: "merge-base retains predecessor",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(stable, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["group"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
+ })
+ },
+ want: "merge-base Schema still publishes predecessor parameter",
+ },
+ {
+ name: "historical constraints malformed",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(stable, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Constraints = "not-json"
+ })
+ },
+ want: "historical Schema constraints are not canonicalizable",
+ },
+ {
+ name: "merge-base constraints malformed",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Constraints = "not-json"
+ })
+ },
+ want: "merge-base Schema constraints are not canonicalizable",
+ },
+ {
+ name: "consumed constraints drift",
+ commandState: interfacesnapshot.CommandMigrationConsumed,
+ mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Constraints = `{"require_one_of":[["open-topic-id"]]}`
+ })
+ },
+ want: "changed merge-base Schema constraints",
+ },
+ {
+ name: "target collision",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ mutateSchemaCommandMigrationTool(stable, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["open-topic-id"] = tool.Parameters["group"]
+ })
+ },
+ want: "target \"open-topic-id\" already exists",
+ },
+ {
+ name: "lineage cycle",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, commands *[]interfacesnapshot.CommandMigration) {
+ (*commands)[0].Schema.Parameters[0].To = "group"
+ },
+ want: "lineage cycle",
+ },
+ {
+ name: "unsupported command receipt state",
+ commandState: "unknown",
+ mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ },
+ want: "unsupported lineage state",
+ },
+ {
+ name: "duplicate historical path",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
+ product := stable.Products["chat"]
+ product.Tools["chat.duplicate"] = product.Tools["chat.list_topic_replies"]
+ stable.Products["chat"] = product
+ },
+ want: "matches 2 historical Schema tools",
+ },
+ {
+ name: "source tool fork",
+ commandState: interfacesnapshot.CommandMigrationPending,
+ mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, commands *[]interfacesnapshot.CommandMigration) {
+ fork := (*commands)[0]
+ fork.Legacy.Command = "dws chat message fork"
+ fork.Replacement.Command = "dws chat topic fork"
+ *commands = append(*commands, fork)
+ },
+ want: "fork Schema source tool",
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ stable, baseBefore, current := schemaCommandLineageContracts()
+ base := baseBefore
+ if test.commandState == interfacesnapshot.CommandMigrationConsumed {
+ base = cloneContract(current)
+ }
+ flags := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...)
+ commands := append([]interfacesnapshot.CommandMigration(nil), schemaCommandLineageManifest(test.commandState).Migrations...)
+ test.mutate(&stable, &base, ¤t, &flags, &commands)
+ if _, err := normalizeSchemaCommandMigrationLineage(stable, base, current, flags, commands); err == nil ||
+ !strings.Contains(err.Error(), test.want) {
+ t.Fatalf("lineage error=%v, want %q", err, test.want)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChecks(t *testing.T) {
+ stable, base, current := schemaCommandLineageContracts()
+ mutateSchemaCommandMigrationTool(¤t, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Positionals = []positionalSchema{{Name: "open-topic-id", Index: 0, Type: "string", Required: true}}
+ })
+ normalized, err := normalizeSchemaCommandMigrationLineage(
+ stable,
+ base,
+ current,
+ schemaCommandLineageFlagManifest().Migrations,
+ schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, "changed positionals") {
+ t.Fatalf("lineage hid positional drift: %s", failures)
+ }
+
+ requiredness := interfacesnapshot.FlagMigration{
+ Kind: interfacesnapshot.FlagMigrationRequirednessChange,
+ Command: "dws report cli-only",
+ Flag: &interfacesnapshot.FlagMigrationSide{
+ Name: "recipient",
+ Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"},
+ After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
+ },
+ State: interfacesnapshot.FlagMigrationConsumed,
+ Reason: "Exercise requiredness receipts beside command lineage without treating them as renames.",
+ }
+ flagsWithRequiredness := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...)
+ flagsWithRequiredness = append(flagsWithRequiredness, requiredness)
+ if _, err := normalizeSchemaCommandMigrationLineage(
+ stable,
+ base,
+ current,
+ flagsWithRequiredness,
+ schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
+ ); err != nil {
+ t.Fatalf("requiredness receipt interfered with command lineage: %v", err)
+ }
+
+ // Multiple historical names may converge only when every predecessor carries
+ // the exact same contract and every receipt is already consumed.
+ stable, base, current = schemaCommandLineageContracts()
+ mutateSchemaCommandMigrationTool(&stable, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["id"] = tool.Parameters["group"]
+ })
+ flags := schemaCommandLineageFlagManifest().Migrations
+ second := flags[0]
+ second.Legacy.Name = "id"
+ flags = append(flags, second)
+ normalized, err = normalizeSchemaCommandMigrationLineage(
+ stable,
+ base,
+ current,
+ flags,
+ schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
+ )
+ if err != nil {
+ t.Fatalf("equivalent predecessor aliases should pass: %v", err)
+ }
+ if failures := checkCompatibility(normalized, current); len(failures) != 0 {
+ t.Fatalf("equivalent predecessor aliases remained incompatible: %v", failures)
+ }
+
+ mutateSchemaCommandMigrationParameter(&stable, "chat.list_topic_replies", "id", func(parameter *parameterSchema) {
+ parameter.Property = "differentProperty"
+ })
+ if _, err := normalizeSchemaCommandMigrationLineage(
+ stable,
+ base,
+ current,
+ flags,
+ schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
+ ); err == nil || !strings.Contains(err.Error(), "changed a non-migration field") {
+ t.Fatalf("drifted predecessor alias error=%v", err)
+ }
+}
+
+func TestCrossPlatformCoverageSchemaCommandMigrationLineageDefensiveEdges(t *testing.T) {
+ stable, base, current := schemaCommandLineageContracts()
+ flags := schemaCommandLineageFlagManifest().Migrations
+ commands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
+
+ flagExtraction := schemaCommandMigrationAuthorizations()[1]
+ if _, err := stageSchemaCommandMigrationPredecessors(stable, base, current, flags, []interfacesnapshot.CommandMigration{flagExtraction}); err != nil {
+ t.Fatalf("non-move command migration should be ignored: %v", err)
+ }
+
+ missingTool := cloneContract(stable)
+ product := missingTool.Products["chat"]
+ delete(product.Tools, "chat.list_topic_replies")
+ missingTool.Products["chat"] = product
+ if _, err := stageSchemaCommandMigrationPredecessors(missingTool, base, current, flags, commands); err != nil {
+ t.Fatalf("missing historical source should remain for the ordinary checker: %v", err)
+ }
+ if _, err := stageSchemaCommandMigrationPredecessors(current, current, current, flags, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed).Migrations); err != nil {
+ t.Fatalf("already-after historical source should be a no-op: %v", err)
+ }
+ wrongPath := cloneContract(stable)
+ mutateSchemaCommandMigrationTool(&wrongPath, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.PrimaryCLIPath = "chat unrelated"
+ })
+ if _, err := stageSchemaCommandMigrationPredecessors(wrongPath, base, current, flags, commands); err != nil {
+ t.Fatalf("wrong historical path should remain for the command normalizer: %v", err)
+ }
+
+ bothNames := cloneContract(stable)
+ mutateSchemaCommandMigrationTool(&bothNames, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["conversation-id"] = tool.Parameters["group"]
+ })
+ if _, err := stageSchemaCommandMigrationPredecessors(bothNames, base, current, flags, commands); err == nil ||
+ !strings.Contains(err.Error(), "publishes both predecessor") {
+ t.Fatalf("ambiguous predecessor error=%v", err)
+ }
+
+ duplicatePath := cloneContract(stable)
+ product = duplicatePath.Products["chat"]
+ product.Tools["chat.duplicate"] = product.Tools["chat.list_topic_replies"]
+ duplicatePath.Products["chat"] = product
+ if _, err := stageSchemaCommandMigrationPredecessors(duplicatePath, base, current, flags, commands); err == nil ||
+ !strings.Contains(err.Error(), "requires one exact historical Schema tool") {
+ t.Fatalf("duplicate primary path error=%v", err)
+ }
+
+ forkStable, forkBase, forkCurrent := schemaCommandLineageContracts()
+ mutateSchemaCommandMigrationTool(&forkBase, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["other-mid"] = tool.Parameters["conversation-id"]
+ })
+ mutateSchemaCommandMigrationTool(&forkCurrent, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["other-final"] = tool.Parameters["open-topic-id"]
+ })
+ forkFlags := append([]interfacesnapshot.FlagMigration(nil), flags...)
+ forkFlag := flags[0]
+ forkFlag.Canonical.Name = "other-mid"
+ forkFlags = append(forkFlags, forkFlag)
+ forkCommands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
+ forkCommands[0].Schema.Parameters = append(forkCommands[0].Schema.Parameters,
+ interfacesnapshot.CommandParameterMigration{From: "other-mid", To: "other-final"})
+ if _, err := stageSchemaCommandMigrationPredecessors(forkStable, forkBase, forkCurrent, forkFlags, forkCommands); err == nil ||
+ !strings.Contains(err.Error(), "forks Schema predecessor") {
+ t.Fatalf("forked predecessor error=%v", err)
+ }
+
+ cycleStable, cycleBase, _ := schemaCommandLineageContracts()
+ mutateSchemaCommandMigrationTool(&cycleStable, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["id"] = tool.Parameters["group"]
+ })
+ mutateSchemaCommandMigrationTool(&cycleBase, "chat.list_topic_replies", func(tool *toolSchema) {
+ tool.Parameters["other-mid"] = tool.Parameters["conversation-id"]
+ })
+ cycleFlags := append([]interfacesnapshot.FlagMigration(nil), flags...)
+ cycleFlag := flags[0]
+ cycleFlag.Legacy.Name = "id"
+ cycleFlag.Canonical.Name = "other-mid"
+ cycleFlags = append(cycleFlags, cycleFlag)
+ cycleCommands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
+ cycleCommands[0].Schema.Parameters[0].To = "id"
+ cycleCommands[0].Schema.Parameters = append(cycleCommands[0].Schema.Parameters,
+ interfacesnapshot.CommandParameterMigration{From: "other-mid", To: "other-final"})
+ missingCurrent := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{}}
+ if _, err := stageSchemaCommandMigrationPredecessors(cycleStable, cycleBase, missingCurrent, cycleFlags, cycleCommands); err == nil ||
+ !strings.Contains(err.Error(), "lineage cycle") {
+ t.Fatalf("cross-lineage cycle error=%v", err)
+ }
+}
+
+func schemaCommandMigrationContract(after bool) schemaContract {
+ id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true}
+ keep := parameterSchema{Type: `"string"`, Property: "keep"}
+ name := parameterSchema{Type: `"string"`, Property: "name"}
+ groupType := parameterSchema{Type: `"string"`, Property: "type"}
+ users := parameterSchema{Type: `"array"`, Property: "userIds"}
+ thread := parameterSchema{
+ Type: `"boolean"`,
+ Property: "threadEnabled",
+ InterfaceType: "boolean",
+ Default: "false",
+ InterfaceDefault: "false",
+ Enum: []string{"false", "true"},
+ }
+ group := toolSchema{
+ PrimaryCLIPath: "chat group create",
+ InterfaceMode: "mcp",
+ InterfaceRef: `{"product_id":"im","rpc_name":"create_group"}`,
+ Availability: "available",
+ Parameters: map[string]parameterSchema{
+ "name": name,
+ "thread": thread,
+ "type": groupType,
+ "users": users,
+ },
+ Constraints: `{"require_together":[["name","type"]]}`,
+ Positionals: []positionalSchema{{Name: "name", Index: 0, Type: "string", Required: true}},
+ Effect: "write",
+ Risk: "medium",
+ Confirmation: "not_required",
+ Idempotency: "unknown",
+ }
+ move := toolSchema{
+ PrimaryCLIPath: "chat message old",
+ InterfaceMode: "mcp",
+ InterfaceRef: `{"product_id":"chat","rpc_name":"move"}`,
+ Availability: "available",
+ Parameters: map[string]parameterSchema{"old-id": id, "keep": keep},
+ Constraints: `{"require_together":[["keep","old-id"]]}`,
+ Effect: "read",
+ Risk: "low",
+ Confirmation: "not_required",
+ Idempotency: "idempotent",
+ }
+ tools := map[string]toolSchema{"chat.create_group": group, "chat.move": move}
+ if after {
+ delete(group.Parameters, "thread")
+ tools["chat.create_group"] = group
+ replacement := group
+ replacement.Parameters = make(map[string]parameterSchema, len(group.Parameters))
+ for name, parameter := range group.Parameters {
+ replacement.Parameters[name] = parameter
+ }
+ replacement.Positionals = append([]positionalSchema(nil), group.Positionals...)
+ replacement.PrimaryCLIPath = "chat topic create"
+ tools["chat.create_topic"] = replacement
+ delete(move.Parameters, "old-id")
+ move.Parameters["new-id"] = id
+ move.PrimaryCLIPath = "chat topic new"
+ move.Constraints = `{"require_together":[["keep","new-id"]]}`
+ tools["chat.move"] = move
+ }
+ return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}}
+}
+
+func schemaCommandLineageContracts() (schemaContract, schemaContract, schemaContract) {
+ conversation := parameterSchema{
+ Type: `"string"`,
+ Property: "openconversationId",
+ InterfaceType: "string",
+ Required: true,
+ CLIRequired: true,
+ }
+ topic := parameterSchema{
+ Type: `"string"`,
+ Property: "openConversationThreadId",
+ InterfaceType: "string",
+ Required: true,
+ CLIRequired: true,
+ }
+ tool := toolSchema{
+ PrimaryCLIPath: "chat message list-topic-replies",
+ InterfaceMode: "mcp",
+ InterfaceRef: `{"product_id":"im","rpc_name":"list_topic_replies"}`,
+ Availability: "available",
+ Parameters: map[string]parameterSchema{
+ "group": conversation,
+ "topic-id": topic,
+ },
+ Constraints: `{"require_together":[["group","topic-id"]]}`,
+ Effect: "read",
+ Risk: "low",
+ Confirmation: "not_required",
+ Idempotency: "idempotent",
+ }
+ stable := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
+ "chat": {Tools: map[string]toolSchema{"chat.list_topic_replies": tool}},
+ }}
+
+ base := cloneContract(stable)
+ mutateSchemaCommandMigrationTool(&base, "chat.list_topic_replies", func(tool *toolSchema) {
+ delete(tool.Parameters, "group")
+ tool.Parameters["conversation-id"] = conversation
+ tool.Constraints = `{"require_together":[["conversation-id","topic-id"]]}`
+ })
+
+ current := cloneContract(base)
+ mutateSchemaCommandMigrationTool(¤t, "chat.list_topic_replies", func(tool *toolSchema) {
+ delete(tool.Parameters, "conversation-id")
+ delete(tool.Parameters, "topic-id")
+ tool.Parameters["open-topic-id"] = conversation
+ tool.Parameters["open-conv-thread-id"] = topic
+ tool.PrimaryCLIPath = "chat topic list-replies"
+ tool.Constraints = `{"require_together":[["open-conv-thread-id","open-topic-id"]]}`
+ })
+ return stable, base, current
+}
+
+func schemaCommandLineageFlagManifest() interfacesnapshot.FlagMigrationManifest {
+ beforeCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local"}
+ afterCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}
+ return interfacesnapshot.FlagMigrationManifest{
+ Version: interfacesnapshot.FlagMigrationManifestVersion,
+ Migrations: []interfacesnapshot.FlagMigration{{
+ Command: "dws chat message list-topic-replies",
+ Legacy: interfacesnapshot.FlagMigrationSide{
+ Name: "group",
+ Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
+ After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "conversation-id"},
+ },
+ Canonical: interfacesnapshot.FlagMigrationSide{
+ Name: "conversation-id",
+ Before: beforeCanonical,
+ After: afterCanonical,
+ },
+ State: interfacesnapshot.FlagMigrationConsumed,
+ Reason: "preserve the reviewed group to conversation-id lineage",
+ }},
+ }
+}
+
+func schemaCommandLineageManifest(state string) interfacesnapshot.CommandMigrationManifest {
+ return interfacesnapshot.CommandMigrationManifest{
+ Version: interfacesnapshot.CommandMigrationManifestVersion,
+ Migrations: []interfacesnapshot.CommandMigration{{
+ Kind: interfacesnapshot.CommandMigrationMove,
+ Legacy: interfacesnapshot.CommandMigrationSide{
+ Command: "dws chat message list-topic-replies",
+ Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
+ After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
+ },
+ Replacement: interfacesnapshot.CommandMigrationSide{
+ Command: "dws chat topic list-replies",
+ Before: interfacesnapshot.CommandMigrationState{},
+ After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
+ },
+ Schema: interfacesnapshot.CommandMigrationSchema{
+ ProductID: "chat",
+ SourceToolID: "chat.list_topic_replies",
+ ReplacementToolID: "chat.list_topic_replies",
+ Parameters: []interfacesnapshot.CommandParameterMigration{
+ {From: "conversation-id", To: "open-topic-id"},
+ {From: "topic-id", To: "open-conv-thread-id"},
+ },
+ },
+ State: state,
+ Reason: "move topic reply listing while retaining the legacy command",
+ }},
+ }
+}
+
+func schemaCommandLineageSnapshot(flagAfter, commandAfter bool) interfacesnapshot.Snapshot {
+ legacyFlags := []interfacesnapshot.Flag{
+ {Name: "conversation-id", Type: "string", Hidden: true},
+ {Name: "group", Type: "string", Required: true},
+ {Name: "topic-id", Type: "string", Required: true},
+ }
+ if flagAfter {
+ legacyFlags[0].Hidden = false
+ legacyFlags[0].Required = true
+ legacyFlags[1].Required = false
+ legacyFlags[1].Hidden = true
+ legacyFlags[1].AliasOf = "conversation-id"
+ }
+ commands := []interfacesnapshot.Command{
+ {Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
+ {
+ Path: "dws chat message list-topic-replies",
+ Runnable: true,
+ Aliases: []string{},
+ LocalFlags: legacyFlags,
+ InheritedFlags: []interfacesnapshot.Flag{},
+ },
+ }
+ if commandAfter {
+ commands[1].Hidden = true
+ commands = append(commands, interfacesnapshot.Command{
+ Path: "dws chat topic list-replies",
+ Runnable: true,
+ Aliases: []string{},
+ LocalFlags: []interfacesnapshot.Flag{},
+ InheritedFlags: []interfacesnapshot.Flag{},
+ })
+ }
+ return interfacesnapshot.Snapshot{
+ SchemaVersion: interfacesnapshot.SchemaVersion,
+ Rules: interfacesnapshot.Rules{
+ ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"},
+ ExcludedFlags: []string{"help"},
+ },
+ Commands: commands,
+ }
+}
+
+func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration {
+ return []interfacesnapshot.CommandMigration{
+ {
+ Kind: interfacesnapshot.CommandMigrationMove,
+ Legacy: interfacesnapshot.CommandMigrationSide{
+ Command: "dws chat message old",
+ Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
+ After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
+ },
+ Replacement: interfacesnapshot.CommandMigrationSide{
+ Command: "dws chat topic new",
+ After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
+ },
+ Schema: interfacesnapshot.CommandMigrationSchema{
+ ProductID: "chat",
+ SourceToolID: "chat.move",
+ ReplacementToolID: "chat.move",
+ Parameters: []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "new-id"}},
+ },
+ },
+ {
+ Kind: interfacesnapshot.CommandMigrationFlagExtraction,
+ Legacy: interfacesnapshot.CommandMigrationSide{
+ Command: "dws chat group create",
+ Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
+ After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
+ },
+ Replacement: interfacesnapshot.CommandMigrationSide{
+ Command: "dws chat topic create",
+ After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
+ },
+ LegacyFlag: interfacesnapshot.CommandMigrationFlag{
+ Name: "thread",
+ Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"},
+ After: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"},
+ },
+ Schema: interfacesnapshot.CommandMigrationSchema{
+ ProductID: "chat",
+ SourceToolID: "chat.create_group",
+ ReplacementToolID: "chat.create_topic",
+ Parameters: []interfacesnapshot.CommandParameterMigration{
+ {From: "name", To: "name"},
+ {From: "type", To: "type"},
+ {From: "users", To: "users"},
+ {
+ From: "thread",
+ ReplacementConstant: &interfacesnapshot.CommandReplacementConstant{
+ Property: "threadEnabled",
+ Value: true,
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func schemaCommandMigrationManifest(state string) interfacesnapshot.CommandMigrationManifest {
+ migrations := schemaCommandMigrationAuthorizations()
+ for index := range migrations {
+ migrations[index].State = state
+ migrations[index].Reason = "Reviewed Schema command migration."
+ }
+ return interfacesnapshot.CommandMigrationManifest{
+ Version: interfacesnapshot.CommandMigrationManifestVersion,
+ Migrations: migrations,
+ }
+}
+
+func schemaCommandMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
+ commands := []interfacesnapshot.Command{
+ {Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
+ {
+ Path: "dws chat group create",
+ Runnable: true,
+ Aliases: []string{},
+ LocalFlags: []interfacesnapshot.Flag{{
+ Name: "thread", Type: "bool", NoOpt: "true",
+ }},
+ InheritedFlags: []interfacesnapshot.Flag{},
+ },
+ {Path: "dws chat message old", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
+ }
+ if after {
+ commands[1].LocalFlags[0].Hidden = true
+ commands[2].Hidden = true
+ commands = append(commands,
+ interfacesnapshot.Command{
+ Path: "dws chat topic create",
+ Runnable: true,
+ Aliases: []string{},
+ LocalFlags: []interfacesnapshot.Flag{},
+ InheritedFlags: []interfacesnapshot.Flag{},
+ BoolConstParams: map[string]bool{"threadEnabled": true},
+ },
+ interfacesnapshot.Command{Path: "dws chat topic new", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
+ )
+ }
+ return interfacesnapshot.Snapshot{
+ SchemaVersion: interfacesnapshot.SchemaVersion,
+ Rules: interfacesnapshot.Rules{
+ ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"},
+ ExcludedFlags: []string{"help"},
+ },
+ Commands: commands,
+ }
+}
+
+func mutateSchemaCommandMigrationTool(contract *schemaContract, toolID string, mutate func(*toolSchema)) {
+ product := contract.Products["chat"]
+ tool := product.Tools[toolID]
+ mutate(&tool)
+ product.Tools[toolID] = tool
+ contract.Products["chat"] = product
+}
+
+func mutateSchemaCommandMigrationParameter(
+ contract *schemaContract,
+ toolID string,
+ parameterName string,
+ mutate func(*parameterSchema),
+) {
+ mutateSchemaCommandMigrationTool(contract, toolID, func(tool *toolSchema) {
+ parameter := tool.Parameters[parameterName]
+ mutate(¶meter)
+ tool.Parameters[parameterName] = parameter
+ })
+}
+
+func writeCommandMigrationManifestFile(t *testing.T, path string, manifest interfacesnapshot.CommandMigrationManifest) {
+ t.Helper()
+ file, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.NewEncoder(file).Encode(manifest); err != nil {
+ _ = file.Close()
+ t.Fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go
index 1f55eae4..580eb7b6 100644
--- a/scripts/policy/schema-compat/main.go
+++ b/scripts/policy/schema-compat/main.go
@@ -86,6 +86,8 @@ func main() {
func run(args []string, stdout, stderr io.Writer) int {
var normalizePath, checkPath, mergePath, currentPath string
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
+ var approvedCommandMigrationsPath, candidateCommandMigrationsPath string
+ var migrationBaseSchemaPath string
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -95,6 +97,9 @@ func run(args []string, stdout, stderr io.Writer) int {
flags.StringVar(¤tPath, "current", "", "raw current complete Schema response")
flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest")
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
+ flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest")
+ flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest")
+ flags.StringVar(&migrationBaseSchemaPath, "migration-base-schema", "", "normalized merge-base Schema contract used to verify cross-migration lineage")
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
@@ -112,25 +117,46 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required")
return 2
}
- migrationInputs := []string{
- approvedFlagMigrationsPath,
- candidateFlagMigrationsPath,
+ flagMigrationPair := approvedFlagMigrationsPath != "" || candidateFlagMigrationsPath != ""
+ commandMigrationPair := approvedCommandMigrationsPath != "" || candidateCommandMigrationsPath != ""
+ if flagMigrationPair && (approvedFlagMigrationsPath == "" || candidateFlagMigrationsPath == "") {
+ fmt.Fprintln(stderr, "Schema flag migration authorization requires both flag manifests")
+ return 2
+ }
+ if commandMigrationPair && (approvedCommandMigrationsPath == "" || candidateCommandMigrationsPath == "") {
+ fmt.Fprintln(stderr, "Schema command migration authorization requires both command manifests")
+ return 2
+ }
+ migrationSnapshots := []string{
migrationCurrentSnapshotPath,
migrationBaseSnapshotPath,
migrationStableSnapshotPath,
}
- migrationInputCount := 0
- for _, path := range migrationInputs {
+ migrationSnapshotCount := 0
+ for _, path := range migrationSnapshots {
if path != "" {
- migrationInputCount++
+ migrationSnapshotCount++
}
}
- if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) {
- fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs")
+ migrationsEnabled := flagMigrationPair || commandMigrationPair
+ if migrationsEnabled && migrationSnapshotCount != len(migrationSnapshots) {
+ fmt.Fprintln(stderr, "Schema migration authorization requires all three interface snapshots")
return 2
}
- if migrationInputCount != 0 && checkPath == "" {
- fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check")
+ if !migrationsEnabled && migrationSnapshotCount != 0 {
+ fmt.Fprintln(stderr, "Schema migration snapshots require a flag or command migration manifest pair")
+ return 2
+ }
+ if migrationsEnabled && checkPath == "" {
+ fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check")
+ return 2
+ }
+ if flagMigrationPair && commandMigrationPair && migrationBaseSchemaPath == "" {
+ fmt.Fprintln(stderr, "combined Schema flag and command migration authorization requires --migration-base-schema")
+ return 2
+ }
+ if migrationBaseSchemaPath != "" && (!flagMigrationPair || !commandMigrationPair) {
+ fmt.Fprintln(stderr, "--migration-base-schema requires both flag and command migration manifest pairs")
return 2
}
@@ -159,8 +185,9 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
return 2
}
- if migrationInputCount != 0 {
- migrations, err := authorizeSchemaFlagMigrations(
+ var flagMigrations []interfacesnapshot.FlagMigration
+ if flagMigrationPair {
+ flagMigrations, err = authorizeSchemaFlagMigrations(
approvedFlagMigrationsPath,
candidateFlagMigrationsPath,
migrationCurrentSnapshotPath,
@@ -171,7 +198,41 @@ func run(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err)
return 2
}
- baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
+ }
+ if commandMigrationPair {
+ commandMigrations, err := authorizeSchemaCommandMigrations(
+ approvedCommandMigrationsPath,
+ candidateCommandMigrationsPath,
+ migrationCurrentSnapshotPath,
+ migrationBaseSnapshotPath,
+ migrationStableSnapshotPath,
+ )
+ if err != nil {
+ fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err)
+ return 2
+ }
+ if flagMigrationPair {
+ migrationBase, readErr := readContract(migrationBaseSchemaPath)
+ if readErr != nil {
+ fmt.Fprintf(stderr, "read migration merge-base Schema contract: %v\n", readErr)
+ return 2
+ }
+ baseline, err = normalizeSchemaCommandMigrationLineage(
+ baseline,
+ migrationBase,
+ current,
+ flagMigrations,
+ commandMigrations,
+ )
+ } else {
+ baseline, err = normalizeSchemaCommandMigrations(baseline, current, commandMigrations)
+ }
+ if err != nil {
+ fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err)
+ return 2
+ }
+ } else if flagMigrationPair {
+ baseline, err = normalizeSchemaFlagMigrations(baseline, current, flagMigrations)
if err != nil {
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
return 2
@@ -251,6 +312,49 @@ func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigration
return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data))
}
+func authorizeSchemaCommandMigrations(
+ approvedManifestPath string,
+ candidateManifestPath string,
+ currentSnapshotPath string,
+ baseSnapshotPath string,
+ stableSnapshotPath string,
+) ([]interfacesnapshot.CommandMigration, error) {
+ approved, err := readCommandMigrationManifestFile(approvedManifestPath)
+ if err != nil {
+ return nil, fmt.Errorf("read approved command migrations: %w", err)
+ }
+ candidate, err := readCommandMigrationManifestFile(candidateManifestPath)
+ if err != nil {
+ return nil, fmt.Errorf("read candidate command migrations: %w", err)
+ }
+ current, err := readInterfaceSnapshotFile(currentSnapshotPath)
+ if err != nil {
+ return nil, fmt.Errorf("read migration current snapshot: %w", err)
+ }
+ base, err := readInterfaceSnapshotFile(baseSnapshotPath)
+ if err != nil {
+ return nil, fmt.Errorf("read migration base snapshot: %w", err)
+ }
+ stable, err := readInterfaceSnapshotFile(stableSnapshotPath)
+ if err != nil {
+ return nil, fmt.Errorf("read migration stable snapshot: %w", err)
+ }
+ return interfacesnapshot.AuthorizeCommandMigrations(
+ current,
+ map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable},
+ approved,
+ candidate,
+ )
+}
+
+func readCommandMigrationManifestFile(path string) (interfacesnapshot.CommandMigrationManifest, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return interfacesnapshot.CommandMigrationManifest{}, err
+ }
+ return interfacesnapshot.ReadCommandMigrationManifest(bytes.NewReader(data))
+}
+
func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -1157,9 +1261,9 @@ type schemaToolRef struct {
}
// normalizeSchemaFlagMigrations projects only an already-authorized CLI flag
-// rename onto a cloned historical Schema contract. The ordinary compatibility
-// checker still makes the final decision; this adapter never drops findings by
-// matching their rendered text.
+// rename or requiredness change onto a cloned historical Schema contract. The
+// ordinary compatibility checker still makes the final decision; this adapter
+// never drops findings by matching their rendered text.
func normalizeSchemaFlagMigrations(
baseline schemaContract,
current schemaContract,
@@ -1169,6 +1273,14 @@ func normalizeSchemaFlagMigrations(
renamesByTool := map[schemaToolRef]map[string]string{}
for _, migration := range migrations {
+ if migration.EffectiveKind() == interfacesnapshot.FlagMigrationRequirednessChange {
+ var err error
+ normalized, err = normalizeSchemaFlagRequirednessMigration(normalized, baseline, current, migration)
+ if err != nil {
+ return schemaContract{}, err
+ }
+ continue
+ }
primaryPath := strings.TrimPrefix(migration.Command, "dws ")
matches := schemaToolsByPrimaryPath(baseline, primaryPath)
if len(matches) == 0 {
@@ -1290,6 +1402,877 @@ func normalizeSchemaFlagMigrations(
return normalized, nil
}
+func normalizeSchemaFlagRequirednessMigration(
+ normalized schemaContract,
+ baseline schemaContract,
+ current schemaContract,
+ migration interfacesnapshot.FlagMigration,
+) (schemaContract, error) {
+ primaryPath := strings.TrimPrefix(migration.Command, "dws ")
+ matches := schemaToolsByPrimaryPath(baseline, primaryPath)
+ if len(matches) == 0 {
+ // A reviewed CLI-only command has no Schema compatibility surface.
+ return normalized, nil
+ }
+ if len(matches) != 1 {
+ return schemaContract{}, fmt.Errorf(
+ "approved flag requiredness migration %q matches %d historical Schema tools",
+ migration.Command,
+ len(matches),
+ )
+ }
+
+ ref := matches[0]
+ oldTool := baseline.Products[ref.productID].Tools[ref.toolID]
+ oldParameter, existed := oldTool.Parameters[migration.Flag.Name]
+ if !existed {
+ // CLI requiredness is not authority to create or mutate an unrelated
+ // historical Schema parameter.
+ return normalized, nil
+ }
+ newProduct, productExists := current.Products[ref.productID]
+ newTool, toolExists := newProduct.Tools[ref.toolID]
+ if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath {
+ // Preserve the baseline so the ordinary checker reports the missing tool
+ // or primary path change.
+ return normalized, nil
+ }
+ newParameter, exists := newTool.Parameters[migration.Flag.Name]
+ if !exists {
+ // Preserve the baseline so the ordinary checker reports parameter loss.
+ return normalized, nil
+ }
+ if !newParameter.Required || !newParameter.CLIRequired {
+ return schemaContract{}, fmt.Errorf(
+ "approved flag requiredness migration %q Schema parameter %q must be required and cli_required",
+ migration.Command,
+ migration.Flag.Name,
+ )
+ }
+
+ normalizedProduct := normalized.Products[ref.productID]
+ normalizedTool := normalizedProduct.Tools[ref.toolID]
+ normalizedParameter := oldParameter
+ normalizedParameter.Required = newParameter.Required
+ normalizedParameter.CLIRequired = newParameter.CLIRequired
+ normalizedTool.Parameters[migration.Flag.Name] = normalizedParameter
+ normalizedProduct.Tools[ref.toolID] = normalizedTool
+ normalized.Products[ref.productID] = normalizedProduct
+ return normalized, nil
+}
+
+// normalizeSchemaCommandMigrationLineage composes two independently reviewed
+// migration receipts without inventing a second alias authority. A consumed
+// flag migration may supply the historical predecessor of a command_move
+// parameter, but only after the merge-base Schema or a retained consumed
+// command receipt proves the corresponding next hop.
+func normalizeSchemaCommandMigrationLineage(
+ historical schemaContract,
+ mergeBase schemaContract,
+ current schemaContract,
+ flagMigrations []interfacesnapshot.FlagMigration,
+ commandMigrations []interfacesnapshot.CommandMigration,
+) (schemaContract, error) {
+ normalized, err := normalizeSchemaFlagMigrations(historical, current, flagMigrations)
+ if err != nil {
+ return schemaContract{}, fmt.Errorf("normalize ordinary flag migrations: %w", err)
+ }
+ staged, err := stageSchemaCommandMigrationPredecessors(
+ normalized,
+ mergeBase,
+ current,
+ flagMigrations,
+ commandMigrations,
+ )
+ if err != nil {
+ return schemaContract{}, err
+ }
+ return normalizeSchemaCommandMigrations(staged, current, commandMigrations)
+}
+
+// stageSchemaCommandMigrationPredecessors replays only the name-changing edge
+// recorded by a base-owned consumed flag migration. It leaves interface,
+// safety, dry-run, and positional facts untouched so the ordinary checker
+// remains authoritative for every non-name change.
+func stageSchemaCommandMigrationPredecessors(
+ historical schemaContract,
+ mergeBase schemaContract,
+ current schemaContract,
+ flagMigrations []interfacesnapshot.FlagMigration,
+ commandMigrations []interfacesnapshot.CommandMigration,
+) (schemaContract, error) {
+ staged := cloneContract(historical)
+ moveBySource := map[schemaToolRef]string{}
+ for _, migration := range commandMigrations {
+ if migration.Kind != interfacesnapshot.CommandMigrationMove {
+ continue
+ }
+ ref := schemaToolRef{productID: migration.Schema.ProductID, toolID: migration.Schema.SourceToolID}
+ if previous, exists := moveBySource[ref]; exists {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migrations fork Schema source tool %q between %q and %q",
+ migration.Schema.SourceToolID,
+ previous,
+ migration.Legacy.Command,
+ )
+ }
+ moveBySource[ref] = migration.Legacy.Command
+ }
+
+ for _, migration := range commandMigrations {
+ if migration.Kind != interfacesnapshot.CommandMigrationMove {
+ continue
+ }
+ oldProduct, productExists := historical.Products[migration.Schema.ProductID]
+ oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID]
+ if !productExists || !toolExists {
+ continue
+ }
+ legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ")
+ replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ")
+ if oldTool.PrimaryCLIPath == replacementPath {
+ continue
+ }
+ if oldTool.PrimaryCLIPath != legacyPath {
+ // Preserve the existing command normalizer's deterministic path error.
+ continue
+ }
+
+ baseProduct, baseProductExists := mergeBase.Products[migration.Schema.ProductID]
+ baseTool, baseToolExists := baseProduct.Tools[migration.Schema.SourceToolID]
+ currentProduct, currentProductExists := current.Products[migration.Schema.ProductID]
+ currentTool, currentToolExists := currentProduct.Tools[migration.Schema.SourceToolID]
+ firstHopRenames := map[string]string{}
+ composedRenames := map[string]string{}
+ lineageApplied := false
+
+ for _, parameter := range migration.Schema.Parameters {
+ if _, direct := oldTool.Parameters[parameter.From]; direct {
+ for _, flagMigration := range flagMigrations {
+ if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename {
+ continue
+ }
+ if flagMigration.Command != migration.Legacy.Command ||
+ flagMigration.Canonical.Name != parameter.From {
+ continue
+ }
+ if _, legacyAlsoPublished := oldTool.Parameters[flagMigration.Legacy.Name]; legacyAlsoPublished {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q historical Schema tool publishes both predecessor %q and intermediate %q",
+ migration.Legacy.Command,
+ flagMigration.Legacy.Name,
+ parameter.From,
+ )
+ }
+ }
+ composedRenames[parameter.From] = parameter.To
+ continue
+ }
+
+ predecessors := make([]interfacesnapshot.FlagMigration, 0, 1)
+ for _, flagMigration := range flagMigrations {
+ if flagMigration.EffectiveKind() != interfacesnapshot.FlagMigrationRename {
+ continue
+ }
+ if flagMigration.Command != migration.Legacy.Command ||
+ flagMigration.Canonical.Name != parameter.From {
+ continue
+ }
+ if flagMigration.State != interfacesnapshot.FlagMigrationConsumed {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q Schema predecessor %q -> %q requires a consumed flag migration receipt",
+ migration.Legacy.Command,
+ flagMigration.Legacy.Name,
+ parameter.From,
+ )
+ }
+ if _, published := oldTool.Parameters[flagMigration.Legacy.Name]; published {
+ predecessors = append(predecessors, flagMigration)
+ }
+ }
+ if len(predecessors) == 0 {
+ continue
+ }
+
+ lineageApplied = true
+ if !baseProductExists || !baseToolExists {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q merge-base Schema lacks source tool %q",
+ migration.Legacy.Command,
+ migration.Schema.SourceToolID,
+ )
+ }
+ var stagedParameter parameterSchema
+ switch migration.State {
+ case interfacesnapshot.CommandMigrationPending:
+ if baseTool.PrimaryCLIPath != legacyPath {
+ return schemaContract{}, fmt.Errorf(
+ "pending command migration %q merge-base Schema source tool has primary_cli_path %q",
+ migration.Legacy.Command,
+ baseTool.PrimaryCLIPath,
+ )
+ }
+ intermediate, exists := baseTool.Parameters[parameter.From]
+ if !exists {
+ return schemaContract{}, fmt.Errorf(
+ "pending command migration %q merge-base Schema lacks intermediate parameter %q",
+ migration.Legacy.Command,
+ parameter.From,
+ )
+ }
+ if _, exists := baseTool.Parameters[parameter.To]; exists {
+ return schemaContract{}, fmt.Errorf(
+ "pending command migration %q merge-base Schema already publishes final parameter %q",
+ migration.Legacy.Command,
+ parameter.To,
+ )
+ }
+ stagedParameter = intermediate
+ case interfacesnapshot.CommandMigrationConsumed:
+ if baseTool.PrimaryCLIPath != replacementPath {
+ return schemaContract{}, fmt.Errorf(
+ "consumed command migration %q merge-base Schema source tool has primary_cli_path %q",
+ migration.Legacy.Command,
+ baseTool.PrimaryCLIPath,
+ )
+ }
+ finalParameter, exists := baseTool.Parameters[parameter.To]
+ if !exists {
+ return schemaContract{}, fmt.Errorf(
+ "consumed command migration %q merge-base Schema lacks final parameter %q",
+ migration.Legacy.Command,
+ parameter.To,
+ )
+ }
+ if _, exists := baseTool.Parameters[parameter.From]; exists {
+ return schemaContract{}, fmt.Errorf(
+ "consumed command migration %q merge-base Schema still publishes intermediate parameter %q",
+ migration.Legacy.Command,
+ parameter.From,
+ )
+ }
+ stagedParameter = oldTool.Parameters[predecessors[0].Legacy.Name]
+ for _, predecessor := range predecessors {
+ oldParameter := oldTool.Parameters[predecessor.Legacy.Name]
+ composite := interfacesnapshot.CommandParameterMigration{From: predecessor.Legacy.Name, To: parameter.To}
+ if err := validateEquivalentCommandSchemaParameter(migration, composite, oldParameter, finalParameter); err != nil {
+ return schemaContract{}, err
+ }
+ }
+ default:
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q has unsupported lineage state %q",
+ migration.Legacy.Command,
+ migration.State,
+ )
+ }
+
+ stagedProduct := staged.Products[migration.Schema.ProductID]
+ stagedTool := stagedProduct.Tools[migration.Schema.SourceToolID]
+ for _, predecessor := range predecessors {
+ if predecessor.Legacy.Name == parameter.To {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q forms a Schema parameter lineage cycle through %q",
+ migration.Legacy.Command,
+ parameter.To,
+ )
+ }
+ if existing, claimed := firstHopRenames[predecessor.Legacy.Name]; claimed && existing != parameter.From {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q forks Schema predecessor %q to both %q and %q",
+ migration.Legacy.Command,
+ predecessor.Legacy.Name,
+ existing,
+ parameter.From,
+ )
+ }
+ oldParameter := oldTool.Parameters[predecessor.Legacy.Name]
+ if migration.State == interfacesnapshot.CommandMigrationPending {
+ if err := validateRenamedSchemaParameter(predecessor, oldParameter, stagedParameter); err != nil {
+ return schemaContract{}, err
+ }
+ }
+ if _, exists := baseTool.Parameters[predecessor.Legacy.Name]; exists {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q merge-base Schema still publishes predecessor parameter %q",
+ migration.Legacy.Command,
+ predecessor.Legacy.Name,
+ )
+ }
+ if currentProductExists && currentToolExists {
+ if _, exists := currentTool.Parameters[predecessor.Legacy.Name]; exists {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q current Schema still publishes predecessor parameter %q",
+ migration.Legacy.Command,
+ predecessor.Legacy.Name,
+ )
+ }
+ }
+ delete(stagedTool.Parameters, predecessor.Legacy.Name)
+ firstHopRenames[predecessor.Legacy.Name] = parameter.From
+ composedRenames[predecessor.Legacy.Name] = parameter.To
+ }
+ stagedTool.Parameters[parameter.From] = stagedParameter
+ stagedProduct.Tools[migration.Schema.SourceToolID] = stagedTool
+ staged.Products[migration.Schema.ProductID] = stagedProduct
+ }
+
+ if !lineageApplied {
+ continue
+ }
+ matches := schemaToolsByPrimaryPath(historical, legacyPath)
+ wantRef := schemaToolRef{productID: migration.Schema.ProductID, toolID: migration.Schema.SourceToolID}
+ if len(matches) != 1 || matches[0] != wantRef {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q predecessor lineage requires one exact historical Schema tool, got %#v",
+ migration.Legacy.Command,
+ matches,
+ )
+ }
+ if currentProductExists && currentToolExists {
+ if source, found := migratedConstraintSourceParameter(currentTool.Constraints, firstHopRenames); found {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q current Schema constraints still reference predecessor parameter %q",
+ migration.Legacy.Command,
+ source,
+ )
+ }
+ }
+ for _, finalTarget := range composedRenames {
+ if _, cycle := firstHopRenames[finalTarget]; cycle {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q forms a Schema parameter lineage cycle through %q",
+ migration.Legacy.Command,
+ finalTarget,
+ )
+ }
+ }
+
+ firstHopConstraints, firstHopOK := canonicalizeMigratedConstraints(oldTool.Constraints, firstHopRenames)
+ if !firstHopOK {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q historical Schema constraints are not canonicalizable",
+ migration.Legacy.Command,
+ )
+ }
+ baseConstraints, baseOK := canonicalizeMigratedConstraints(baseTool.Constraints, nil)
+ if !baseOK {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q merge-base Schema constraints are not canonicalizable",
+ migration.Legacy.Command,
+ )
+ }
+ stagedProduct := staged.Products[migration.Schema.ProductID]
+ stagedTool := stagedProduct.Tools[migration.Schema.SourceToolID]
+ switch migration.State {
+ case interfacesnapshot.CommandMigrationPending:
+ if firstHopConstraints != baseConstraints {
+ return schemaContract{}, fmt.Errorf(
+ "pending command migration %q predecessor lineage changed merge-base Schema constraints",
+ migration.Legacy.Command,
+ )
+ }
+ stagedTool.Constraints = baseTool.Constraints
+ case interfacesnapshot.CommandMigrationConsumed:
+ composedConstraints, composedOK := canonicalizeMigratedConstraints(oldTool.Constraints, composedRenames)
+ if !composedOK || composedConstraints != baseConstraints {
+ return schemaContract{}, fmt.Errorf(
+ "consumed command migration %q predecessor lineage changed merge-base Schema constraints",
+ migration.Legacy.Command,
+ )
+ }
+ stagedTool.Constraints = firstHopConstraints
+ }
+ stagedProduct.Tools[migration.Schema.SourceToolID] = stagedTool
+ staged.Products[migration.Schema.ProductID] = stagedProduct
+ }
+ return staged, nil
+}
+
+// normalizeSchemaCommandMigrations projects only the Schema consequences that
+// are coupled to an already-authorized CLI command migration. It rewrites a
+// cloned historical contract; the ordinary checker still rejects every field
+// not proven equivalent here.
+func normalizeSchemaCommandMigrations(
+ baseline schemaContract,
+ current schemaContract,
+ migrations []interfacesnapshot.CommandMigration,
+) (schemaContract, error) {
+ normalized := cloneContract(baseline)
+ for _, migration := range migrations {
+ oldProduct, productExists := baseline.Products[migration.Schema.ProductID]
+ oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID]
+ if !productExists || !toolExists {
+ // The historical baseline predates this Schema tool, so it has no
+ // compatibility surface for this migration.
+ continue
+ }
+ newProduct, productExists := current.Products[migration.Schema.ProductID]
+ newSource, sourceExists := newProduct.Tools[migration.Schema.SourceToolID]
+ if !productExists || !sourceExists {
+ // Preserve the baseline so the ordinary checker reports the removal.
+ continue
+ }
+ legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ")
+ replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ")
+ if oldTool.PrimaryCLIPath == replacementPath {
+ // A consumed receipt can still be needed for the stable baseline after
+ // main has already reached the after state.
+ continue
+ }
+ if oldTool.PrimaryCLIPath != legacyPath {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %s historical Schema tool %q has primary_cli_path %q",
+ migration.Kind,
+ migration.Schema.SourceToolID,
+ oldTool.PrimaryCLIPath,
+ )
+ }
+
+ normalizedProduct := normalized.Products[migration.Schema.ProductID]
+ normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID]
+ switch migration.Kind {
+ case interfacesnapshot.CommandMigrationMove:
+ if newSource.PrimaryCLIPath != replacementPath {
+ continue
+ }
+ renames := make(map[string]string, len(migration.Schema.Parameters))
+ renameTargets := make(map[string]struct{}, len(migration.Schema.Parameters))
+ for _, parameter := range migration.Schema.Parameters {
+ oldParameter, existed := oldTool.Parameters[parameter.From]
+ if !existed {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q historical Schema tool lacks parameter %q",
+ migration.Legacy.Command,
+ parameter.From,
+ )
+ }
+ if _, exists := oldTool.Parameters[parameter.To]; exists {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q Schema parameter target %q already exists in historical Schema tool %q",
+ migration.Legacy.Command,
+ parameter.To,
+ migration.Schema.SourceToolID,
+ )
+ }
+ if _, exists := newSource.Parameters[parameter.From]; exists {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q still publishes legacy Schema parameter %q",
+ migration.Legacy.Command,
+ parameter.From,
+ )
+ }
+ newParameter, exists := newSource.Parameters[parameter.To]
+ if !exists {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q does not publish replacement Schema parameter %q",
+ migration.Replacement.Command,
+ parameter.To,
+ )
+ }
+ if err := validateEquivalentCommandSchemaParameter(migration, parameter, oldParameter, newParameter); err != nil {
+ return schemaContract{}, err
+ }
+ delete(normalizedTool.Parameters, parameter.From)
+ normalizedTool.Parameters[parameter.To] = newParameter
+ renames[parameter.From] = parameter.To
+ renameTargets[parameter.To] = struct{}{}
+ }
+ parameterNames := make([]string, 0, len(newSource.Parameters))
+ for name := range newSource.Parameters {
+ parameterNames = append(parameterNames, name)
+ }
+ sort.Strings(parameterNames)
+ for _, name := range parameterNames {
+ parameter := newSource.Parameters[name]
+ if _, existed := oldTool.Parameters[name]; existed {
+ continue
+ }
+ if _, approvedRename := renameTargets[name]; approvedRename {
+ continue
+ }
+ if parameter.Required || parameter.CLIRequired || parameter.RequiredWhen != "" {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q replacement Schema tool %q introduced unregistered required Schema parameter %q",
+ migration.Legacy.Command,
+ migration.Schema.SourceToolID,
+ name,
+ )
+ }
+ }
+ oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
+ if !oldOK {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q historical Schema constraints are not canonicalizable",
+ migration.Legacy.Command,
+ )
+ }
+ newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil)
+ if !newOK {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q current Schema constraints are not canonicalizable",
+ migration.Legacy.Command,
+ )
+ }
+ if source, found := migratedConstraintSourceParameter(newSource.Constraints, renames); found {
+ return schemaContract{}, fmt.Errorf(
+ "approved command migration %q current constraints still reference legacy Schema constraint parameter %q",
+ migration.Legacy.Command,
+ source,
+ )
+ }
+ normalizedTool.Constraints = oldConstraints
+ if oldConstraints == newConstraints {
+ normalizedTool.Constraints = newSource.Constraints
+ }
+ normalizedTool.PrimaryCLIPath = replacementPath
+
+ case interfacesnapshot.CommandMigrationFlagExtraction:
+ if newSource.PrimaryCLIPath != legacyPath {
+ continue
+ }
+ replacement, exists := newProduct.Tools[migration.Schema.ReplacementToolID]
+ if !exists || replacement.PrimaryCLIPath != replacementPath {
+ return schemaContract{}, fmt.Errorf(
+ "approved flag extraction %q lacks replacement Schema tool %q at %q",
+ migration.Legacy.Command,
+ migration.Schema.ReplacementToolID,
+ replacementPath,
+ )
+ }
+ if oldTool.InterfaceMode != replacement.InterfaceMode ||
+ oldTool.InterfaceRef != replacement.InterfaceRef ||
+ oldTool.Availability != replacement.Availability ||
+ oldTool.Effect != replacement.Effect ||
+ oldTool.Risk != replacement.Risk ||
+ oldTool.Confirmation != replacement.Confirmation ||
+ oldTool.Idempotency != replacement.Idempotency {
+ return schemaContract{}, fmt.Errorf(
+ "approved flag extraction %q replacement Schema tool changed interface or safety identity",
+ migration.Legacy.Command,
+ )
+ }
+ if oldTool.DryRun != "" && oldTool.DryRun != replacement.DryRun {
+ return schemaContract{}, fmt.Errorf(
+ "approved flag extraction %q replacement Schema tool changed or removed dry_run",
+ migration.Legacy.Command,
+ )
+ }
+ var err error
+ normalizedTool, err = normalizeFlagExtractionSchemaMigration(
+ migration,
+ oldTool,
+ newSource,
+ replacement,
+ normalizedTool,
+ )
+ if err != nil {
+ return schemaContract{}, err
+ }
+ }
+ normalizedProduct.Tools[migration.Schema.SourceToolID] = normalizedTool
+ normalized.Products[migration.Schema.ProductID] = normalizedProduct
+ }
+ return normalized, nil
+}
+
+func normalizeFlagExtractionSchemaMigration(
+ migration interfacesnapshot.CommandMigration,
+ oldTool toolSchema,
+ newSource toolSchema,
+ replacement toolSchema,
+ normalizedSource toolSchema,
+) (toolSchema, error) {
+ mappingsBySource := make(map[string]interfacesnapshot.CommandParameterMigration, len(migration.Schema.Parameters))
+ for _, mapping := range migration.Schema.Parameters {
+ if _, duplicate := mappingsBySource[mapping.From]; duplicate {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q maps historical Schema parameter %q more than once",
+ migration.Legacy.Command,
+ mapping.From,
+ )
+ }
+ if _, exists := oldTool.Parameters[mapping.From]; !exists {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q historical Schema tool lacks parameter %q",
+ migration.Legacy.Command,
+ mapping.From,
+ )
+ }
+ mappingsBySource[mapping.From] = mapping
+ }
+
+ historicalNames := make([]string, 0, len(oldTool.Parameters))
+ for name := range oldTool.Parameters {
+ historicalNames = append(historicalNames, name)
+ }
+ sort.Strings(historicalNames)
+ for _, name := range historicalNames {
+ if _, mapped := mappingsBySource[name]; !mapped {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q does not map historical Schema parameter %q",
+ migration.Legacy.Command,
+ name,
+ )
+ }
+ }
+
+ legacyName := migration.LegacyFlag.Name
+ legacyMapping, mapped := mappingsBySource[legacyName]
+ if !mapped || legacyMapping.To != "" || legacyMapping.ReplacementConstant == nil {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q legacy Schema parameter %q must map to one replacement constant",
+ migration.Legacy.Command,
+ legacyName,
+ )
+ }
+ legacyParameter := oldTool.Parameters[legacyName]
+ if err := validateFlagExtractionBooleanConstant(migration, legacyParameter, legacyMapping.ReplacementConstant); err != nil {
+ return toolSchema{}, err
+ }
+ constantProperty := legacyMapping.ReplacementConstant.Property
+ if _, exists := newSource.Parameters[legacyName]; exists {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q still publishes extracted Schema parameter %q",
+ migration.Legacy.Command,
+ legacyName,
+ )
+ }
+ sourceNames := make([]string, 0, len(newSource.Parameters))
+ for name := range newSource.Parameters {
+ sourceNames = append(sourceNames, name)
+ }
+ sort.Strings(sourceNames)
+ for _, name := range sourceNames {
+ if newSource.Parameters[name].Property == constantProperty {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q source Schema tool still publishes replacement constant property %q through parameter %q on %q",
+ migration.Legacy.Command,
+ constantProperty,
+ name,
+ migration.Schema.SourceToolID,
+ )
+ }
+ }
+
+ replacementNames := make([]string, 0, len(replacement.Parameters))
+ for name := range replacement.Parameters {
+ replacementNames = append(replacementNames, name)
+ }
+ sort.Strings(replacementNames)
+ for _, name := range replacementNames {
+ if replacement.Parameters[name].Property == constantProperty {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q replacement Schema tool %q still publishes replacement constant property %q through parameter %q",
+ migration.Legacy.Command,
+ migration.Schema.ReplacementToolID,
+ constantProperty,
+ name,
+ )
+ }
+ }
+
+ replacementTargets := make(map[string]string, len(historicalNames)-1)
+ renames := make(map[string]string, len(historicalNames)-1)
+ for _, sourceName := range historicalNames {
+ if sourceName == legacyName {
+ continue
+ }
+ mapping := mappingsBySource[sourceName]
+ if mapping.To == "" || mapping.ReplacementConstant != nil {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q shared Schema parameter %q must map to one replacement parameter",
+ migration.Legacy.Command,
+ sourceName,
+ )
+ }
+ if previous, duplicate := replacementTargets[mapping.To]; duplicate {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q replacement Schema parameter %q is mapped from both %q and %q",
+ migration.Legacy.Command,
+ mapping.To,
+ previous,
+ sourceName,
+ )
+ }
+ replacementParameter, exists := replacement.Parameters[mapping.To]
+ if !exists {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q replacement Schema tool %q does not publish mapped Schema parameter %q",
+ migration.Legacy.Command,
+ migration.Schema.ReplacementToolID,
+ mapping.To,
+ )
+ }
+ if err := validateEquivalentCommandSchemaParameter(migration, mapping, oldTool.Parameters[sourceName], replacementParameter); err != nil {
+ return toolSchema{}, err
+ }
+ replacementTargets[mapping.To] = sourceName
+ renames[sourceName] = mapping.To
+ }
+ for _, name := range replacementNames {
+ if _, mapped := replacementTargets[name]; !mapped {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q replacement Schema tool %q publishes unmapped Schema parameter %q",
+ migration.Legacy.Command,
+ migration.Schema.ReplacementToolID,
+ name,
+ )
+ }
+ }
+
+ oldConstraints, oldConstraintsOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
+ replacementConstraints, replacementConstraintsOK := canonicalizeMigratedConstraints(replacement.Constraints, nil)
+ if !oldConstraintsOK || !replacementConstraintsOK || oldConstraints != replacementConstraints {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q replacement Schema tool changed constraints",
+ migration.Legacy.Command,
+ )
+ }
+ if !equivalentMigratedPositionals(oldTool.Positionals, replacement.Positionals, renames) {
+ return toolSchema{}, fmt.Errorf(
+ "approved flag extraction %q replacement Schema tool changed positionals",
+ migration.Legacy.Command,
+ )
+ }
+
+ delete(normalizedSource.Parameters, legacyName)
+ return normalizedSource, nil
+}
+
+func validateFlagExtractionBooleanConstant(
+ migration interfacesnapshot.CommandMigration,
+ parameter parameterSchema,
+ constant *interfacesnapshot.CommandReplacementConstant,
+) error {
+ if strings.TrimSpace(constant.Property) == "" || parameter.Property != constant.Property {
+ return fmt.Errorf(
+ "approved flag extraction %q replacement constant property %q does not match historical Schema property %q",
+ migration.Legacy.Command,
+ constant.Property,
+ parameter.Property,
+ )
+ }
+ if !constant.Value {
+ return fmt.Errorf(
+ "approved flag extraction %q replacement constant for Schema property %q must be constant true",
+ migration.Legacy.Command,
+ constant.Property,
+ )
+ }
+ if parameter.Type != `"boolean"` {
+ return fmt.Errorf(
+ "approved flag extraction %q legacy Schema parameter %q must have boolean type",
+ migration.Legacy.Command,
+ migration.LegacyFlag.Name,
+ )
+ }
+ if parameter.Required || parameter.CLIRequired {
+ return fmt.Errorf(
+ "approved flag extraction %q legacy Schema parameter %q must remain optional",
+ migration.Legacy.Command,
+ migration.LegacyFlag.Name,
+ )
+ }
+ if parameter.RequiredWhen != "" {
+ return fmt.Errorf(
+ "approved flag extraction %q legacy Schema parameter %q must not declare required_when",
+ migration.Legacy.Command,
+ migration.LegacyFlag.Name,
+ )
+ }
+ if parameter.Default != "" && parameter.Default != "false" {
+ return fmt.Errorf(
+ "approved flag extraction %q legacy Schema parameter %q default must be absent or false",
+ migration.Legacy.Command,
+ migration.LegacyFlag.Name,
+ )
+ }
+ if parameter.InterfaceDefault != "" && parameter.InterfaceDefault != "false" {
+ return fmt.Errorf(
+ "approved flag extraction %q legacy Schema parameter %q interface_default must be absent or false",
+ migration.Legacy.Command,
+ migration.LegacyFlag.Name,
+ )
+ }
+ if parameter.InterfaceType != "" && parameter.InterfaceType != "boolean" {
+ return fmt.Errorf(
+ "approved flag extraction %q legacy Schema parameter %q interface_type must be empty or boolean",
+ migration.Legacy.Command,
+ migration.LegacyFlag.Name,
+ )
+ }
+ if parameter.Format != "" {
+ return fmt.Errorf(
+ "approved flag extraction %q legacy Schema parameter %q format must be empty",
+ migration.Legacy.Command,
+ migration.LegacyFlag.Name,
+ )
+ }
+ if len(parameter.Enum) > 0 {
+ allowsTrue := false
+ for _, value := range parameter.Enum {
+ if value == "true" {
+ allowsTrue = true
+ break
+ }
+ }
+ if !allowsTrue {
+ return fmt.Errorf(
+ "approved flag extraction %q legacy Schema parameter %q enum must allow true",
+ migration.Legacy.Command,
+ migration.LegacyFlag.Name,
+ )
+ }
+ }
+ return nil
+}
+
+func equivalentMigratedPositionals(
+ historical []positionalSchema,
+ replacement []positionalSchema,
+ renames map[string]string,
+) bool {
+ if len(historical) != len(replacement) {
+ return false
+ }
+ for index, positional := range historical {
+ if renamed := renames[positional.Name]; renamed != "" {
+ positional.Name = renamed
+ }
+ if positional != replacement[index] {
+ return false
+ }
+ }
+ return true
+}
+
+func validateEquivalentCommandSchemaParameter(
+ migration interfacesnapshot.CommandMigration,
+ parameter interfacesnapshot.CommandParameterMigration,
+ oldParameter parameterSchema,
+ newParameter parameterSchema,
+) error {
+ if oldParameter.Type != newParameter.Type ||
+ oldParameter.Property != newParameter.Property ||
+ oldParameter.InterfaceType != newParameter.InterfaceType ||
+ oldParameter.Required != newParameter.Required ||
+ oldParameter.CLIRequired != newParameter.CLIRequired ||
+ oldParameter.RequiredWhen != newParameter.RequiredWhen ||
+ oldParameter.Default != newParameter.Default ||
+ oldParameter.InterfaceDefault != newParameter.InterfaceDefault ||
+ oldParameter.Format != newParameter.Format ||
+ !equalStringSlices(oldParameter.Enum, newParameter.Enum) {
+ return fmt.Errorf(
+ "approved command migration %q Schema parameter %q -> %q changed a non-name field",
+ migration.Legacy.Command,
+ parameter.From,
+ parameter.To,
+ )
+ }
+ return nil
+}
+
func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef {
var matches []schemaToolRef
for productID, product := range contract.Products {
@@ -1395,6 +2378,23 @@ func canonicalizeMigratedConstraints(raw string, renames map[string]string) (str
return string(encoded), err == nil
}
+func migratedConstraintSourceParameter(raw string, renames map[string]string) (string, bool) {
+ groups, ok := parseMigrationConstraintsStrict(raw)
+ if !ok {
+ return "", false
+ }
+ for _, kind := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
+ for _, group := range groups[kind] {
+ for _, member := range group {
+ if _, renamed := renames[member]; renamed {
+ return member, true
+ }
+ }
+ }
+ }
+ return "", false
+}
+
func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go
index f33f09c5..9e6c7794 100644
--- a/scripts/policy/schema-compat/main_test.go
+++ b/scripts/policy/schema-compat/main_test.go
@@ -1229,6 +1229,144 @@ func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testin
}
}
+func TestCrossPlatformCoverageSchemaFlagRequirednessMigration(t *testing.T) {
+ baseline := schemaContract{
+ Version: schemaContractVersion,
+ Products: map[string]productSchema{
+ "report": {
+ Tools: map[string]toolSchema{
+ "report.entry_submit": {
+ PrimaryCLIPath: "report entry submit",
+ Parameters: map[string]parameterSchema{
+ "to-user-ids": {Type: `"string"`, Property: "toUserIds", InterfaceType: "string"},
+ },
+ },
+ },
+ },
+ },
+ }
+ current := cloneContract(baseline)
+ product := current.Products["report"]
+ tool := product.Tools["report.entry_submit"]
+ parameter := tool.Parameters["to-user-ids"]
+ parameter.Required = true
+ parameter.CLIRequired = true
+ tool.Parameters["to-user-ids"] = parameter
+ product.Tools["report.entry_submit"] = tool
+ current.Products["report"] = product
+
+ migration := interfacesnapshot.FlagMigration{
+ Kind: interfacesnapshot.FlagMigrationRequirednessChange,
+ Command: "dws report entry submit",
+ Flag: &interfacesnapshot.FlagMigrationSide{
+ Name: "to-user-ids",
+ Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"},
+ After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
+ },
+ State: interfacesnapshot.FlagMigrationConsumed,
+ Reason: "Reject report submissions that have no visible recipient.",
+ }
+ normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{migration})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failures := checkCompatibility(normalized, current); len(failures) != 0 {
+ t.Fatalf("exact requiredness migration should pass after normalization: %v", failures)
+ }
+
+ t.Run("non-requiredness drift remains blocked", func(t *testing.T) {
+ drifted := cloneContract(current)
+ product := drifted.Products["report"]
+ tool := product.Tools["report.entry_submit"]
+ parameter := tool.Parameters["to-user-ids"]
+ parameter.Type = `"array"`
+ tool.Parameters["to-user-ids"] = parameter
+ product.Tools["report.entry_submit"] = tool
+ drifted.Products["report"] = product
+ normalized, err := normalizeSchemaFlagMigrations(baseline, drifted, []interfacesnapshot.FlagMigration{migration})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failures := strings.Join(checkCompatibility(normalized, drifted), "\n"); !strings.Contains(failures, "changed type") {
+ t.Fatalf("requiredness migration hid type drift: %s", failures)
+ }
+ })
+
+ t.Run("partial Schema projection fails closed", func(t *testing.T) {
+ partial := cloneContract(current)
+ product := partial.Products["report"]
+ tool := product.Tools["report.entry_submit"]
+ parameter := tool.Parameters["to-user-ids"]
+ parameter.CLIRequired = false
+ tool.Parameters["to-user-ids"] = parameter
+ product.Tools["report.entry_submit"] = tool
+ partial.Products["report"] = product
+ if _, err := normalizeSchemaFlagMigrations(baseline, partial, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "required and cli_required") {
+ t.Fatalf("partial requiredness projection error = %v", err)
+ }
+ })
+
+ t.Run("CLI-only migration does not manufacture Schema", func(t *testing.T) {
+ cliOnly := migration
+ cliOnly.Command = "dws report cli-only"
+ normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{cliOnly})
+ if err != nil || !reflect.DeepEqual(normalized, baseline) {
+ t.Fatalf("CLI-only requiredness migration = %#v, %v", normalized, err)
+ }
+ })
+
+ t.Run("duplicate historical path fails closed", func(t *testing.T) {
+ duplicate := cloneContract(baseline)
+ product := duplicate.Products["report"]
+ product.Tools["report.duplicate"] = product.Tools["report.entry_submit"]
+ duplicate.Products["report"] = product
+ if _, err := normalizeSchemaFlagMigrations(duplicate, current, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "matches 2") {
+ t.Fatalf("duplicate requiredness Schema path error = %v", err)
+ }
+ })
+
+ t.Run("missing historical parameter is not authority", func(t *testing.T) {
+ missing := cloneContract(baseline)
+ product := missing.Products["report"]
+ tool := product.Tools["report.entry_submit"]
+ delete(tool.Parameters, "to-user-ids")
+ product.Tools["report.entry_submit"] = tool
+ missing.Products["report"] = product
+ normalized, err := normalizeSchemaFlagMigrations(missing, current, []interfacesnapshot.FlagMigration{migration})
+ if err != nil || !reflect.DeepEqual(normalized, missing) {
+ t.Fatalf("missing historical requiredness parameter = %#v, %v", normalized, err)
+ }
+ })
+
+ t.Run("missing current tool stays visible to ordinary checker", func(t *testing.T) {
+ missing := cloneContract(current)
+ delete(missing.Products, "report")
+ normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failures := checkCompatibility(normalized, missing); len(failures) == 0 {
+ t.Fatal("requiredness migration hid missing current Schema tool")
+ }
+ })
+
+ t.Run("missing current parameter stays visible to ordinary checker", func(t *testing.T) {
+ missing := cloneContract(current)
+ product := missing.Products["report"]
+ tool := product.Tools["report.entry_submit"]
+ delete(tool.Parameters, "to-user-ids")
+ product.Tools["report.entry_submit"] = tool
+ missing.Products["report"] = product
+ normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failures := strings.Join(checkCompatibility(normalized, missing), "\n"); !strings.Contains(failures, "lost parameter") {
+ t.Fatalf("requiredness migration hid missing current parameter: %s", failures)
+ }
+ })
+}
+
func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing.T) {
tests := []struct {
name string
@@ -1589,7 +1727,7 @@ func TestCrossPlatformCoverageSchemaMigrationCLIRequiresCompleteCheckInputs(t *t
"--migration-stable-snapshot", "stable.json",
}
var stdout, stderr bytes.Buffer
- if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all five") {
+ if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both flag manifests") {
t.Fatalf("partial migration inputs code=%d stderr=%q", code, stderr.String())
}
@@ -1739,11 +1877,12 @@ func TestCrossPlatformCoverageSchemaConsumedReceiptIsNoOpForAfterBaseline(t *tes
t.Fatalf("consumed receipt did not preserve compatibility: %v", failures)
}
- // Once stable also reaches after, retaining the consumed receipt is stale;
- // deleting it yields no authorization and the already-after Schema passes.
+ // Once stable also reaches after, retaining or deleting the consumed receipt
+ // yields no authorization and the already-after Schema passes.
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaFlagMigrationSnapshot(true))
- if _, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath); err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
- t.Fatalf("stale consumed receipt error = %v", err)
+ migrations, err = authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath)
+ if err != nil || len(migrations) != 0 {
+ t.Fatalf("retained consumed receipt authorizations = %#v, %v", migrations, err)
}
emptyManifest := interfacesnapshot.FlagMigrationManifest{
Version: interfacesnapshot.FlagMigrationManifestVersion,
diff --git a/scripts/policy/schemacompat/authority_integration_test.go b/scripts/policy/schemacompat/authority_integration_test.go
index 5866ee50..9346e1c1 100644
--- a/scripts/policy/schemacompat/authority_integration_test.go
+++ b/scripts/policy/schemacompat/authority_integration_test.go
@@ -32,6 +32,8 @@ func main() {
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
+ _ = flag.String("approved-command-migrations", "", "base command ledger")
+ _ = flag.String("candidate-command-migrations", "", "candidate command ledger")
flag.Parse()
if *normalize != "" {
@@ -171,6 +173,9 @@ func main() {
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
+ migrationBaseSchema := flag.String("migration-base-schema", "", "merge-base Schema contract")
+ approvedCommand := flag.String("approved-command-migrations", "", "base command ledger")
+ candidateCommand := flag.String("candidate-command-migrations", "", "candidate command ledger")
flag.Parse()
if *normalize != "" {
@@ -210,6 +215,29 @@ func main() {
os.Exit(2)
}
}
+ commandPair := *approvedCommand != "" || *candidateCommand != ""
+ if commandPair && (*approvedCommand == "" || *candidateCommand == "") {
+ fmt.Fprintln(os.Stderr, "stable Schema guard received a partial command migration pair")
+ os.Exit(2)
+ }
+ if commandPair {
+ data, err := os.ReadFile(*migrationBaseSchema)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(2)
+ }
+ if !strings.Contains(string(data), "BASE_AUTHORITY") {
+ fmt.Fprintf(os.Stderr, "migration base Schema is not base-owned: %s\n", data)
+ os.Exit(2)
+ }
+ fmt.Fprintln(os.Stdout, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY")
+ } else {
+ if *migrationBaseSchema != "" {
+ fmt.Fprintln(os.Stderr, "flag-only Schema check received command migration lineage")
+ os.Exit(2)
+ }
+ fmt.Fprintln(os.Stdout, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED")
+ }
currentData, err := os.ReadFile(*current)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -282,6 +310,27 @@ var (
func AuthorityMarker() string { return "BASE" }
`
+const schemaCommandGovernedSnapshotSource = `package interfacesnapshot
+
+import (
+ "example.com/dws-schema-compat-authority-test/internal/corecmd"
+ "example.com/dws-schema-compat-authority-test/internal/corecmd/runtimeannotate"
+)
+
+var (
+ _ = runtimeannotate.AnnotationFlagAliasOf
+ _ = runtimeannotate.AnnotationFlagAliasOrigin
+ _ = runtimeannotate.FlagAliasOriginCorecmdV1
+)
+
+func AuthorityMarker() string {
+ if corecmd.InterfaceBoolConstParams() != "BASE" {
+ return "UNTRUSTED_CONST_PARAMS"
+ }
+ return "BASE"
+}
+`
+
const schemaBaseSnapshotSource = `package interfacesnapshot
func AuthorityMarker() string { return "BASE" }
@@ -306,6 +355,54 @@ const (
)
`
+func schemaConstParamsRegistrySource(marker string) string {
+ return fmt.Sprintf(`package corecmd
+
+type constParamsRegistry struct{}
+
+func (constParamsRegistry) Store(any, any) {}
+
+var interfaceBoolConstParamsRegistry constParamsRegistry
+
+func attachInterfaceBoolConstParams() {}
+
+func InterfaceBoolConstParams() string { return %q }
+`, marker)
+}
+
+const schemaBaseCorecmdBridgeSource = `package corecmd
+
+func boolConstParams(map[string]any) map[string]bool {
+ return map[string]bool{"convThreadEnabled": true}
+}
+
+func installConstParamsEvidence() { attachInterfaceBoolConstParams() }
+`
+
+const schemaForgedCorecmdBridgeSource = `package corecmd
+
+func boolConstParams(map[string]any) map[string]bool {
+ return map[string]bool{"convThreadEnabled": false}
+}
+
+func installConstParamsEvidence() { attachInterfaceBoolConstParams() }
+`
+
+const schemaBaseLeafAdapterSource = `package helpers
+
+func forwardToolArgs(toolArgs map[string]any) map[string]any {
+ return toolArgs
+}
+`
+
+const schemaForgedLeafAdapterSource = `package helpers
+
+func forwardToolArgs(toolArgs map[string]any) map[string]any {
+ delete(toolArgs, "convThreadEnabled")
+ return toolArgs
+}
+`
+
const candidateNoopSource = `package main
import "fmt"
@@ -327,12 +424,14 @@ func main() {
}
type authorityScenario struct {
- name string
- baseGovernance string
- candidateMutation string
- checkerMode string
- want string
- authorityMarker string
+ name string
+ baseGovernance string
+ commandGovernance string
+ commandManifestChanged bool
+ candidateMutation string
+ checkerMode string
+ want string
+ authorityMarker string
}
func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) {
@@ -343,9 +442,16 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
},
{
- name: "governed checker protects stable Schema contract",
+ name: "governed checker protects stable Schema contract",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ checkerMode: "stable-schema-guard",
+ authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
+ },
+ {
+ name: "lineage capable checker omits command lineage for flag-only governance",
baseGovernance: "complete",
- checkerMode: "stable-schema-guard",
+ checkerMode: "flag-only-schema-guard",
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
},
{
@@ -353,6 +459,85 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi
baseGovernance: "none",
authorityMarker: "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED",
},
+ {
+ name: "command governed base owns bool ConstParams contract",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "change-const-params-protocol",
+ authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
+ },
+ {
+ name: "command governance bootstrap preserves candidate bool ConstParams contract",
+ baseGovernance: "complete",
+ commandGovernance: "bootstrap",
+ authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
+ },
+ {
+ name: "command governed candidate cannot delete bool ConstParams contract",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "delete-const-params-contract",
+ want: "must preserve the complete Schema command migration governance artifact set",
+ },
+ {
+ name: "same package candidate cannot call private ConstParams writer",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "forge-const-params-token",
+ want: "may not access framework-owned bool ConstParams registry",
+ },
+ {
+ name: "candidate cannot reopen private ConstParams reader",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "forge-const-params-reader",
+ want: "may not access framework-owned bool ConstParams registry",
+ },
+ {
+ name: "same package candidate cannot store directly in ConstParams registry",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "forge-const-params-registry-store",
+ want: "may not access framework-owned bool ConstParams registry",
+ },
+ {
+ name: "changed command manifest rejects forged corecmd bool ConstParams bridge",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ commandManifestChanged: true,
+ candidateMutation: "forge-corecmd-bool-const-params",
+ want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go",
+ },
+ {
+ name: "changed command manifest rejects tampered ConstParams protocol",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ commandManifestChanged: true,
+ candidateMutation: "change-const-params-protocol",
+ want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go",
+ },
+ {
+ name: "changed command manifest rejects tampered leaf adapter",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ commandManifestChanged: true,
+ candidateMutation: "forge-leaf-adapter",
+ want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go",
+ },
+ {
+ name: "changed command manifest allows base identical bridges",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ commandManifestChanged: true,
+ authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
+ },
+ {
+ name: "unchanged command manifest allows independent corecmd change",
+ baseGovernance: "complete",
+ commandGovernance: "complete",
+ candidateMutation: "forge-corecmd-bool-const-params",
+ authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
+ },
{
name: "partial base fails closed",
baseGovernance: "partial",
@@ -457,7 +642,7 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
switch test.baseGovernance {
case "complete":
checkerSource := governedSchemaCheckerSource
- if test.checkerMode == "stable-schema-guard" {
+ if test.checkerMode == "stable-schema-guard" || test.checkerMode == "flag-only-schema-guard" {
checkerSource = stableSchemaGuardCheckerSource
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), checkerSource, 0o644)
@@ -479,6 +664,19 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
default:
t.Fatalf("unknown base governance state %q", test.baseGovernance)
}
+ switch test.commandGovernance {
+ case "":
+ case "complete":
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaCommandGovernedSnapshotSource, 0o644)
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644)
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaBaseCorecmdBridgeSource, 0o644)
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("BASE"), 0o644)
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaBaseLeafAdapterSource, 0o644)
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644)
+ case "bootstrap":
+ default:
+ t.Fatalf("unknown command governance state %q", test.commandGovernance)
+ }
schemaRun(t, fixtureRoot, "git", "add", ".")
schemaRun(t, fixtureRoot, "git", "commit", "-m", "base authority")
baseRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD"))
@@ -508,6 +706,17 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
candidateLedger = "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n"
}
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644)
+ if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" {
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644)
+ if test.commandGovernance == "bootstrap" {
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644)
+ }
+ commandManifest := "{\"version\":1,\"migrations\":[]}\n"
+ if test.commandManifestChanged {
+ commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n"
+ }
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644)
+ }
schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644)
switch test.candidateMutation {
case "":
@@ -534,6 +743,34 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
"package evil\n\nconst forged = \"dws.compat.alias_of\"\n",
0o644,
)
+ case "delete-const-params-contract":
+ if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil {
+ t.Fatalf("remove candidate bool ConstParams registry: %v", err)
+ }
+ case "forge-const-params-token":
+ schemaWriteFile(t,
+ filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"),
+ "package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n",
+ 0o644,
+ )
+ case "forge-const-params-reader":
+ schemaWriteFile(t,
+ filepath.Join(fixtureRoot, "internal", "evil", "forged.go"),
+ "package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n",
+ 0o644,
+ )
+ case "forge-const-params-registry-store":
+ schemaWriteFile(t,
+ filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"),
+ "package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n",
+ 0o644,
+ )
+ case "forge-corecmd-bool-const-params":
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaForgedCorecmdBridgeSource, 0o644)
+ case "change-const-params-protocol":
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644)
+ case "forge-leaf-adapter":
+ schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaForgedLeafAdapterSource, 0o644)
case "symlink-helper-parent":
helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot")
alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt")
@@ -588,6 +825,12 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 {
t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got)
}
+ if test.checkerMode == "stable-schema-guard" && strings.Count(got, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY") != 2 {
+ t.Fatalf("governed Schema check did not pass the base-owned Schema lineage to both historical checks; output:\n%s", got)
+ }
+ if test.checkerMode == "flag-only-schema-guard" && strings.Count(got, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED") != 2 {
+ t.Fatalf("flag-only Schema check received command migration lineage; output:\n%s", got)
+ }
wrongStable := exec.Command(
"sh",
diff --git a/scripts/release/check-release-compatibility.sh b/scripts/release/check-release-compatibility.sh
new file mode 100755
index 00000000..290826bb
--- /dev/null
+++ b/scripts/release/check-release-compatibility.sh
@@ -0,0 +1,122 @@
+#!/bin/sh
+set -eu
+
+# Release compatibility is one decision seam over the sealed source tree.
+# Trusted release tooling may orchestrate this script, but the explicitly
+# selected repository remains the authority for both CLI and Schema checks.
+
+REPO_ROOT=""
+BASE_REF=""
+STABLE_REF=""
+CANDIDATE_REF="HEAD"
+
+usage() {
+ printf '%s\n' \
+ "usage: $0 --repo-root --base-ref --stable-ref [--candidate-ref ]" >&2
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --repo-root)
+ [ "$#" -ge 2 ] || { usage; exit 2; }
+ REPO_ROOT="$2"
+ shift 2
+ ;;
+ --base-ref)
+ [ "$#" -ge 2 ] || { usage; exit 2; }
+ BASE_REF="$2"
+ shift 2
+ ;;
+ --stable-ref)
+ [ "$#" -ge 2 ] || { usage; exit 2; }
+ STABLE_REF="$2"
+ shift 2
+ ;;
+ --candidate-ref)
+ [ "$#" -ge 2 ] || { usage; exit 2; }
+ CANDIDATE_REF="$2"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ printf 'error: unknown argument: %s\n' "$1" >&2
+ usage
+ exit 2
+ ;;
+ esac
+done
+
+[ -n "$REPO_ROOT" ] && [ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || {
+ usage
+ exit 2
+}
+
+REPO_ROOT="$(CDPATH= cd -- "$REPO_ROOT" && pwd -P)" || {
+ printf 'error: release source root is not available: %s\n' "$REPO_ROOT" >&2
+ exit 2
+}
+GIT_ROOT="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null)" || {
+ printf 'error: release source root is not a Git worktree: %s\n' "$REPO_ROOT" >&2
+ exit 2
+}
+[ "$GIT_ROOT" = "$REPO_ROOT" ] || {
+ printf 'error: release source root must be the Git worktree root: %s\n' "$REPO_ROOT" >&2
+ exit 2
+}
+
+CLI_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-interface-baselines.sh"
+SCHEMA_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-schema-compatibility.sh"
+for check in "$CLI_CHECK" "$SCHEMA_CHECK"; do
+ [ -x "$check" ] || {
+ printf 'error: authoritative release compatibility checker is unavailable: %s\n' "$check" >&2
+ exit 2
+ }
+done
+
+resolve_commit() {
+ label="$1"
+ ref="$2"
+ commit="$(git -C "$REPO_ROOT" rev-parse --verify "${ref}^{commit}" 2>/dev/null)" || {
+ printf 'error: release %s ref is not available in sealed source: %s\n' "$label" "$ref" >&2
+ return 2
+ }
+ [ -n "$commit" ] || {
+ printf 'error: release %s ref resolved to an empty commit: %s\n' "$label" "$ref" >&2
+ return 2
+ }
+ printf '%s\n' "$commit"
+}
+
+# Freeze the complete comparison tuple before either checker runs. Otherwise a
+# concurrent ref update (or the first checker itself) could make Schema inspect
+# different Git objects from the CLI gate even when both receive the same names.
+BASE_COMMIT="$(resolve_commit base "$BASE_REF")"
+STABLE_COMMIT="$(resolve_commit stable "$STABLE_REF")"
+CANDIDATE_COMMIT="$(resolve_commit candidate "$CANDIDATE_REF")"
+
+printf '==> Checking authoritative CLI compatibility\n'
+if "$CLI_CHECK" \
+ --base-ref "$BASE_COMMIT" \
+ --stable-ref "$STABLE_COMMIT" \
+ --candidate-ref "$CANDIDATE_COMMIT"; then
+ :
+else
+ status=$?
+ printf 'error: authoritative CLI compatibility failed\n' >&2
+ exit "$status"
+fi
+
+printf '==> Checking authoritative Schema compatibility\n'
+if "$SCHEMA_CHECK" \
+ --base-ref "$BASE_COMMIT" \
+ --stable-ref "$STABLE_COMMIT" \
+ --candidate-ref "$CANDIDATE_COMMIT"; then
+ :
+else
+ status=$?
+ printf 'error: authoritative Schema compatibility failed\n' >&2
+ exit "$status"
+fi
diff --git a/scripts/release/prepare-changelog.sh b/scripts/release/prepare-changelog.sh
index 053a46b7..e7a7b25a 100755
--- a/scripts/release/prepare-changelog.sh
+++ b/scripts/release/prepare-changelog.sh
@@ -64,17 +64,49 @@ release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}"
section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")"
output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")"
fragments="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragments.XXXXXX")"
-cleanup() { rm -f "$section" "$output" "$fragments"; }
+fragment_entries="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragment-entries.XXXXXX")"
+cleanup() { rm -f "$section" "$output" "$fragments" "$fragment_entries"; }
trap cleanup EXIT HUP INT TERM
+render_fragments=false
+if [ "$CHANNEL" = "prerelease" ]; then
+ render_fragments=true
+else
+ if [ -e "$CHANGES_DIR" ] && [ ! -d "$CHANGES_DIR" ]; then
+ printf 'release fragments path is not a directory: %s\n' "$CHANGES_DIR" >&2
+ exit 1
+ fi
+ if [ -d "$CHANGES_DIR" ]; then
+ find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -print | sort >"$fragment_entries"
+ while IFS= read -r entry; do
+ base="${entry##*/}"
+ if [ "$base" = 'README.md' ] && [ -f "$entry" ] && [ ! -L "$entry" ]; then
+ continue
+ fi
+ if [ "$base" = 'released' ] && [ -d "$entry" ] && [ ! -L "$entry" ]; then
+ continue
+ fi
+ render_fragments=true
+ break
+ done <"$fragment_entries"
+ fi
+fi
+
+if [ "$render_fragments" = true ]; then
+ "$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments"
+fi
+
{
printf '## [%s] - %s\n\n' "$semver" "$release_date"
if [ "$CHANNEL" = "stable" ]; then
printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA"
printf '### Changed\n\n'
printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA"
+ if [ -s "$fragments" ]; then
+ printf '\n### Changes since `%s`\n\n' "$FROM_BETA"
+ cat "$fragments"
+ fi
else
- "$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments"
cat "$fragments"
fi
} > "$section"
@@ -106,14 +138,18 @@ fi
[ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; }
cp "$output" "$CHANGELOG"
-if [ "$CHANNEL" = "prerelease" ]; then
+if [ -s "$fragments" ]; then
archive_dir="$CHANGES_DIR/released/$semver"
mkdir -p "$archive_dir"
find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -exec mv {} "$archive_dir"/ \;
fi
if [ "$CHANNEL" = "stable" ]; then
- printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
+ if [ -s "$fragments" ]; then
+ printf 'Prepared CHANGELOG template and archived post-beta release fragments for %s. Replace TODO, review, commit, and merge the release-seal PR before release.\n' "$VERSION"
+ else
+ printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
+ fi
else
printf 'Prepared CHANGELOG and archived release fragments for %s. Review, commit, and merge the release-seal PR before release.\n' "$VERSION"
fi
diff --git a/scripts/release/release.sh b/scripts/release/release.sh
index bb7dea1f..eb44b5a5 100755
--- a/scripts/release/release.sh
+++ b/scripts/release/release.sh
@@ -19,7 +19,7 @@ usage() {
cat >&2 <<'EOF'
usage: release.sh [options]
-Runs the full test, command-compatibility, package, and install preflight.
+Runs the full test, authoritative CLI/Schema compatibility, package, and install preflight.
The default is validation only. Official publication is cloud-only.
Options:
@@ -359,10 +359,12 @@ else
make policy
if [ -n "$previous_stable" ]; then
- printf '==> Comparing command tree with %s\n' "$previous_stable"
- "$ROOT/scripts/policy/check-command-compatibility.sh" \
+ printf '==> Comparing authoritative CLI and Schema with %s\n' "$previous_stable"
+ "$SCRIPT_DIR/check-release-compatibility.sh" \
+ --repo-root "$ROOT" \
--base-ref HEAD \
- --stable-ref "$previous_stable"
+ --stable-ref "$previous_stable" \
+ --candidate-ref HEAD
fi
printf '==> Building local release artifacts for %s\n' "$VERSION"
@@ -412,11 +414,13 @@ printf '==> Revalidating delivered stable baseline %s\n' "$previous_stable"
require_delivered_previous_stable
if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then
- printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
+ printf '==> Stable authority advanced from %s to %s; rechecking authoritative CLI and Schema compatibility\n' \
"${previous_stable_before_refresh:-none}" "$previous_stable"
- "$ROOT/scripts/policy/check-command-compatibility.sh" \
+ "$SCRIPT_DIR/check-release-compatibility.sh" \
+ --repo-root "$ROOT" \
--base-ref HEAD \
- --stable-ref "$previous_stable"
+ --stable-ref "$previous_stable" \
+ --candidate-ref HEAD
fi
if [ "$PUBLISH" -eq 1 ]; then
diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh
index 71b0d8c0..da1512b7 100755
--- a/scripts/release/verify-package-managers.sh
+++ b/scripts/release/verify-package-managers.sh
@@ -54,42 +54,92 @@ while [ "$#" -gt 0 ]; do
done
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")"
-HOME_SPECIFIC_AGENT_PARENTS="
-.claude
-.cursor
-.qoder
-.qoderwork
-.gemini
-.codex
-.zcode
-.github
-.windsurf
-.augment
-.cline
-.amp
-.kiro
-.trae
-.openclaw
-.hermes
-"
-HOME_SPECIFIC_SKILL_BASES="
-.claude/skills
-.cursor/skills
-.qoder/skills
-.qoderwork/skills
-.gemini/skills
-.codex/skills
-.zcode/skills
-.github/skills
-.windsurf/skills
-.augment/skills
-.cline/skills
-.amp/skills
-.kiro/skills
-.trae/skills
-.openclaw/skills
-.hermes/skills
-"
+# vercel-labs/skills agents.ts (c6f69c6), exactly 76 upstream IDs.
+# Format: id|universal(1/0)|effective default global root; '-' has no global root.
+UPSTREAM_AGENT_REGISTRY='aider-desk|0|.aider-desk/skills
+amp|1|.config/agents/skills
+antigravity|1|.gemini/antigravity/skills
+antigravity-cli|1|.gemini/antigravity-cli/skills
+astrbot|0|.astrbot/data/skills
+autohand-code|0|.autohand/skills
+augment|0|.augment/skills
+bob|0|.bob/skills
+claude-code|0|.claude/skills
+openclaw|0|.openclaw/skills
+cline|1|.agents/skills
+codearts-agent|0|.codeartsdoer/skills
+codebuddy|0|.codebuddy/skills
+codemaker|0|.codemaker/skills
+codestudio|0|.codestudio/skills
+codex|1|.codex/skills
+command-code|0|.commandcode/skills
+continue|0|.continue/skills
+cortex|0|.snowflake/cortex/skills
+crush|0|.config/crush/skills
+cursor|1|.cursor/skills
+deepagents|1|.deepagents/agent/skills
+devin|0|.config/devin/skills
+dexto|1|.agents/skills
+droid|0|.factory/skills
+eve|0|-
+firebender|1|.firebender/skills
+forgecode|0|.forge/skills
+gemini-cli|1|.gemini/skills
+github-copilot|1|.copilot/skills
+goose|0|.config/goose/skills
+grok|0|.grok/skills
+hermes-agent|0|.hermes/skills
+inference-sh|0|.inferencesh/skills
+jazz|0|.jazz/skills
+junie|0|.junie/skills
+iflow-cli|0|.iflow/skills
+kilo|0|.kilocode/skills
+kimchi|0|.config/kimchi/harness/skills
+kimi-code-cli|1|.agents/skills
+kiro-cli|0|.kiro/skills
+kode|0|.kode/skills
+lingma|0|.lingma/skills
+loaf|1|.agents/skills
+mcpjam|0|.mcpjam/skills
+minimax-code|0|.minimax/skills
+mistral-vibe|0|.vibe/skills
+moxby|0|.moxby/skills
+mux|0|.mux/skills
+opencode|1|.config/opencode/skills
+openhands|0|.openhands/skills
+ona|0|.ona/skills
+pi|0|.pi/agent/skills
+qoder|0|.qoder/skills
+qoder-cn|0|.qoder-cn/skills
+qwen-code|0|.qwen/skills
+replit|1|.config/agents/skills
+reasonix|0|.reasonix/skills
+rovodev|0|.rovodev/skills
+roo|0|.roo/skills
+tabnine-cli|0|.tabnine/agent/skills
+terramind|0|.terramind/skills
+tinycloud|0|.tinycloud/skills
+trae|0|.trae/skills
+trae-cn|0|.trae-cn/skills
+warp|1|.agents/skills
+windsurf|0|.codeium/windsurf/skills
+zed|1|.agents/skills
+zcode|0|.zcode/skills
+zencoder|0|.zencoder/skills
+zenflow|0|.zencoder/skills
+neovate|0|.neovate/skills
+pochi|0|.pochi/skills
+promptscript|1|-
+adal|0|.adal/skills
+universal|1|.config/agents/skills'
+
+# DWS-only compatibility and old wrong global paths. Qoderwork remains a
+# non-universal install target; the other entries verify migration cleanup.
+LEGACY_AGENT_CLEANUP_REGISTRY='dws-qoderwork|0|.qoderwork/skills
+dws-legacy-github|1|.github/skills
+dws-legacy-amp|1|.amp/skills
+dws-legacy-cline|1|.cline/skills
+dws-legacy-windsurf|1|.windsurf/skills'
cleanup() {
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then
HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \
@@ -103,11 +153,61 @@ trap cleanup EXIT INT TERM
seed_specific_agent_homes() {
home_root="$1"
- for parent in $HOME_SPECIFIC_AGENT_PARENTS; do
- mkdir -p "$home_root/$parent"
+ seen=''
+ for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do
+ id=${row%%|*}; rest=${row#*|}; path=${rest#*|}
+ [ "$path" != '-' ] || continue
+ base=$(resolve_agent_base "$home_root" "$id" "$path")
+ [ -n "$base" ] || continue
+ key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')
+ printf '%s\n' "$seen" | grep -Fqx "$key" && continue
+ seen="${seen}${key}
+"
+ mkdir -p "${base%/skills}"
done
}
+resolve_agent_base() {
+ home_root="$1"; id="$2"; default_path="$3"
+ case "$id" in
+ autohand-code) printf '%s\n' "${AUTOHAND_HOME:-$home_root/.autohand}/skills" ;;
+ claude-code) printf '%s\n' "${CLAUDE_CONFIG_DIR:-$home_root/.claude}/skills" ;;
+ codex) printf '%s\n' "${CODEX_HOME:-$home_root/.codex}/skills" ;;
+ grok) printf '%s\n' "${GROK_HOME:-$home_root/.grok}/skills" ;;
+ hermes-agent) printf '%s\n' "${HERMES_HOME:-$home_root/.hermes}/skills" ;;
+ mistral-vibe) printf '%s\n' "${VIBE_HOME:-$home_root/.vibe}/skills" ;;
+ amp|replit|universal) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/agents/skills" ;;
+ crush|devin|goose|kimchi|opencode) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/${default_path#.config/}" ;;
+ openclaw)
+ for alias in .openclaw .clawdbot .moltbot; do
+ if [ -d "$home_root/$alias" ]; then printf '%s\n' "$home_root/$alias/skills"; return; fi
+ done
+ printf '%s\n' "$home_root/.openclaw/skills"
+ ;;
+ *) printf '%s\n' "$home_root/$default_path" ;;
+ esac
+}
+
+# Package verification must never inherit the runner's real Agent roots. In
+# particular, hosted Linux runners can define XDG_CONFIG_HOME globally; two
+# parallel verification jobs would then publish links into the same directory
+# and make one another's links appear broken. Keep every supported override
+# inside the scenario-specific temporary HOME instead.
+with_isolated_agent_env() (
+ isolated_home=$1
+ shift
+ HOME="$isolated_home"
+ AUTOHAND_HOME="$isolated_home/.autohand"
+ CLAUDE_CONFIG_DIR="$isolated_home/.claude"
+ CODEX_HOME="$isolated_home/.codex"
+ GROK_HOME="$isolated_home/.grok"
+ HERMES_HOME="$isolated_home/.hermes"
+ VIBE_HOME="$isolated_home/.vibe"
+ XDG_CONFIG_HOME="$isolated_home/.config"
+ export HOME AUTOHAND_HOME CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME HERMES_HOME VIBE_HOME XDG_CONFIG_HOME
+ "$@"
+)
+
verify_skill_base() {
home_root="$1"
base="$2"
@@ -116,25 +216,71 @@ verify_skill_base() {
[ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws"
}
-verify_skill_targets() {
- home_root="$1"
- found_specific=0
- for base in $HOME_SPECIFIC_SKILL_BASES; do
- parent="${base%/skills}"
- if [ -d "$home_root/$parent" ]; then
- verify_skill_base "$home_root" "$base"
- found_specific=1
+verify_compatible_skill_base() {
+ home_root="$1"; base="$2"; canonical="$home_root/.agents/skills"
+ for skill_md in "$canonical"/*/SKILL.md; do
+ [ -f "$skill_md" ] || continue
+ name=$(basename "$(dirname "$skill_md")")
+ target="$base/$name"
+ if [ -L "$target" ]; then
+ link_target="$(readlink "$target")" || err "could not read canonical Skill link at $target"
+ # Relative link targets are the headline invariant of the canonical store:
+ # an absolute target breaks as soon as the home directory moves or the
+ # store is relocated, so reject it even though it resolves today.
+ case "$link_target" in
+ /*) err "canonical Skill link must be relative at $target (target=$link_target)" ;;
+ esac
+ linked_real="$(CDPATH= cd -- "$target" 2>/dev/null && pwd -P)" || err "broken canonical Skill link at $target"
+ canonical_real="$(CDPATH= cd -- "$canonical/$name" && pwd -P)" || err "canonical Skill missing at $canonical/$name"
+ [ "$linked_real" = "$canonical_real" ] || err "Skill link does not resolve to canonical source at $target"
+ elif [ -d "$target" ]; then
+ diff -qr "$canonical/$name" "$target" >/dev/null || err "Skill copy fallback differs from canonical source at $target"
+ else
+ err "canonical Skill compatibility target missing at $target"
fi
done
+ [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ] || err "unexpected mono Skill layout found in $base/dws"
+}
- if [ "$found_specific" -eq 0 ]; then
- verify_skill_base "$home_root" ".agents/skills"
- return
- fi
+# Universal Agents read the canonical store directly, so they must own no copy
+# of ANY canonical Skill. Iterate the real store instead of a hardcoded name
+# list: a hardcoded list silently passed leftover copies of every other
+# dingtalk-* Skill from earlier betas.
+verify_universal_skill_base() {
+ home_root="$1"; base="$2"; canonical="$home_root/.agents/skills"
+ for skill_md in "$canonical"/*/SKILL.md; do
+ [ -f "$skill_md" ] || continue
+ name=$(basename "$(dirname "$skill_md")")
+ { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \
+ err "unexpected duplicate Skill found in universal Agent root $base/$name"
+ done
+ # The mono layout is absent from a multi canonical store, so gate it directly.
+ { [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ]; } || \
+ err "unexpected duplicate Skill found in universal Agent root $base/dws"
+}
- for name in dingtalk-shared dingtalk-misc dws; do
- [ ! -e "$home_root/.agents/skills/$name" ] || \
- err "unexpected generic Skill copy found in $home_root/.agents/skills/$name"
+verify_skill_targets() {
+ home_root="$1"
+ # The universal-convention canonical store is always present, regardless of
+ # which concrete Agent homes existed at install time.
+ verify_skill_base "$home_root" ".agents/skills"
+ seen=''
+ for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do
+ id=${row%%|*}; rest=${row#*|}; universal=${rest%%|*}; path=${rest#*|}
+ [ "$path" != '-' ] || continue
+ base=$(resolve_agent_base "$home_root" "$id" "$path")
+ [ -n "$base" ] || continue
+ key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')
+ printf '%s\n' "$seen" | grep -Fqx "$key" && continue
+ seen="${seen}${key}
+"
+ [ "$base" != "$home_root/.agents/skills" ] || continue
+ [ -d "${base%/skills}" ] || continue
+ if [ "$universal" -eq 1 ]; then
+ verify_universal_skill_base "$home_root" "$base"
+ continue
+ fi
+ verify_compatible_skill_base "$home_root" "$base"
done
}
@@ -146,15 +292,15 @@ verify_npm_install() {
npm_cache="$TMP_ROOT/npm-cache-$scenario"
mkdir -p "$npm_home" "$npm_prefix" "$npm_cache"
if [ "$scenario" = "specific-agent-roots" ]; then
- seed_specific_agent_homes "$npm_home"
+ with_isolated_agent_env "$npm_home" seed_specific_agent_homes "$npm_home"
fi
say "==> verifying npm package install ($scenario)"
- HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
+ with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
npm install -g "$tarball_path" >/dev/null
[ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin"
- HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null
+ with_isolated_agent_env "$npm_home" "$npm_prefix/bin/dws" --help >/dev/null
if [ -n "$EXPECTED_VERSION" ]; then
vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws"
need_file "$vendor_bin"
@@ -165,9 +311,9 @@ verify_npm_install() {
if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1);
' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch"
fi
- [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home"
+ [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || with_isolated_agent_env "$npm_home" verify_skill_targets "$npm_home"
- HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
+ with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \
npm uninstall -g dingtalk-workspace-cli >/dev/null
}
@@ -176,6 +322,7 @@ verify_npm() {
need_cmd node
need_cmd tar
need_cmd unzip
+ need_cmd diff
need_file "$NPM_STAGE_DIR/package.json"
tarball_name="$(
diff --git a/skills/content-qa/mono-multi-coverage.yaml b/skills/content-qa/mono-multi-coverage.yaml
index 440c149e..8dc8895a 100644
--- a/skills/content-qa/mono-multi-coverage.yaml
+++ b/skills/content-qa/mono-multi-coverage.yaml
@@ -108,6 +108,10 @@ coverage:
multi_skill: dingtalk-misc
multi_refs:
- references/whiteboard.md
+ - mono: recruit
+ multi_skill: dingtalk-misc
+ multi_refs:
+ - references/recruit.md
omit_coverage:
- mono: simple
diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md
index 189ffbad..3d226752 100644
--- a/skills/mono/SKILL.md
+++ b/skills/mono/SKILL.md
@@ -1,6 +1,6 @@
---
name: dws
-description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/终止/完成,以及审批任务创建/完成/转交时使用。
+description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/抄送/终止/完成,以及审批任务创建/完成/转交时使用。
cli_version: ">=1.0.15"
---
@@ -44,18 +44,18 @@ cli_version: ">=1.0.15"
| 服务 | shortcut 数 | multi skill |
|---|---:|---|
| `aitable` | 93 | `dingtalk-aitable` |
-| `attendance` | 19 | `dingtalk-misc` |
+| `attendance` | 8 | `dingtalk-misc` |
| `calendar` | 27 | `dingtalk-calendar` |
| `chat` | 98 | `dingtalk-chat` |
| `contact` | 14 | `dingtalk-contact` |
| `devapp` | 19 | `dingtalk-misc` |
-| `ding` | 4 | `dingtalk-misc` |
+| `ding` | 1 | `dingtalk-misc` |
| `doc` | 45 | `dingtalk-doc` |
| `drive` | 28 | `dingtalk-drive` |
-| `mail` | 10 | `dingtalk-mail` |
+| `mail` | 8 | `dingtalk-mail` |
| `minutes` | 27 | `dingtalk-minutes` |
-| `oa` | 7 | `dingtalk-misc` |
-| `report` | 2 | `dingtalk-misc` |
+| `oa` | 1 | `dingtalk-misc` |
+| `report` | 4 | `dingtalk-misc` |
| `sheet` | 2 | `dingtalk-misc` |
| `todo` | 21 | `dingtalk-todo` |
| `wiki` | 20 | `dingtalk-wiki` |
@@ -94,6 +94,7 @@ cli_version: ">=1.0.15"
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
+| `recruit` | 钉钉招聘:查询职位列表、获取职位详情、创建职位 | [recruit.md](./references/products/recruit.md) |
| `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
## 意图判断决策树
@@ -121,6 +122,7 @@ cli_version: ">=1.0.15"
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert`
+用户提到"招聘/职位/JD/在招职位/创建职位/职位详情" → `recruit`
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume`
用户提到"监听待我审批的任务/监听审批任务创建、完成或转交/监听审批单发起或终止/监听我发起的审批完成/监听审批实例完成/订阅 OA 事件/event consume user_oa_approval_*" → `event consume`
diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md
index 5fbff337..90159f7b 100644
--- a/skills/mono/references/products/chat.md
+++ b/skills/mono/references/products/chat.md
@@ -437,23 +437,23 @@ Flags:
--role-id string 群身份 openRoleId,由 group-role list 返回 (必填)
```
-#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份(传空则清除所有身份)
+#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份
```
Usage:
dws chat group-role set-user [flags]
Example:
- dws chat group-role set-user --group --user --role-ids roleId1,roleId2
+ dws chat group-role set-user --group --user --role-id
# 查询人员: dws aisearch person --query "姓名" --dimension name
# 查询 role-id: dws chat group-role list --group
Flags:
- --group string 群聊 openConversationId (必填)
- --user string 用户 userId(必填)
- --role-ids string 群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份
+ --group string 群聊 openConversationId (必填)
+ --user string 用户 userId(必填)
+ --role-id string 群身份 openRoleId,由 group-role list 返回
```
#### 移除用户的指定群身份 — 从用户身上移除指定的群身份(不影响其他群身份)
-> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。
+> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。set-user 会清掉该用户的**全部**身份,无法只移除其中一个。
```
Usage:
dws chat group-role remove-user [flags]
@@ -1212,6 +1212,51 @@ Flags:
--msg-ids string 消息 ID 列表,逗号分隔 (必填)
```
+### emotion (个人收藏表情)
+
+#### 列出个人收藏表情
+```
+Usage:
+ dws chat emotion list [flags]
+Example:
+ dws chat emotion list --format json
+```
+当前用户身份由 MCP server 注入,无业务参数。
+
+#### 发送个人收藏表情
+```
+Usage:
+ dws chat emotion send [flags]
+Example:
+ dws chat emotion send --media-id --group
+ dws chat emotion send --media-id --emotion-id --user
+ dws chat emotion send --media-id --open-dingtalk-id --uuid
+Flags:
+ --media-id string 表情媒体 ID (必填)
+ --emotion-id string 表情 ID (可选)
+ --conversation-id string 群聊 openConversationId(与 --group 等价)
+ --group string 群聊 openConversationId
+ --user string 单聊接收人 userId,CLI 会解析为 openDingTalkId
+ --open-dingtalk-id string 单聊接收人 openDingTalkId
+ --uuid string 幂等键
+ --idempotency-key string 幂等键(--uuid 等价)
+```
+目标参数 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一。该命令会真实发送表情,执行前必须确认目标和 mediaId。
+
+#### 新增个人收藏表情
+```
+Usage:
+ dws chat emotion favorite [flags]
+Example:
+ dws chat emotion favorite --media-id --name "赞"
+ dws chat emotion favorite --media-id --source-conversation-id --source-message-id
+Flags:
+ --media-id string 待收藏 mediaId (必填)
+ --name string 表情名称
+ --source-conversation-id string 来源会话 ID,需与 --source-message-id 成对指定
+ --source-message-id string 来源消息 ID,需与 --source-conversation-id 成对指定
+```
+
### list-top-conversations (置顶会话)
#### 拉取置顶会话列表
@@ -2129,6 +2174,9 @@ Flags:
用户说"群公告/发布公告/修改公告/查看公告/定时公告" → `chat group notice create/edit/get/list`
用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids`
用户说"批量查消息回复/表情回复/文字回复/消息回应列表" → `chat message list-emotion-replies`
+用户说"查看个人收藏表情/列出我的收藏表情" → `chat emotion list`
+用户说"发送个人收藏表情/发表情包" → `chat emotion send`
+用户说"收藏表情/新增个人收藏表情" → `chat emotion favorite`
用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji`
用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji`
用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion`
@@ -2200,6 +2248,7 @@ Flags:
- `chat message list-emotion-replies` — 批量拉取消息的表情回复和文字回复
- `chat message add-text-emotion` / `update-text-emotion` / `remove-text-emotion` — 对消息添加、原地更新或移除文字表情回应
- `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用
+- `chat emotion list` / `send` / `favorite` — 当前用户个人收藏表情列表、发送和新增;不同于消息 reaction/文字回应
- `chat category list` — 获取用户自定义会话分组列表
- `chat category list-conversations` — 拉取指定分组下的会话列表
- `chat category create-smart` — 创建智能会话分组(可指定群名称关键词和成员作为匹配规则)
@@ -2457,7 +2506,7 @@ Flags:
| `chat conversation-info` | `newCSpaceIdIM` | 独立钉盘存储流程的共享空间 ID;不是发送本地聊天附件的前置条件 |
| `chat file upload` | 无(已下线) | 不要调用;常规发图/发文件用 `chat message send --msg-type file --file` |
| `chat message list` | `openMsgId` | message read-status 的 --message-id |
-| `chat group-role list` | `openRoleId` | group-role update/remove/set-user/remove-user 的 --role-id |
+| `chat group-role list` | `openRoleId` | group-role update/remove/set-user 的 --role-id;remove-user 的 --role-ids |
| `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id |
| `chat category list` | `categoryId` | category list-conversations 的 --category-id |
| `chat group get-by-group-id` | `openConversationId` | 同 chat search,将群号转为 openConversationId |
@@ -2494,7 +2543,7 @@ Flags:
- `send-by-bot` 群聊传 `--conversation-id`,单聊传 `--users` 或 `--open-dingtalk-ids`,与群目标互斥且必选其一;机器人群聊 Markdown 引用回复必须同时传 `--reply ` 与 `--ref-sender `,单聊、图片和文件不支持引用回复;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingTalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --conversation-id --robot-code ` 将机器人邀请进群后再发送
- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回)
- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--content` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--content` 中包含 `@10`
-- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user `
+- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 用单个 `--role-id` 覆盖某人全部身份(不支持传空值清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user `
- 消息**换行符**(`send` / `send-by-webhook` 使用 `--content`,`send-by-bot` 使用 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `
`(硬换行)
- `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner`
- `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久)
diff --git a/skills/mono/references/products/event.md b/skills/mono/references/products/event.md
index f9b82c95..15178398 100644
--- a/skills/mono/references/products/event.md
+++ b/skills/mono/references/products/event.md
@@ -47,10 +47,11 @@
| `user_oa_approval_task_finished` | 审批任务已完成 | 无 |
| `user_oa_approval_task_redirected` | 审批任务已转交 | 无 |
| `user_oa_approval_instance_started` | 审批实例已发起 | 无 |
+| `user_oa_approval_instance_cc` | 审批实例到达抄送节点,发送给被抄送人 | 无 |
| `user_oa_approval_instance_terminated` | 审批实例已终止 | 无 |
| `user_oa_approval_instance_finished` | 审批实例完成,发送给审批单发起人 | 无 |
-只承认上表 22 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。六个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。
+只承认上表 23 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。七个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。
## Intent mapping
@@ -76,9 +77,10 @@
| "审批任务完成时通知我" | `event consume`,事件码 `user_oa_approval_task_finished`,参数 `--flatten -f ndjson` |
| "审批任务被转交时通知我" | `event consume`,事件码 `user_oa_approval_task_redirected`,参数 `--flatten -f ndjson` |
| "有审批单发起时通知我" | `event consume`,事件码 `user_oa_approval_instance_started`,参数 `--flatten -f ndjson` |
+| "有审批抄送给我时通知我" | `event consume`,事件码 `user_oa_approval_instance_cc`,参数 `--flatten -f ndjson` |
| "有审批单终止时通知我" | `event consume`,事件码 `user_oa_approval_instance_terminated`,参数 `--flatten -f ndjson` |
| "监听我发起的审批何时完成" / "审批实例完成时通知我" | `event consume`,事件码 `user_oa_approval_instance_finished`,参数 `--flatten -f ndjson` |
-| "同时监听全部已公开 OA 事件" | 一个 consume 放入六个 OA event key,不加目标或消息过滤参数 |
+| "同时监听全部已公开 OA 事件" | 一个 consume 放入七个 OA event key,不加目标或消息过滤参数 |
| "查看个人事件 schema" | `dws event schema --flatten` |
| "看个人事件订阅状态" | `dws event status --event ` |
| "停止这个个人事件订阅" | `dws event stop --dry-run`,确认后改用 `--yes` |
@@ -130,6 +132,7 @@ dws event schema user_oa_approval_task_created --flatten
dws event schema user_oa_approval_task_finished --flatten
dws event schema user_oa_approval_task_redirected --flatten
dws event schema user_oa_approval_instance_started --flatten
+dws event schema user_oa_approval_instance_cc --flatten
dws event schema user_oa_approval_instance_terminated --flatten
dws event schema user_oa_approval_instance_finished --flatten
```
@@ -157,6 +160,7 @@ dws event consume user_oa_approval_task_created --flatten -f ndjson
dws event consume user_oa_approval_task_finished --flatten -f ndjson
dws event consume user_oa_approval_task_redirected --flatten -f ndjson
dws event consume user_oa_approval_instance_started --flatten -f ndjson
+dws event consume user_oa_approval_instance_cc --flatten -f ndjson
dws event consume user_oa_approval_instance_terminated --flatten -f ndjson
dws event consume user_oa_approval_instance_finished --flatten -f ndjson
```
@@ -185,13 +189,14 @@ dws event consume \
user_oa_approval_task_finished \
user_oa_approval_task_redirected \
user_oa_approval_instance_started \
+ user_oa_approval_instance_cc \
user_oa_approval_instance_terminated \
user_oa_approval_instance_finished \
--flatten \
-f ndjson
```
-用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。六个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。
+用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。七个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。
上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user ` 替换为 `--open-dingtalk-id `,但两个参数不能同时使用。
@@ -216,7 +221,7 @@ dws event stop --all --yes
## 订阅创建失败与重试预算
-以下约束适用于上表全部 22 个公开个人事件(16 个 IM + 6 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
+以下约束适用于上表全部 23 个公开个人事件(16 个 IM + 7 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md
index 332d8649..97f32e3d 100644
--- a/skills/mono/references/products/oa.md
+++ b/skills/mono/references/products/oa.md
@@ -27,13 +27,14 @@ Flags:
--instance-id string 审批实例 ID (必填)
```
-### 审批附件授权与下载
+### 审批附件授权、上传与下载
先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令:
- 需要单个附件的临时下载链接:`attachment download-url`
- 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download`
- 需要在审批场景内批量预览附件:`attachment authorize-preview`
+- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload`
#### 获取审批附件临时下载链接
@@ -78,6 +79,21 @@ Flags:
该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。
+#### 上传本地文件为审批附件
+
+```
+Usage:
+ dws oa approval attachment upload [flags]
+Example:
+ dws oa approval attachment upload --file ./合同.pdf --format json
+Flags:
+ --file string 本地文件路径 (必填)
+ --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名)
+ --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)
+```
+
+该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。
+
### 同意审批
> **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。
@@ -292,7 +308,7 @@ Flags:
- **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。
- **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。
-- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。
+- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如计算公式、流水号、OCR 等),直接告知用户不支持通过 CLI 发起。**注意:附件控件 `DDAttachment` 已支持通过 CLI 提交**,先用 `dws oa approval attachment upload --file ` 获取字段再组装为 value 提交,不属于不支持类型。
- **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。**
- **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --query "<姓名>" --dimension name --format json` 转成 userId。
- **严禁在未得到用户确认前直接执行真实提单。**
@@ -411,7 +427,7 @@ Flags:
| 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 |
| 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 |
| 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** |
-| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 |
+| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **支持通过 CLI 提交**:先用 `dws oa approval attachment upload --file ` 获取 fileId/spaceId/fileName/fileSize/fileType,再组装为 DDAttachment value 提交 |
| 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) |
| 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 |
| 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 |
@@ -426,9 +442,19 @@ Flags:
- `CalculateField`(计算公式)— 由系统自动计算
- `SeqNumberField`(流水号)— 由系统自动生成
- `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互
-- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取
- **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作
+> **`DDAttachment`(附件控件)已支持通过 CLI 提交:** 采用两步流程——先用 `dws oa approval attachment upload --file ` 上传本地文件,返回 `fileId`、`spaceId`、`fileName`、`fileSize`、`fileType`;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。示例:
+>
+> ```bash
+> # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType
+> dws oa approval attachment upload --file ./a.pdf
+> # 2) 组装 value 后先向用户展示提单汇总;确认前不要追加 --yes
+> dws oa approval create-instance --process-code PROC-xxx \
+> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}'
+> # 用户确认模板、表单值、流程路径和人员后,才可在同一命令末尾追加 --yes
+> ```
+
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。
如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。
@@ -634,6 +660,30 @@ Flags:
--query string 查询关键词,可选
```
+### 以管理员身份查询审批实例列表
+
+> **IMPORTANT:** 需要当前用户具备 OA 审批管理员权限,否则查不到数据。只查个人维度的审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。
+
+```
+Usage:
+ dws oa approval list-by-admin [flags]
+Example:
+ dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20
+ dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --statuses RUNNING,COMPLETED --user-ids "userId1,userId2"
+ # 高级用法:传入完整 JSON(startTime/endTime 为 yyyy-MM-dd HH:mm:ss 格式字符串)
+ dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20}'
+Flags:
+ --process-code string 审批模板 processCode(简单模式必填)
+ --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式必填)
+ --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选)
+ --cursor string 分页游标,首次传 0(默认 "0")
+ --limit string 每页大小,最大 20(默认 "20")
+ --user-ids string 按发起人 userId 过滤,多个用逗号分隔(可选)
+ --statuses string 按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED)
+ --request string 完整请求体 JSON(高级模式,与简单模式互斥)
+```
+MCP 工具: `get_process_instances_by_admin`;参数封装在 `ProcessInstanceListQueryRequest`(processCode、startTime 必填,endTime、userIds、statuses、cursor、pageSize 可选;startTime/endTime 为 `yyyy-MM-dd HH:mm:ss` 格式字符串,简单模式的 ISO-8601 入参会自动转换)。processCode 可从 `list-forms` / `search-forms` 获取,返回的 processInstanceId 可用于 `detail` / `records` / `tasks`。
+
### 转交审批任务
```
Usage:
@@ -721,6 +771,7 @@ Flags:
用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment)
用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项)
用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment)
+用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit)
用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve`
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
用户说"撤回审批/取消审批" → `approval revoke`
@@ -743,6 +794,7 @@ Flags:
用户说"我发起的审批单" -> `approval list-submitted`
用户说"我审批/处理过的审批单" -> `approval list-executed`
用户说"抄送我的审批单" -> `approval list-cc`
+用户说"以管理员身份查审批/全员审批单/统计某个模板的审批单/企业内审批记录" → `approval list-by-admin`(需 --process-code 和 --start,且当前用户需具备 OA 管理员权限)
用户说"转交审批/转交任务" → `approval redirect-task`(需 --task-id 和 --to-actioner-id)
用户说"评论审批/添加评论/写评论" → `approval oa-comments`(需 --instance-id 和 --content)
用户说"抄送审批/添加抄送人" → `approval oa-cc-noticer`(需 --instance-id 和 --users)
@@ -792,6 +844,9 @@ dws oa approval list-submitted --limit --page --query
# 11. 抄送我的审批单
dws oa approval list-cc --limit --page --query 关键词 --format json
+# 11b. 以管理员身份跨用户查询某模板的审批实例列表(需 OA 管理员权限)
+dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 --format json
+
# 12. 转交审批任务(taskId 来自 tasks,toActionerId 来自 contact user search)
dws oa approval redirect-task --task-id --to-actioner-id --format json
dws oa approval redirect-task --task-id --to-actioner-id --remark "请帮忙处理" --format json
@@ -834,6 +889,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1
| `forecast-process` | `workflowActivityRuleVOs`(`activityId`, `targetSelect`, `activityActioners`, `workflowActor`) | ① 向用户展示流程走向和各节点处理人;② `targetSelect: true` 的节点需用户自选审批人,`workflowActor.actorKey` 作为 `targetSelectActioners` 的 `actionerKey` 传入 create-instance |
| `search-forms` → `form-schema` → `forecast-process` | `processCode` → 字段定义 → 流程走向 + 自选节点 | create-instance 的完整上下文:表单值 + 流程路径 + targetSelectActioners |
| `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 |
+| `list-by-admin` | `processInstanceId` | detail / records / tasks 的 --instance-id |
## 注意事项
@@ -844,6 +900,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1
- `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹
- `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取。当 `list-forms` 返回 `processCodeList` 为空(`totalCount -1`)时,用 `search-forms --query <表单名>`(如 `--query 报销`)按名称精准拿 `processCode` 更稳
- `list-initiated` 的 `--start` / `--end` 区间有后端上限(约 120 天)。超过上限会返回误导性的 `business_error: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询
+- `list-by-admin` 需要当前用户具备 OA 审批管理员权限,否则查不到数据;只查个人维度审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。高级模式 `--request` 中 `startTime`/`endTime` 为 `yyyy-MM-dd HH:mm:ss` 格式字符串(不再接受毫秒时间戳);`pageSize` 上限为 20,超过会报错(简单模式为 `--limit`)
- `form-schema` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取;返回的 `content` 字段为 JSON 字符串,需解析后查看表单组件(items)定义。
- `create-instance` 发起前**必须先阅读** [oa-form-components.md](oa/oa-form-components.md)(控件值格式)和 [oa-process-nodes.md](oa/oa-process-nodes.md)(流程节点规则),再调用 `form-schema` 获取表单字段定义,确保 `--form-values` 中的 key 与控件 label 完全一致。
diff --git a/skills/mono/references/products/oa/oa-form-components.md b/skills/mono/references/products/oa/oa-form-components.md
index 5e795ce4..7da45277 100644
--- a/skills/mono/references/products/oa/oa-form-components.md
+++ b/skills/mono/references/products/oa/oa-form-components.md
@@ -239,14 +239,14 @@
### DDAttachment(附件控件)
-> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。
+> **[支持] 已支持通过 CLI 提交附件控件。** 采用两步流程:先用 `dws oa approval attachment upload --file ` 上传本地文件,获取 spaceId、fileName、fileSize、fileType、fileId;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。
| 属性 | 说明 |
|------|------|
| `componentName` | `DDAttachment` |
| value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId |
| 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` |
-| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 |
+| 约束 | **支持通过 CLI 提交**;先用 `dws oa approval attachment upload --file ` 获取 spaceId、fileName、fileSize、fileType、fileId,再组装为 value 随 `create-instance` 提交 |
### StarRatingField(评分控件)
@@ -328,7 +328,6 @@
| 流水号 | `SeqNumberField` | 由系统自动生成 |
| OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 |
| OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 |
-| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 |
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。
diff --git a/skills/mono/references/products/recruit.md b/skills/mono/references/products/recruit.md
new file mode 100644
index 00000000..93502d4c
--- /dev/null
+++ b/skills/mono/references/products/recruit.md
@@ -0,0 +1,39 @@
+# 钉钉招聘
+
+`dws recruit` 提供招聘职位列表、职位详情和职位创建能力。组织、业务标识和当前
+操作人由登录身份及 MCP Connector 注入,不要要求用户提供或猜测身份字段。
+
+## 查询
+
+```bash
+dws recruit job list --keyword "Java" --status open --size 20 --format json
+dws recruit job get --job-id JOB_ID --format json
+```
+
+列表状态支持 `draft`、`open`、`invalid`、`closed`。首页不传 `--cursor`;
+`hasMore=true` 时把响应 `nextCursor` 回填。查询详情前必须从真实列表结果取得
+`jobId`。
+
+## 创建
+
+创建是非幂等写操作。先用 `--dry-run` 检查;实际执行必须经过用户明确确认,并
+交由 CLI 的确认流程处理:
+
+```bash
+dws recruit job create --from ./job.json --dry-run --format json
+dws recruit job create --from ./job.json --format json
+```
+
+JSON 必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`、
+`creatorUserId`。
+`jobNature` 当前固定为 `FULL-TIME`;学历枚举为 1小学、2初中、3高中、4中专、
+5大专、6本科、7硕士、8博士、9其他。`minSalary`、`maxSalary` 可选;两者同时
+提供时最低薪资不得高于最高薪资。
+
+`creatorUserId` 是当前创建人 userId,必须来自同一 profile 下的真实通讯录结果。
+`ownerUserIds` 可选;提供时是负责人 userId 的 JSON 字符串数组,也必须使用真实
+通讯录结果。禁止猜测任何 userId。`extData.headCount` 范围 1–999,薪资月份范围
+12–24;提供地址时必须包含地点名、详细地址和可信的经纬度。
+
+文件只写职位对象,不要添加 MCP 信封字段 `atsAddJobParam`、`corpId`、`bizCode`
+或 `opUserId`;这些字段由 CLI 或 Connector 处理。
diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md
index 4276456d..6b45e267 100644
--- a/skills/mono/references/products/sheet.md
+++ b/skills/mono/references/products/sheet.md
@@ -54,6 +54,7 @@
| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 |
| 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 |
| 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 |
+| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 |
## 触发必读
@@ -63,6 +64,7 @@
- 涉及合并、冻结、工作表增删改、最后非空边界:先读 [sheet-workbook](./sheet/sheet-workbook.md)。
- 涉及行列插删、隐藏、尺寸、移动、分组:先读 [sheet-dimension-operations](./sheet/sheet-dimension-operations.md)。
- 涉及多个写操作一次提交:先读 [sheet-batch-operations](./sheet/sheet-batch-operations.md)。
+- 涉及 revision、changeset、编辑审计、语义变更、撤销或状态重置:先读 [sheet-revision-changeset](./sheet/sheet-revision-changeset.md)。
## Reference 索引
@@ -82,13 +84,14 @@
| [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` |
| [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` |
| [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` |
-| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` |
+| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令会自动等待结果,禁止 Agent 侧重复调用。命令:`export` |
| [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` |
| [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` |
| [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` |
| [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` |
| sheet-template | 表格模板管理。当用户说“用模板创建表格”、“搜索模板”、“模板列表”时使用。命令:`template list`/`template search`/`template apply` |
-| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;版本号从 `version list` 获取。命令:`version save`/`version list`/`version revert` |
+| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本或精确 revision/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;通常从 `version list` 选目标,用户明确要求时也可使用同一工作簿中已确认且仍可恢复的 revision。命令:`version save`/`version list`/`version revert` |
+| [sheet-revision-changeset](./sheet/sheet-revision-changeset.md) | 工作簿 revision 与语义化前向 changeset。当用户说“当前 revision/两个版本之间改了什么/复核编辑/回滚到了哪里”时使用。命令:`revision-get`/`changeset-get` |
## 全局硬约束
@@ -99,12 +102,12 @@
5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn`
6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"`
7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV
-8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段
+8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。不要用 `hyperlink:null`,该写法不能可靠表达清除
9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段
10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension`
11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put`
12. **单元格图片用 `write-image`**(`range update` 不支持图片参数)
-13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调
+13. **`export` / `import create` 禁止自行轮询**(命令会自动等待结果,最长约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调
14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@`
16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update`
@@ -115,9 +118,10 @@
- **公式**:支持通过 `range update` 写入、通过 `range read --value-render-option formula` 回读公式文本、通过 `formula-verify` 按错误类型聚合扫描、通过 `raw_value` / `formatted_value` 回读计算结果。只有 `formula-verify` 返回 `status=success`、`hasMore=false`、`totalErrors=0` 时,才能声称本次目标范围未发现公式错误;这仍不等价于业务数值一定正确。
- **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。
- **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。
-- **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。
+- **历史版本与 revision**:`version save`/`list` 管理选定的历史版本;`version revert` 通常回滚到列表项,也可在用户明确要求时回滚到同一工作簿中已确认且仍可恢复的精确 revision。列表项 `version` 可作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision;禁止猜测未确认的 revision。
- **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。
-- **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。
+- **changeset 边界**:返回的是 `EDIT`/`UNDO`/`STATE_RESET` 事件及提交时的前向语义 change,不是统一的单元格 `oldValue/newValue` diff,也不保证代表当前最终状态;需要最终值必须额外回读。
+- **未暴露或未确认能力**:迷你图等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。
## URL 粘贴场景
@@ -144,7 +148,7 @@ dws sheet import create --file ./report.xls --folder-token --name
dws sheet import create --file ./data.xls --workspace
```
- 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格
-- CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试**
+- 命令会自动上传、确认并等待结果(最长约 5 分钟),**禁止 Agent 侧再实现轮询或重试**
- 导入结果返回 `documentUrl` / `nodeId`,`nodeId` 可直接作为后续 `sheet` 命令的 `--node`
- **禁止用 `dws drive upload --convert` 代替**:drive upload 只上传文件、不在表格导入闭环内,产出不保证为可编辑在线电子表格
diff --git a/skills/mono/references/products/sheet/sheet-comment.md b/skills/mono/references/products/sheet/sheet-comment.md
index 13a614f4..b8d58280 100644
--- a/skills/mono/references/products/sheet/sheet-comment.md
+++ b/skills/mono/references/products/sheet/sheet-comment.md
@@ -12,7 +12,7 @@
- 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。
- 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。
- `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。
-- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。
+- Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不自行构造评论标识。
---
diff --git a/skills/mono/references/products/sheet/sheet-dropdown.md b/skills/mono/references/products/sheet/sheet-dropdown.md
index 05b5f0c0..07c32d65 100644
--- a/skills/mono/references/products/sheet/sheet-dropdown.md
+++ b/skills/mono/references/products/sheet/sheet-dropdown.md
@@ -66,7 +66,7 @@ Flags:
查询指定范围内的下拉列表配置信息。
- **用途**:查看单元格已设置的下拉列表选项和配置。
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
-- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
+- **返回**:`dataValidations` 按相同配置分组。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。
### 删除下拉列表
```
@@ -99,5 +99,5 @@ Flags:
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉
- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持
-- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
+- `get-dropdown` 查询指定范围内的下拉配置,并按相同配置分组。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
diff --git a/skills/mono/references/products/sheet/sheet-export.md b/skills/mono/references/products/sheet/sheet-export.md
index ba0880c6..8c067aff 100644
--- a/skills/mono/references/products/sheet/sheet-export.md
+++ b/skills/mono/references/products/sheet/sheet-export.md
@@ -5,11 +5,11 @@
### 导出
用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel":
-- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载)
+- 导出表格 → `export`(单命令会自动等待完成,并可选下载)
- 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl)
- 需要落盘到本地 → `dws sheet export --node --output `,命令自动下载 xlsx
-- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性)
-- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟)
+- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出;必须使用 `export`,才能保留格式、合并和公式等属性
+- 禁止在 AI Agent 侧实现轮询或重试;命令会自动等待结果,最长约 5 分钟
用户说"导出 CSV/存成 csv/导出这个工作表为 csv":
- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令)
@@ -38,26 +38,14 @@ Flags:
--output string 本地保存路径(可选,支持文件路径或目录)
```
-将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。
-
-**内部流程**:
-1. 调 `submit_export_job` 获取 `jobId`
-2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时
-3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件
-
-**内置轮询策略(CLI 内实现,无需关心)**:
-- 第 1~5 次:每次间隔 2 秒
-- 第 6~10 次:每次间隔 5 秒
-- 第 11~20 次:每次间隔 10 秒
-- 第 21~30 次:每次间隔 15 秒
-- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误
+将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令会自动等待导出完成,并在指定 `--output` 时保存到本地。AI Agent 无需拆分步骤或自行轮询;最长等待约 5 分钟,超时后按命令错误处理。
**命令返回**:
- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: `
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下
-**失败处理(命令内部已处理,Agent 仅需转述)**:
+**失败处理**:
- 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
@@ -111,7 +99,7 @@ dws sheet export-csv --node --sheet-id --allow-truncated --
```bash
# ── 工作流 12: 导出表格为 xlsx(单命令一站式)──
-# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl)
+# 场景 A:仅获取下载链接(命令自动等待完成并返回 downloadUrl)
dws sheet export --node --format json
# 传入 URL 也可:
# dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" --format json
@@ -122,7 +110,7 @@ dws sheet export --node --output ./report.xlsx
# 场景 C:下载到目录,自动按链接推断文件名
dws sheet export --node --output ./
-# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。
+# 禁止在 Agent 侧实现任何轮询或重试;命令会自动等待结果。
# 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。
```
@@ -130,18 +118,18 @@ dws sheet export --node --output ./
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
-| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 |
+| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地;不要再调用其他 export 相关命令 |
| `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 |
| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: `(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 |
## 注意事项
- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
-- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令
+- ★ `export` 为单命令一站式,会自动等待结果并可选下载;**Agent 不得自行轮询或重试**,命令返回成功后不再调用其他 export 相关命令
- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可
- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试
- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载
- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存
-- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性)
+- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出;`export` 会保留格式、合并、公式等属性
- `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export`
- ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated`
diff --git a/skills/mono/references/products/sheet/sheet-filter.md b/skills/mono/references/products/sheet/sheet-filter.md
index 0e65607b..90bfe695 100644
--- a/skills/mono/references/products/sheet/sheet-filter.md
+++ b/skills/mono/references/products/sheet/sheet-filter.md
@@ -173,7 +173,7 @@ Flags:
- `filter update` 前置:工作表必须已创建筛选
- `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等)
- `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选
-- ★ **筛选操作规范**(参照飞书 core-operations):
+- ★ **筛选操作规范**:
- 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替
- 创建/更新筛选后**必须** `filter get` 回读验证配置正确
- 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作
diff --git a/skills/mono/references/products/sheet/sheet-import.md b/skills/mono/references/products/sheet/sheet-import.md
index d50b27b4..b301e4bf 100644
--- a/skills/mono/references/products/sheet/sheet-import.md
+++ b/skills/mono/references/products/sheet/sheet-import.md
@@ -6,7 +6,7 @@
- 支持本地 `xlsx` / `xls`,文件上限 20MB
- `--folder-token` 与 `--workspace` 至少提供一个
-- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试
+- `import create` 会自动上传文件、确认转换并等待结果,Agent 不要自行拆分或重试
- `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令
## 命令
diff --git a/skills/mono/references/products/sheet/sheet-media-image.md b/skills/mono/references/products/sheet/sheet-media-image.md
index 06aba4b7..d3e236f0 100644
--- a/skills/mono/references/products/sheet/sheet-media-image.md
+++ b/skills/mono/references/products/sheet/sheet-media-image.md
@@ -228,8 +228,8 @@ dws sheet write-image --node --sheet-id --range B2:B2 --fil
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
-- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作
-- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作
+- `media-upload` 会自动完成图片上传并返回后续命令需要的资源信息,无需手动拆分步骤
+- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
diff --git a/skills/mono/references/products/sheet/sheet-pivot-table.md b/skills/mono/references/products/sheet/sheet-pivot-table.md
index 6b8f93d2..2e451972 100644
--- a/skills/mono/references/products/sheet/sheet-pivot-table.md
+++ b/skills/mono/references/products/sheet/sheet-pivot-table.md
@@ -191,7 +191,7 @@ Flags:
- 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500`
- 必须包含表头行(通常从第 1 行开始)
- sheet 名称用单引号包裹(含空格或特殊字符时必须)
-- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构
+- `source` 直接使用上述 A1 表示法,不要改写成其他结构
## 高级功能示例
@@ -210,7 +210,7 @@ dws sheet pivot-table create --node \
}'
```
-> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。
+> `collapse` / `show_data_as` 为可选字段;仅使用命令文档列出的取值,并按命令返回处理无效配置。
## 核心工作流
diff --git a/skills/mono/references/products/sheet/sheet-read-data.md b/skills/mono/references/products/sheet/sheet-read-data.md
index f8008d0e..89fef5a2 100644
--- a/skills/mono/references/products/sheet/sheet-read-data.md
+++ b/skills/mono/references/products/sheet/sheet-read-data.md
@@ -54,7 +54,7 @@ Flags:
- `rowIndices` — 行号映射数组(如 `[1,2,3]`)
- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完
- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现
-- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围
+- `resolvedRange` — 仅在未传 `--range` 时返回,表示本次读取所对应的完整目标范围
- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标
`csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node --sheet-id --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。
@@ -161,7 +161,7 @@ Flags:
| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 |
| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 |
| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 |
-| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` |
+| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 来源引用是否有效;无效引用仍返回配置,但不返回 `sourceRange` |
| `checkbox` | `checked: boolean` | 当前勾选状态 |
SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。
@@ -270,6 +270,7 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f
- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读
- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
+- ★ 保护区域权限:`csv-get`、`table-get`、`range read`、`formula-verify` 等命令会检查本次实际读取范围。若返回权限错误,说明当前身份无法确认具有该范围的完整读取权限;不要解释为空数据或反复重试,应申请对目标内容的直接访问权限或请管理员执行。仅当任务本来就不需要受保护区域时,才可缩小 `--range` 避开无关区域,禁止拆分范围绕过权限。
- `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read`
- ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度
- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表
diff --git a/skills/mono/references/products/sheet/sheet-revision-changeset.md b/skills/mono/references/products/sheet/sheet-revision-changeset.md
new file mode 100644
index 00000000..13e5b662
--- /dev/null
+++ b/skills/mono/references/products/sheet/sheet-revision-changeset.md
@@ -0,0 +1,306 @@
+# 工作簿 revision 与 changeset
+
+## 何时使用
+
+当用户要知道在线电子表格当前 revision,或要复核两个 revision 之间提交了哪些变更、是否发生撤销或整表状态替换时使用。
+
+- 当前 revision → `dws sheet revision-get`
+- revision 区间内的变更 → `dws sheet changeset-get`
+- 可保存、展示或回滚的历史版本 → [sheet-version](./sheet-version.md)
+- 当前单元格值、公式或工作簿结构 → 使用对应 read/list/get 命令
+
+两条命令都是工作簿级能力,只接受 `--node`,不接受 `--sheet-id`。`--node` 可传在线电子表格文档 ID 或完整 URL;`spreadsheetv2` URL 必须原样传入。
+
+revision 表示工作簿的编辑进度;历史版本是选定的保存或回滚点。`version list` 返回的 `version` 可以作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision。
+
+当用户明确要求恢复到某个精确 revision 时,可以把已确认属于同一工作簿的 revision 传给 `version revert --version`,即使它没有出现在 `version list` 中。默认仍应从版本列表选择稳定的历史版本;未列入列表的 revision 只有在服务端仍可恢复时才会成功,禁止猜测或自动改用相邻 revision。具体安全流程见 [sheet-version](./sheet-version.md)。
+
+## 获取当前 revision
+
+```bash
+dws sheet revision-get --node --format json
+```
+
+成功时从统一输出的 `data.revision` 读取当前 revision:
+
+```json
+{
+ "ok": true,
+ "outcome": "success",
+ "data": {
+ "success": true,
+ "logId": "trace-id",
+ "revision": 142
+ }
+}
+```
+
+- `revision=0` 是合法的空工作簿基线。
+- revision 不是工作表数量,也不是历史版本列表序号。
+- `logId` 仅用于失败反馈和问题排查,不是 revision。
+
+## 获取 revision 区间 changeset
+
+```bash
+dws sheet changeset-get \
+ --node \
+ --start-revision 120 \
+ --end-revision 140 \
+ --format json
+```
+
+也可以省略 `--end-revision`,查询到本次请求开始时的最新 revision:
+
+```bash
+dws sheet changeset-get \
+ --node \
+ --start-revision 120 \
+ --format json
+```
+
+参数规则:
+
+- `--start-revision` 必填,必须为非负整数;`0` 合法。
+- `--end-revision` 可选;提供时必须大于或等于 start。
+- 查询区间固定为 `(startRevision, endRevision]`:不包含 start,包含 end。
+- `startRevision == endRevision` 合法,返回空 `changesets`。
+- 单次最多查询 20 个 revision,即 `endRevision - startRevision <= 20`;更大区间必须连续分段查询。
+- 省略 end 时,本次响应会固定一个 `endRevision`;查询期间产生的新 revision 不进入本次结果。
+- 无法完整取得所请求区间时整次失败,不会把缺失的 revision 当作成功结果。
+
+成功后直接读取统一输出中的 `data.changesets` 数组。DWS 已把它转换为可遍历的 JSON 对象,不需要额外解析。
+
+## 先看区间摘要
+
+`data.summary` 用于快速判断本次结果是否完整以及哪些工作表受到影响:
+
+| 字段 | 含义 |
+|------|------|
+| `changeCount` | `changes[]` 中变更对象的总数 |
+| `completeChangeCount` | 详情完整的变更数量 |
+| `partialChangeCount` | 只返回部分可信详情的变更数量 |
+| `unsupportedChangeCount` | 当前无法解释的变更数量 |
+| `containsStateReset` | 区间内是否发生整表状态替换 |
+| `containsIncompleteChanges` | 是否存在不完整或无法解释的详情 |
+| `affectedSheets` | 受影响工作表及其 A1 范围摘要 |
+
+`affectedSheets[]` 常用字段:
+
+| 字段 | 含义 |
+|------|------|
+| `sheetId` | 工作表 ID |
+| `sheetName` | 可用时的工作表名称;缺失时不要从 ID 猜测 |
+| `ranges` | 去重后的 1-based A1 范围;工作表级变更可为空数组,整行/整列可表示为 `1:3` / `A:C` |
+
+摘要用于定位,不是完整变更副本。要回答改了什么,仍需遍历 `changesets[].changes[]`。
+
+## 读取 `changesets[]`
+
+每个 changeset 对应一个 revision:
+
+| 字段 | 含义 | 使用规则 |
+|------|------|----------|
+| `revision` | 该事件对应的 revision | 按升序解释 |
+| `createTime` | 事件创建时间 | 时间仅用于说明,顺序以 revision 为准 |
+| `isSelfEdit` | 是否由当前请求用户提交 | `false` 不能用于识别具体编辑者,也不能判断是否由人工或自动化产生 |
+| `eventType` | `EDIT`、`UNDO` 或 `STATE_RESET` | 先按事件类型分流 |
+| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | 决定能否把详情当作完整描述 |
+| `changes` | 本 revision 的变更数组 | `STATE_RESET` 时为空数组 |
+| `reset` | 整表状态替换信息 | 只在 `STATE_RESET` 时读取 |
+
+事件类型:
+
+- `EDIT`:一次普通编辑提交产生的效果。
+- `UNDO`:一次撤销提交产生的效果。只描述这次撤销实际改变了什么,不要反推被撤销操作的完整原貌。
+- `STATE_RESET`:工作簿整体状态被替换。不要跨过该事件拼接推导完整状态;需要重新读取工作簿。
+
+changeset 描述的是该 revision 提交的效果,不是统一的变更前/变更后快照,也不保证仍是当前最终状态。
+
+## 读取 `reset`
+
+| 字段 | 含义 |
+|------|------|
+| `type` | 状态替换类型:`ROLLBACK`、`OVERWRITE`、`UPGRADE`、`TEMPLATE`、`PRETTIFY` 或 `UNKNOWN_RESET` |
+| `targetRevision` | 可确认时的目标 revision;`0` 是合法空基线 |
+| `targetStatus` | `KNOWN` 表示目标已返回;`NOT_APPLICABLE` 表示该事件没有目标 revision;`UNAVAILABLE` 表示目标无法确认 |
+
+当 `targetStatus=UNAVAILABLE` 时,禁止把目标猜成 `revision-1`。即使目标 revision 已知,也要通过读取命令确认需要的当前内容。
+
+回滚完成后,可用回滚前后的 revision 查询 changeset:若返回 `STATE_RESET`、`reset.type=ROLLBACK` 且 `reset.targetRevision` 等于请求目标,表示回滚事件指向了该 revision;工作簿内容是否符合预期仍需独立回读。
+
+## 读取 `changes[]`
+
+每个 change 都按以下顺序读取:
+
+1. `type`:发生了哪类变更。
+2. `targets`:变更作用在哪个工作簿、工作表或范围。
+3. `detailsStatus`:详情是否完整。
+4. `details`:本次变更的可用详情。
+5. `omissions`:哪些详情未能提供。
+
+公共字段:
+
+| 字段 | 含义 |
+|------|------|
+| `type` | 语义变更类型,见下方字典 |
+| `targets` | 工作簿、工作表或 A1 范围定位数组 |
+| `details` | 与 `type` 对应的变更详情;无详情时为空对象 |
+| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` |
+| `omissions` | 未提供详情的原因;没有省略时为空数组 |
+
+`detailsStatus` 的处理规则:
+
+- `COMPLETE`:已完整解释本类型可提供的本次变更详情,但不代表包含旧值或当前值。
+- `PARTIAL`:已返回字段可信,同时存在缺失;回答时要明确“仅确认到这些内容”。
+- `UNAVAILABLE`:不能可靠说明具体详情;只报告事件、类型和可信位置,不猜内容。
+
+`omissions[].fields` 可指出受影响的详情字段。`omissions[].code` 只用于判断存在何种缺口;面向用户时翻译成“部分详情未提供”“公式文本不可用”“位置无法确认”等自然语言,不需要展示内部原因码。
+
+## 读取 `targets[]`
+
+| 字段 | 含义 |
+|------|------|
+| `scope` | `WORKBOOK`、`SHEET` 或 `RANGE` |
+| `sheetId` | 工作表 ID |
+| `sheetName` | 可用时的工作表名称 |
+| `sheetNameSource` | 名称是变更时名称、当前名称或无法确认 |
+| `a1Range` | 1-based A1 范围 |
+| `role` | `AFFECTED`、`SOURCE` 或 `DESTINATION`;缺失时按 `AFFECTED` 处理 |
+
+- `SOURCE` 是读取或复制来源,不表示该位置被修改。
+- `DESTINATION` 是写入目标,应算作受影响位置。
+- `WORKBOOK` 级 target 通常没有工作表或 A1 范围,不要补造。
+- `sheetName` 缺失或来源无法确认时,优先使用 `sheetId` 定位并按需调用 `sheet list`。
+
+## 变更类型字典
+
+### 行列与工作表
+
+| type | 含义 | 常用 `details` |
+|------|------|----------------|
+| `ROWS_INSERTED` / `COLUMNS_INSERTED` | 插入行或列 | `startNumber`、`count`、`styleSource?` |
+| `ROWS_DELETED` / `COLUMNS_DELETED` | 删除行或列 | `startNumber`、`count` |
+| `ROWS_UPDATED` / `COLUMNS_UPDATED` | 更新行列属性 | `startNumber`、`count`、`properties?`、`relativeChanges?`、`replace?` |
+| `SHEET_CREATED` | 创建工作表 | `position?`、`properties?`、`copiedFromSheetId?` |
+| `SHEET_DELETED` | 删除工作表 | 通常由 target 定位 |
+| `SHEET_UPDATED` | 更新工作表名称、位置或属性 | `position?`、`properties?` |
+| `CUSTOM_TAB_ADDED` / `CUSTOM_TAB_DELETED` / `CUSTOM_TAB_UPDATED` | 新增、删除或更新自定义 Tab | `tabId`、`position?`、`properties?` |
+
+`startNumber` 和 `position` 为 1-based。行列 `properties` 常见字段包括 `size`、`hidden`、`sticky`、`cellType`;工作表属性常见字段包括 `name`、`hidden`、`tabColor`、冻结行列数和默认行列尺寸。只解释实际返回的字段。
+
+### 单元格与范围
+
+| type | 含义 | 常用 `details` |
+|------|------|----------------|
+| `CELLS_INSERTED` / `CELLS_DELETED` | 在 target 内插入或删除单元格并平移 | `axis` |
+| `RANGE_PASTED` | 将来源内容粘贴到目标范围 | `pasteMode`、`isCut`、`step?`、`includedParts?`、`contentPattern?` |
+| `RANGE_AUTOFILLED` | 自动填充目标范围 | `fillMode`、`copyStyle`、`step?` |
+| `RANGE_CLEARED` | 清除范围内指定内容 | `clearParts?`、`preservedCellTypes?` |
+| `RANGE_CONTENT_SET` | 对整个范围写入相同内容或公式 | `cell?`、`replaceMode?` |
+| `RANGE_BORDER_SET` | 设置范围边框 | `border?`、`borderSides?` |
+| `RANGE_STYLE_SET` | 设置范围样式 | `style`、`replace?` |
+| `RANGE_TAG_SET` | 设置或清除范围标签 | `tag?`、`replace?` |
+| `RANGE_SORTED` | 对范围排序 | `sortByRange?`、`beforeOrder?`、`afterOrder?`、`indexesAreRelative` |
+| `CELLS_CONTENT_SET` | 按相对位置分别修改内容 | `relativeChanges`、`replaceMode?` |
+| `CELLS_STYLE_SET` | 按相对位置分别修改样式 | `relativeChanges?`、`styles?`、`styleMode?`、`replace?` |
+| `CELLS_TAG_SET` | 按相对位置分别修改标签 | `relativeChanges`、`replace?` |
+
+粘贴和自动填充的 `SOURCE` target 是来源,`DESTINATION` target 是写入位置。自动填充的 `DESTINATION` 可能是包含 `SOURCE` 的完整填充跨度;重叠部分不表示来源值发生了变化。描述新增填充区域时使用 `DESTINATION` 减去 `SOURCE`,核对最终结果时仍回读完整 `DESTINATION`。`step.rows` / `step.columns` 表示重复模式的大小。`contentPattern.cells[]` 使用 `rowOffset` / `columnOffset` 表示相对模式左上角的位置,并可返回写入的 `value`、`formula`、`cellType` 或 `link`。
+
+`RANGE_SORTED.beforeOrder` / `afterOrder` 只列实际换位的行或列,未移动的位置会省略;数组短于 target 的行列数不代表详情缺失。`indexesAreRelative` 决定这些索引是相对 target 还是工作表绝对索引。
+
+`relativeChanges[]` 中的 `rowOffset` / `columnOffset` 都是相对 target 左上角的 0-based 偏移,不是工作表绝对行列号;必须与 target 起点相加后再换算成 A1。
+
+### 分组、数据验证和结构对象
+
+| type | 含义 | 常用 `details` |
+|------|------|----------------|
+| `DIMENSION_GROUP_ADDED` / `DIMENSION_GROUP_REMOVED` / `DIMENSION_GROUP_UPDATED` | 新增、删除或更新行列分组 | `axis`、`startNumber`、`count`、`depth?`、`collapsed?` |
+| `DATA_VALIDATION_SET` | 设置数据验证规则 | `ruleId?`、`dataValidation` |
+| `DATA_VALIDATION_CLEARED` | 清除数据验证规则 | `ruleId?` |
+| `CELLS_MERGED` / `CELLS_UNMERGED` | 合并或取消合并单元格 | `featureId?`、`effectless?` |
+| `NAMED_RANGE_SET` | 设置命名范围 | `name`、`baseSheetId?`、`formula?` |
+| `NAMED_RANGE_CLEARED` | 清除命名范围 | `name`、`baseSheetId?` |
+| `FEATURE_ADDED` / `FEATURE_DELETED` / `FEATURE_UPDATED` | 新增、删除或更新筛选、评论、透视表、保护范围等扩展对象 | `featureType`、`featureId?` |
+
+扩展对象通常只返回类型、标识和可信 target,不保证包含完整配置。用户询问当前配置时,调用该对象对应的 list/get 命令。
+
+`dataValidation` 常用字段:
+
+| 字段 | 含义 |
+|------|------|
+| `type` | 验证规则类型 |
+| `sourceType` | 下拉来源:`inline` 或 `sourceRange` |
+| `options` | 内联下拉选项数组,每项包含 `value` 和可选 `color` |
+| `sourceRange` | 已确认的来源工作表与 A1 范围 |
+| `sourceRangeStatus` | `RESOLVED`、`UNRESOLVED` 或 `INVALID` |
+| `sourceRangeExpression` | 可读的来源表达式 |
+| `enableMultiSelect` | 是否允许多选 |
+| `criteria` | 非下拉规则的运算符和条件值 |
+| `settings` | 是否允许空值、是否显示提示、错误提示等行为设置 |
+
+只有 `sourceRangeStatus=RESOLVED` 时,才能把 `sourceRange` 当作已确认范围;否则说明来源未解析或无效,并在需要时回读当前规则。
+
+### 工作簿设置与其他变更
+
+| type | 含义 | 常用 `details` |
+|------|------|----------------|
+| `WORKBOOK_SETTING_UPDATED` | 更新工作簿设置 | `setting`、`changes` |
+| `EXTERNAL_REFERENCES_REPLACED` | 替换外部引用 | `referenceCount` |
+| `UNSUPPORTED_CHANGE` | 存在当前无法可靠解释的变更 | `category?` |
+
+`WORKBOOK_SETTING_UPDATED` 的 `setting` 可表示计算设置或背景设置;只解释 `changes` 中实际返回的字段。遇到 `UNSUPPORTED_CHANGE` 时,只说明存在未能解释的变更,不根据相邻事件猜测其内容。
+
+## 常用详情值
+
+### 单元格内容
+
+`cell` 常见字段:
+
+- `value`:带类型的值;`kind` 可为 `STRING`、`NUMBER`、`BOOLEAN`、`NULL` 或 `MULTI`,分别读取对应的 `stringValue`、`numberValue`、`booleanValue` 或 `values`。
+- `formula`:本次写入的可读公式。
+- `formulaCleared=true`:本次清除了公式。
+- `cellType`:`general`、`checkbox` 或 `select`;select 可带 `options`。
+- `link`:可用时返回工作簿内范围链接及目标 `sheetId` / `a1Range`。
+
+### 清除、null 与字段缺失
+
+- 对象整体为 `{ "cleared": true }`:本次清除了该对象。
+- `formulaCleared=true`:本次清除了公式。
+- `styleCleared=true`:本次清除了该相对位置的样式。
+- 对象内某个已知属性为 `null`:本次清除该属性或恢复默认。
+- 字段缺失:本次未提供或不适用;若状态不是 `COMPLETE`,结合 `omissions` 判断缺口。
+
+不要把整体清除、单属性清除和字段缺失混为一谈,也不要补造清除前内容。
+
+## Agent 解读顺序
+
+1. 检查统一输出 `ok=true`、`outcome=success` 和 `data.success=true`。
+2. 直接遍历 `data.changesets`,不要把它当作字符串。
+3. 用 `startRevision` / `endRevision` 准确表述 `(start,end]`。
+4. 先看 `summary.containsStateReset` 和 `summary.containsIncompleteChanges`。
+5. 每个事件先看 `eventType`;`STATE_RESET` 读 `reset`,`EDIT` / `UNDO` 再读 `changes`。
+6. 每条 change 按 `type -> targets -> detailsStatus -> details -> omissions` 解读。
+7. `PARTIAL` 只使用已返回字段并说明缺口;`UNAVAILABLE` 和 `UNSUPPORTED_CHANGE` 不猜。
+8. `isSelfEdit=false` 不用于归因具体用户、系统或自动化。
+9. 用户问“现在是什么”时,按 target 使用对应 read/list/get 命令回读。
+10. 看到可用 revision 不代表已获准回滚;只有用户明确要求并确认覆盖风险后,才按 [sheet-version](./sheet-version.md) 执行 `version revert`。
+
+## 最终状态回读
+
+changeset 用于解释“某个 revision 提交了什么”,不是“当前最终是什么”。
+
+- 值和公式:用 `csv-get`、`range read` 或 `table-get` 回读目标范围。
+- 行列、分组、隐藏和尺寸:用 `sheet info --include ...` 或对应结构读取命令。
+- 数据验证、筛选、评论、透视表等对象:用对应 list/get 命令回读。
+- 粘贴或自动填充:优先回读 `DESTINATION`,不要把 `SOURCE` 当作已修改范围。
+- `STATE_RESET`:重新读取工作簿当前状态。
+- 分段查询时,只要任一段失败或包含不完整变更,就不能声称整个跨度已完整解释。
+
+## 常见失败处理
+
+- revision 非法或超出范围:先调用 `revision-get`,再修正或分段查询。
+- 区间内容不可用:说明无法获得完整 changeset,不要解释为空 changeset。
+- changeset 过大:缩小 revision 区间;不要假设结果会自动截断。
+- 读取权限不足或无法确认受保护内容的完整读取权限:申请对目标内容的直接访问权限,或请管理员执行;不要把权限错误解释为空数据,也不要拆分范围绕过权限。
+- `containsIncompleteChanges=true`:可以报告已确认部分和明确缺口,不能声称所有改动均已完整解析。
diff --git a/skills/mono/references/products/sheet/sheet-style-format.md b/skills/mono/references/products/sheet/sheet-style-format.md
index 20cb2b32..574c0d94 100644
--- a/skills/mono/references/products/sheet/sheet-style-format.md
+++ b/skills/mono/references/products/sheet/sheet-style-format.md
@@ -99,7 +99,7 @@ Flags:
- `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者
- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000)
- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等
-- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并
+- 纯样式设置只修改样式并保留原值,因此可直接作用于含合并单元格的区域,无需先取消合并
### 批量设置单元格样式(服务端原子事务)
```
@@ -161,7 +161,7 @@ Flags:
- 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发**
- 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用
- `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量)
-- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域
+- 批量纯样式设置同样只修改样式并保留原值,可直接作用于含合并单元格的区域
### 合并单元格
```
@@ -304,7 +304,7 @@ dws sheet merge-cells --node --sheet-id --range "A1:C3" --m
- ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错
- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式
- `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式
-- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截)
+- `range set-style` / `range batch-set-style` 只修改样式并保留原值;可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截)
- `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style`
- `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃
- `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格)
diff --git a/skills/mono/references/products/sheet/sheet-version.md b/skills/mono/references/products/sheet/sheet-version.md
index 3a119745..928e9ad4 100644
--- a/skills/mono/references/products/sheet/sheet-version.md
+++ b/skills/mono/references/products/sheet/sheet-version.md
@@ -5,10 +5,10 @@
管理钉钉在线电子表格的历史版本快照。当用户说"保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格"时使用。
- 手动保存当前表格为一个版本快照 → `version save`
-- 查看表格的历史版本列表(含版本号、名称、创建人、创建时间) → `version list`(别名 `ls`)
-- 把表格回滚到指定历史版本 → `version revert`(危险操作,默认需二次确认)
+- 查看表格的历史版本列表 → `version list`(别名 `ls`)。返回不含版本名称;列表项包含 `version`、`type`、`userId`、`createTime`、`updateTime`、`editorList` 等服务端字段
+- 把表格回滚到指定历史版本或已确认的精确 revision → `version revert`(危险操作,默认需二次确认)
-三个命令统一用 `--node` 指定表格文档(ID 或 URL)。回滚用的**版本号 `--version` 从 `version list` 的返回中获取**,禁止臆测。
+三个命令统一用 `--node` 指定表格文档(ID 或 URL)。默认从 `version list` 选择稳定的历史版本。用户明确要求恢复到某个精确 revision 时,也可把已从同一工作簿真实查询结果确认的 revision 传给 `--version`,即使它没有出现在版本列表中。禁止猜测版本号或 revision。
## 命令详细参考
@@ -38,32 +38,47 @@ Flags:
--limit int 返回版本数量上限 (可选)
--cursor string 分页游标 (可选,游标分页)
```
-返回表格的历史版本列表;回滚前先用它拿到目标 `version`(版本号)。
+返回表格的历史版本列表;顶层包含 `versions`、`nextCursor`、`hasMore`,列表项不含 `name`。回滚前先从列表项拿到真实 `version`(版本号)。
### 回滚表格到指定版本
```
Usage:
dws sheet version revert [flags]
Example:
- dws sheet version revert --node SHEET_ID --version 3 --yes
+ dws sheet version revert --node SHEET_ID --version 3
Flags:
--node string 表格文档 ID 或 URL (必填)
- --version int 目标版本号 (必填,从 version list 获取)
+ --version int 目标历史版本或已确认 revision (必填,通常从 version list 获取)
```
-把表格回滚到指定历史版本。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本执行时可加全局 `--yes` 跳过确认。
+把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认要求二次确认。本文不提供带 `--yes` 的可复制示例;执行器只有在当前流程中向用户展示完整目标参数和覆盖风险、并获得明确确认后,才可动态追加全局 `--yes`。
+
+`version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。
+
+### 精确 revision 回滚
+
+只有用户明确要求恢复到某个 revision 时才走此流程:
+
+1. 用 `revision-get` 记录回滚前的当前 revision。
+2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。
+3. 向用户展示工作簿、目标 revision 以及“当前内容将被覆盖”的风险,然后停止并等待明确确认;确认前禁止调用回滚工具,也禁止预先添加全局 `--yes`。
+4. 只有用户对当前展示的参数明确确认后,执行器才可对同一条命令动态追加全局 `--yes` 并执行;任一参数发生变化都必须重新确认。
+5. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。
+6. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `version list` | `version`(版本号) | 作为 `version revert --version` 的入参 |
+| `revision-get` / `changeset-get` | 已确认属于同一工作簿的 `revision` | 用户明确要求精确恢复时,作为 `version revert --version` 的入参 |
| `version save` | 版本快照结果 | 确认已生成快照 |
| `version revert` | 回滚结果 | 确认回滚完成,回读表格内容验证 |
## 注意事项
-- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes`
-- ★ `--version` 必须来自 `version list` 的真实返回,禁止臆测版本号
-- 底层复用 doc 域的历史版本能力(`save_doc_version` / `list_doc_versions` / `revert_doc_version`),`--node` 传在线电子表格文档即可
+- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作;确认前禁止调用工具。只有用户对当前完整参数明确确认后,执行器才可动态追加全局 `--yes`;任一参数变化都必须重新确认
+- ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision
+- ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试
+- ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md)
- 回滚后应用独立读命令(`csv-get` / `range read`)回读确认,避免"写返回不等于完成"
diff --git a/skills/mono/references/products/sheet/sheet-workbook.md b/skills/mono/references/products/sheet/sheet-workbook.md
index 54c4ca48..8299ba06 100644
--- a/skills/mono/references/products/sheet/sheet-workbook.md
+++ b/skills/mono/references/products/sheet/sheet-workbook.md
@@ -70,12 +70,12 @@ Flags:
--styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配)
```
-建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。
+本命令会创建表格、写入初始数据并回读校验,还可选择应用样式。**只要一个空表格请用 `dws sheet create`**;本命令必须给数据。
**写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**):
-- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。
-- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如
+- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,并自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码后 2000000 字符。
+- `--sheets`:typed table 数组,一次创建多个带数据的工作表。每项形如
`{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`;
`name`、`columns` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。
- 字段名为 camelCase,只接受 `name` / `columns` / `data` / `dtypes` / `formats` / `cellStyles` / `startCell` / `mode`(`overwrite`|`append`) / `header` / `allowOverwrite`;**未知键与 snake_case 变体一律拒绝**(服务端会静默丢弃写错的键,`{"datas":[...]}` 会写出一张只有表头的表却报成功)
@@ -104,7 +104,7 @@ Flags:
**行为要点**:
- 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档
-- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据
+- 命令会等待新建表格可写后再写入数据,并通过回读确认结果
- 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写
- 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档
diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md
index d1e62ea1..a6fb537a 100644
--- a/skills/multi/dingtalk-chat/SKILL.md
+++ b/skills/multi/dingtalk-chat/SKILL.md
@@ -47,6 +47,7 @@ metadata:
| 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 |
| 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 |
| 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` |
+| 个人收藏表情列表/发送/收藏 | `dws chat emotion list/send/favorite` | 约束见 leaf Schema |
| 修改群名称 | `dws chat group rename --id --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` |
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | 检查 `complete`;空结果仍返回数组 |
| 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` |
diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md
index cb46df88..3b8e710f 100644
--- a/skills/multi/dingtalk-chat/references/chat.md
+++ b/skills/multi/dingtalk-chat/references/chat.md
@@ -57,8 +57,10 @@
| `message add-emoji` / `remove-emoji` | 默认 emoji reaction |
| `message create-text-emotion` / `add-text-emotion` / `update-text-emotion` / `remove-text-emotion` | 文字表情 |
| `message list-emotion-replies` | 批量 reaction/文字回应 |
+| `emotion list` / `send` / `favorite` | 当前用户个人收藏表情列表、发送和新增 |
Favorite、消息 Pin、消息 Top 与会话 Top 是四种对象,不能互换。
+个人收藏表情与消息 reaction/文字回应不同;发送收藏表情使用 `chat emotion send`,给已有消息贴表情使用 `chat message add-emoji` 或 `chat message add-text-emotion`。
## 群与成员底层能力
diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md
index 534cef40..5e67eff3 100644
--- a/skills/multi/dingtalk-doc/SKILL.md
+++ b/skills/multi/dingtalk-doc/SKILL.md
@@ -1,6 +1,6 @@
---
name: dingtalk-doc
-description: 钉钉在线文字文档(adoc)本体及其内容的操作:查找、创建、读取、文档信息、编辑、块、评论、附件与媒体、白板卡片、导入、导出(docx/markdown/pdf)、版本、模板、权限、分享及Markdown/JSONML写入。不包括:文档空间与钉盘的文件管理(归 dingtalk-drive,doc 同名原子命令已弃用)、知识库空间与节点管理(归 dingtalk-wiki)、原生 .md 文件读写(归 dingtalk-misc)、电子表格 axls(归 dingtalk-misc)、AI 表格 able(归 dingtalk-aitable)。命令前缀:dws doc。
+description: 钉钉在线文字文档(adoc)的查找创建、读写、文档信息、白板卡片、附件、评论、媒体、导入导出、版本、模板、权限分享;本地文件转在线文档或协作编辑归 dingtalk-doc。原文件/文件夹归 dingtalk-drive,知识库归 dingtalk-wiki,原生 .md/axls 归 dingtalk-misc,able 归 dingtalk-aitable。前缀:dws doc。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -35,21 +35,23 @@ metadata:
## Golden Route
-选择最小入口。ID/URL 直用;只有标题时先搜索,唯一候选后执行,禁止默认第一项。
+走最小入口:ID/URL 直用;标题先搜索,唯一命中再执行。成本顺序:稳定 ID → shortcut → 局部读 → 精确写;禁以产品 Schema、全文或原子命令起步。
| 用户意图 | 唯一推荐入口 | 关键边界 |
|---|---|---|
| 按标题或主题定位文档 | `dws doc +search --query <关键词>` | 检查候选类型与分页;需要正文时再用真实 `nodeId` 执行 `+fetch` |
-| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node ` | `--scope keyword/section/range` 可减少无关正文;非 adoc 切换对应产品 |
+| 最近访问文档 | `dws doc +search`(省略 `--query`) | `--limit` 为每页量,`--max-items` 为总上限;完整集合才加 `--page-all` 并检查 `complete` |
+| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node ` | 具体术语直用 `keyword`;章节先 `outline` 再 `section`;只有整篇任务才用默认 `full` |
| 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node ` | 仅打开任务所需的 `--include-*`,不要默认全取 |
-| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 长或复杂 Markdown 使用 `@file`/stdin;根据结果验证真实 `nodeId` |
-| 追加、覆盖或精确编辑 block | `dws doc +update --node --command <动作>` | `--expected-revision` 仅用于 JSONML 原子覆盖;先消费 Schema confirmation |
+| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 先在本地完成正文;Runtime 负责 Markdown 分片和回读,禁止 Agent 自行拆成多次远程写入 |
+| 追加、覆盖或精确编辑 block | `dws doc +update --node --command <动作>` | 唯一文本直接 `str_replace`;章节/block 先局部取 ID 再精准修改;整篇覆盖才用 overwrite |
| 重要内容更新且需要恢复点 | `dws doc +checkpoint-update` | 自动保存版本、更新并回读;检查 `steps` 和 `compensation` |
| 版本操作 | `dws doc +version-save --node` / `dws doc +version-list --node` / `dws doc +version-revert --node --version` | 快照/列表/回滚 |
| 导出为 docx/markdown/pdf | `dws doc +export --export-format <格式>` | 格式必须显式指定;普通文件下载切 `dingtalk-drive` |
-| 导入本地文件为在线对象 | `dws doc +import --file <相对路径>` | folder/workspace 可选,缺省导入默认根;纯上传切 `dingtalk-drive` |
-| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | 没有名称/关键词时使用;不调用 search/create |
-| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 有查询词才使用;零或多候选停止 |
+| 本地文件转在线文档 | `dws doc +import --file <相对路径>` | “传上去/放进文件夹”不改变路由;在线改、协作编辑或转换用 import,仅保留原文件切 `dingtalk-drive` |
+| 封面/背景 | `+resource-update/+resource-delete`;`+background-update/+background-delete` | 写后 `+inspect --include-style`;禁查 Catalog |
+| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | “我的/我这边”只查 MY;明确公开才查 PUBLIC;“有哪些/全部”翻页至完整 |
+| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 来源可选 MY/PUBLIC;零命中停止,禁止拿无关模板替代;多候选消歧 |
| 从模板创建 | `dws doc +create-from-template --template-id <唯一ID>` | 已有唯一 templateId 才创建;不重复 list/search |
| 创建评论或聚合待处理评论 | `dws doc +comment-create [--selection]` / `+review` | 划词统一用 `+comment-create`;后续操作使用真实 `commentKey` |
| 添加/调整/移除协作者权限 | `dws doc +access-grant/+access-change/+access-revoke` | 先读取现有权限;姓名歧义或 profile 不一致时禁止写入 |
@@ -58,18 +60,20 @@ metadata:
## 关键结果语义
-- 保留真实 `nodeId`、URL、资源类型和容器;标题不能替代稳定 ID。
-- 按状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读且不重试写入;`retryable` 仅限明确未开始;权限、参数、认证失败停止。
+- 保留真实 `nodeId`、URL、资源类型和容器;同轮创建后直接复用返回 ID,禁止再按标题或钉盘搜索。
+- 先消费完整回执;Runtime 已分片/回读时不重复读取。只有局部验收、`partial_success` 或 commit-unknown 才再 `+fetch`。
+- 状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读、不重试写;`retryable` 仅限明确未开始;权限/参数/认证失败停止。
- 仅在结果明确且关键内容回读匹配后报告写入完成。
-- 搜索、列表和批量结果必须检查 `complete`、`hasMore`、continuation 和失败项;单页结果不得表述为完整集合。
+- 搜索/列表检查 `complete`、`hasMore`、cursor 和失败项。“有哪些/列出/全部”逐页至完整;只有示例/前 N 条可提前停止并声明范围。
+- `+import` 已含上传、转换和轮询;成功检查 `success=true`、`taskId`、`documentUrl`。超时或中断保留 `taskId`、查询原任务,禁止重导。
- 导出/下载仅用工作目录相对路径,默认不覆盖并原子落盘。
## 参数与安全边界
-- `@file` 统一协议:已有或临时文件先暂存到 cwd 后传 `@相对路径`;单次生成文本优先 `--content -` (stdin);禁止绝对路径和 `..`。
+- `@file`:已有或临时文件先暂存到 cwd;传 `@相对路径`,禁绝对路径和 `..`。
- `doc +update` 的动作由 `--command` 指定;block 操作的 ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表。
-- Schema 门禁:已知只读路由且参数明确时直接执行;写命令或 selection/参数不清、准备 Help 时,本轮仅查一次 `dws schema --cli-path "doc +" --compact --fields use_when,avoid_when,parameters,constraints,confirmation -f json`;禁用产品级/`--all`。
-- 执行前消费其中的 `confirmation`:`user_required` 且用户已确认同一目标/动作/参数才加 `--yes`,否则预览/询问;禁止靠失败探测门禁。
+- Schema 门禁:不确定时仅查一次精确 leaf:`--fields use_when,avoid_when,parameters,constraints,confirmation`;禁用产品级/`--all`。准备 Help 时,本轮仅查一次。
+- 消费本页或精确 Schema 的 `confirmation`:`user_required` 且原请求/预授权已确认目标、动作、参数时,首调即加 `--yes`;否则预览/询问;禁止靠失败探测门禁。
- JSONML 顶层必须是单个非空元素;禁止 `[[...]]` 元素数组包裹。
## 按需加载
@@ -80,25 +84,24 @@ Golden Route 已给出命令且参数足够时,禁止读取 reference。其余
|---|---|
| 低频/无 shortcut 意图消歧 | [intent-guide.md](references/intent-guide.md) / [doc.md](references/doc.md) 对应章节 |
| 分页、`partial_success`、`status=unknown` 或恢复 | [contracts.md](references/contracts.md) |
-| 复杂 JSONML/非默认操作 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) |
-| block/划词评论/媒体高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) |
+| 复杂 JSONML、长文或局部精准读写 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) |
+| block/划词评论/媒体/封面/背景高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) |
| 导出/导入失败恢复 | [export](references/doc/doc-export.md) / [import](references/doc/doc-import.md) |
-`+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;禁止预加载或连读。
+常规 `+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;需策略时仅读上表一项,禁预加载/连读。
## 错误最短路径
1. 零命中、多候选、类型不明或分页不完整:停止后续写入,展示候选或 continuation;禁止默认第一项。
2. Help 不参与选路;按上方门禁先消费一次精确 leaf Schema。只有真实 `unknown flag`/契约漂移后才查一次 leaf Help;`unknown command` 只查一次 shortcut 清单,禁止试探后缀和 `dws doc --help | grep/head`。
3. `REVISION_CONFLICT`:重新读取当前 revision,展示差异;未经用户确认不得改成无 revision 覆盖。
-4. `partial_success`:保留已完成步骤,优先执行补偿或从 checkpoint 继续,不重放成功步骤。
-5. `doc_write_commit_unknown`:先回读文档确认状态;禁止自动重试创建或追加。
-6. 认证、权限或 profile 错误:只读取 `dingtalk-shared` 对应 reference;不要尝试同义底层命令绕过。
-7. 导出或媒体失败:保留 `jobId/resourceId/nodeId` 后停止;禁止 `curl`、安装依赖、Python 文档库或手写 HTTP 兜底。
+4. `doc_write_commit_unknown`:先回读;禁止自动重试创建或追加。
+5. 认证、权限或 profile 错误:只读 `dingtalk-shared` 对应 reference,不用底层命令绕过。
+6. 导出/媒体失败:保留稳定 ID 后停止;禁止网络请求、安装依赖或本地文档库兜底。
## 跨产品边界
-- 普通文件、目录、纯上传下载、节点存储权限 → `dingtalk-drive`
+- 普通文件/目录/纯上传下载/节点权限 → `dingtalk-drive`;保留原文件用原子命令 `dws drive upload --workspace`,在线转换用 `doc +import --workspace`
- 知识库空间、节点层级和成员管理 → `dingtalk-wiki`
- 原生 `.md` 文件读取和编辑 → `dingtalk-misc`
- `axls` / `able` → 对应电子表格或多维表 Skill
diff --git a/skills/multi/dingtalk-doc/references/contracts.md b/skills/multi/dingtalk-doc/references/contracts.md
index a75ff6d1..544382e1 100644
--- a/skills/multi/dingtalk-doc/references/contracts.md
+++ b/skills/multi/dingtalk-doc/references/contracts.md
@@ -13,10 +13,12 @@
| `success` | 所有计划步骤完成,且需要验证的内容已经回读 | 可以向用户报告完成 |
| `partial_success` | 已发生部分副作用,后续步骤失败 | 检查 `steps` 与 `compensation`,不得重放成功步骤 |
| `unknown` | 请求已发出但无法确定服务端是否提交 | 先回读目标;创建和追加禁止自动重试 |
-| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after`,最多有界重试一次 |
-| `failed` | 已确认没有完成目标动作 | 根据 `retryable` 和 `suggestedActions` 决定是否重试 |
+| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after_seconds`,最多有界重试一次 |
+| `failed` | 已确认没有完成目标动作 | 根据 `retryable`、`actions` 和 details 决定是否重试 |
-`ok=true` 或进程退出码为零不能替代 `status/verified`。写回执应保留 operation、target、steps、warnings、failures、compensation 和 verification 摘要。
+进程退出码为零不能替代业务证据。采用 `doc.operation.v1` 的 shortcut 回执固定提供 `contractVersion/ok/status/complete/operation/steps/data/warnings/compensation`;`target/failures/verification` 只有实际操作返回时才能消费,不是通用字段。`+import` 使用现有导入回执,成功时检查 `success=true`、`taskId`、`documentUrl`、`documentName` 与 `documentType`,不要要求不存在的 `status/steps`。业务 `status` 不应与框架外层 `outcome` 混为一谈。
+
+稳定返回 ID/任务 ID 只用于定位目标、恢复流程或继续查询,不能单独证明写操作成功。成功证据优先级从高到低为:与该操作匹配的明确服务端成功终态 → 契约要求的写后读回匹配 → 仅 transport/退出码;异步任务必须使用返回的任务 ID 查询到成功终态。只有成功终态成立且所有必要回读均匹配时才报告成功;`partial_success`、`unknown`、未完成任务或仅返回资源/任务 ID 都不得报告完成。Runtime 已给出充分回读证据时,不为“再确认一次”重复请求。
## 分页与完整性
@@ -24,14 +26,14 @@
- `complete=true`:已证明覆盖请求范围;
- `hasMore=true`:仍有后续页,必须保留有效 continuation;
-- `truncated=true`:因 maxPages/maxItems/timeout 等边界停止;
-- `failures[]`:已返回部分数据但某一页或后处理失败。
+- `truncated=true`:成功返回因 `max_pages` 或 `max_items` 边界停止,并通过 `stopReason` 说明原因;
+- 后续页请求失败、cursor 缺失/停滞/循环时返回 typed error;部分结果位于 `details.items`,并保留 `details.status=partial_success`、`complete=false`、`reason/page/nextCursor/count`。不要期待成功回执的空 `failures[]` 承载这类错误,也不要把 timeout 写成 `truncated`。
-只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。
+只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。用户问“有哪些”“全部”或要求“列出结果”时,使用返回的 cursor/continuation 继续取页直至完整;只有用户明确要示例、前 N 条或接受部分结果时才可提前停止,并说明已覆盖范围。分页应复用原查询与过滤条件,不得换命令或放宽关键词。
## 错误
-结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 和 suggested actions。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。
+结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 与已有的 `actions`/details。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。不要发明 `suggestedAction` 或顶层 `nextCommand` 字段。
## 安全落盘
diff --git a/skills/multi/dingtalk-doc/references/doc.md b/skills/multi/dingtalk-doc/references/doc.md
index 76570e83..9ecdfc67 100644
--- a/skills/multi/dingtalk-doc/references/doc.md
+++ b/skills/multi/dingtalk-doc/references/doc.md
@@ -26,10 +26,14 @@
dws doc +template-search --query "周报" --source PUBLIC --format json
```
+来源按用户原话守门:“我的模板/我这边”只查 `MY`,明确“公开/钉钉模板库”才查 `PUBLIC`;不得为了凑结果跨来源扩展。未指定来源时保持默认 `MY`。
+
- `selection.status=resolved`:取唯一候选的 `templateId`。
-- `selection.status=not_found`:报告零命中后停止。
+- `selection.status=not_found`:报告零命中后停止;不得改用语义不相干的热门模板,更不得擅自创建文档。
- `selection.status=selection_required`:展示候选并要求用户选择,禁止默认第一项。
+若返回 `hasMore=true`,沿原 query/source 使用 cursor 继续搜索;只有服务端返回完整结果后才能判断零命中或完整候选集。
+
选定后只创建一次:
```bash
diff --git a/skills/multi/dingtalk-doc/references/doc/doc-create.md b/skills/multi/dingtalk-doc/references/doc/doc-create.md
index c5a23e3e..3e06ff19 100644
--- a/skills/multi/dingtalk-doc/references/doc/doc-create.md
+++ b/skills/multi/dingtalk-doc/references/doc/doc-create.md
@@ -12,7 +12,7 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --
- 统一输入协议:已有或临时文件先暂存到当前工作目录后传 `@相对文件`;单次生成文本可用 `--content -` 从 stdin 读取。
- `@file` 禁止绝对路径和 `..` 逃逸;不要直接引用宿主临时目录。
-- `--name` 是文档名称,不等于正文中的 heading。用户明确要求“正文一级标题”时,正文仍须包含对应 H1。
+- `--name` 是文档名称;默认不要再在正文开头重复同名 H1,只有用户明确要求正文一级标题时才保留。
- JSONML 顶层必须是数组;仅在确实需要富结构时加载 JSONML cookbook。
`+create` 负责创建、长 Markdown 分片和最终回读验证。正常成功结果至少包含:
@@ -35,6 +35,18 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --
- `status=unknown`:服务端可能已经提交;先定位并读取文档,禁止自动重试。
- 没有真实 `nodeId` 或写回执时,禁止声称“已创建”。
+## 创作与执行策略
+
+采用 Plan → Execute → Observe → Iterate,将循环放在本地内容与定点修正上,不把长文拆成一串远程创建/追加:
+
+1. **Plan**:明确受众、目的、范围和结构;正文由一个主上下文串行维护,避免按章节并行生成造成重复、矛盾和语气漂移。
+2. **Draft**:短内容可直接传;多行、长文或含特殊字符时先形成 cwd 内相对文件。用户未要求富结构时优先 Markdown;只有样式/引用/嵌套结构确有必要时才用 JSONML。
+3. **Execute**:只调用一次 `+create`。DWS Runtime 会分片长 Markdown、记录每步并回读验证;Agent 不采用“先骨架、再逐节远程插入”流程,以减少网络次数、顺序错误和 commit-unknown 面。
+4. **Observe**:先检查回执中的 `nodeId`、`verified`、`steps` 和失败状态。回执已证明完整时不重复拉全文;需要内容质量验收时,只用 `+fetch --scope section/keyword` 读取待检查部分。
+5. **Iterate**:后续修正复用同一 `nodeId`,按 [`doc-update.md`](doc-update.md) 做最小 block/文本修改,禁止重新创建整篇。
+
+交付前检查标题是否重复、段落是否连贯、编号是否统一;只有真实行列数据才使用表格,富组件服务于理解而不是装饰。明确字数要求时应在写入前完成本地统计,不能凭模型估算宣称达标。
+
## 高级通道
只有 shortcut 未公开所需的底层参数或需要原始响应时,才读取精确 leaf Schema 后使用 `dws doc create`。不要因为熟悉旧参数就默认退回原子命令,也不要使用已删除的 Python 创建脚本。
diff --git a/skills/multi/dingtalk-doc/references/doc/doc-import.md b/skills/multi/dingtalk-doc/references/doc/doc-import.md
index c2bfe44e..d03a6031 100644
--- a/skills/multi/dingtalk-doc/references/doc/doc-import.md
+++ b/skills/multi/dingtalk-doc/references/doc/doc-import.md
@@ -8,14 +8,15 @@ dws doc +import --file ./report.docx --folder --format json
dws doc +import --file ./notes.md --workspace --name "会议纪要" --format json
```
-`+import` 一次完成创建会话、上传、确认转换和轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。
+`+import` 一次完成创建会话、上传、确认转换和终态轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。转换成功回执包含 `success=true`、`taskId`、`documentUrl`、`documentName` 和 `documentType`,不包含 `status` 或 `steps`;成功返回即表示本次内部轮询已到终态。超时或中断时保留错误中的 `taskId`,只查询原任务。
## 本地文件边界
- `--file` 只接受当前工作目录内已存在的相对路径;禁止绝对路径、`..` 或符号链接逃逸。
-- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录,两者都有时优先 `--folder`。
+- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录。通常只提供一个;当前 Runtime 在两者同时出现时会一并传给服务端,未声明客户端优先级,不要依赖 `--folder` 覆盖 `--workspace`。
- CLI 负责上传和格式转换。不要先用 Python/Office 库解析文件,不要安装本地依赖来伪造在线导入结果,也不要手写 HTTP 上传。
- 白名单外格式(如 HTML/PDF)自动改走原文件上传,返回 `fallback=upload`、`converted=false`;不得报告成已经转换为可编辑在线文档。
+- “在线改/协作编辑/转在线文档”属于导入转换;“存着/归档/保留原文件/不要转换”属于 `dingtalk-drive` 纯上传。目标为文档空间时,纯上传使用 `drive upload --workspace `,不要因容器叫“文档空间”就误报为在线文档。
## 失败处理
diff --git a/skills/multi/dingtalk-doc/references/doc/doc-media.md b/skills/multi/dingtalk-doc/references/doc/doc-media.md
index b87c2e03..87a5e694 100644
--- a/skills/multi/dingtalk-doc/references/doc/doc-media.md
+++ b/skills/multi/dingtalk-doc/references/doc/doc-media.md
@@ -16,10 +16,22 @@ dws doc +media-download --node --resource-id --output ./d
dws doc +media-preview --node --resource-id --format json
```
+## 封面与背景 Shortcut
+
+```bash
+dws doc +resource-update --node --file ./cover.png --format json
+dws doc +resource-download --node --output ./cover.png --format json
+dws doc +resource-delete --node --format json
+dws doc +background-update --node --color "#E8F2FE" --format json
+dws doc +background-delete --node --format json
+```
+
+封面不是正文媒体 block;背景仅接受 `#RRGGBB` 纯色。设置/清除后用一次 `+inspect --include-style` 验证,禁止为这些已知能力查询 shortcut Catalog。
+
## 稳定 ID 与结果
- `resourceId`、`blockId`、`nodeId` 必须来自真实 media/block 返回,不能从标题或本地文件名猜测。
-- 插入后保留 `resourceId` 和插入结果;下载必须检查 `localPath`、`sizeBytes > 0`。
+- 插入成功回执在 `data.blockId` 返回已回读验证的媒体块 ID;后续定位只复用该 `blockId`。回执不提供相邻空块字段,不得据此猜测或自动删除其他块。插入回执成功后禁止重传媒体。下载必须检查 `localPath`、`sizeBytes > 0`。
- 下载输出只接受工作目录内相对路径,默认 no-clobber。
- 删除源文件是独立的破坏性本地操作,不属于媒体下载;只有用户明确要求且下载验证成功后才能执行。
diff --git a/skills/multi/dingtalk-doc/references/doc/doc-read.md b/skills/multi/dingtalk-doc/references/doc/doc-read.md
index dc8dd710..ed7857fc 100644
--- a/skills/multi/dingtalk-doc/references/doc/doc-read.md
+++ b/skills/multi/dingtalk-doc/references/doc/doc-read.md
@@ -24,10 +24,24 @@ dws doc +fetch --node