diff --git a/internal/cli/canonical_test.go b/internal/cli/canonical_test.go index 79c0a712..6c18eb15 100644 --- a/internal/cli/canonical_test.go +++ b/internal/cli/canonical_test.go @@ -593,6 +593,73 @@ func TestRuntimeSchemaUsesMCPMetadataAnnotations(t *testing.T) { } } +func TestRuntimeSchemaEmitsCrossParameterConstraintsAndPositionals(t *testing.T) { + root := &cobra.Command{Use: "dws"} + send := &cobra.Command{Use: "send", RunE: func(*cobra.Command, []string) error { return nil }} + send.Flags().String("group", "", "群聊必填") + send.Flags().String("users", "", "单聊必填") + AttachRuntimeSchema(send, "chat", "send_robot_message", "hardcoded:chat") + AnnotateRuntimeConstraints(send, RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"group", "users"}}, + RequireOneOf: [][]string{{"group", "users"}}, + }) + AnnotateRuntimePositionals(send, RuntimeSchemaPositional{ + Name: "scope", Type: "array", Variadic: true, Index: 0, + }) + chat := &cobra.Command{Use: "chat"} + chat.AddCommand(send) + root.AddCommand(chat) + + payload, err := runtimeSchemaPayload(root, []string{"chat.send_robot_message"}) + if err != nil { + t.Fatalf("runtimeSchemaPayload() error = %v", err) + } + params, _ := payload["parameters"].(map[string]any) + for _, name := range []string{"group", "users"} { + param, _ := params[name].(map[string]any) + if param["required"] != false { + t.Fatalf("%s required = %#v, want conditional false", name, param["required"]) + } + } + constraints, _ := payload["constraints"].(map[string]any) + requireOneOf, _ := constraints["require_one_of"].([][]string) + if len(requireOneOf) != 1 || !equalStrings(requireOneOf[0], []string{"group", "users"}) { + t.Fatalf("require_one_of = %#v", constraints["require_one_of"]) + } + positionals, _ := payload["positionals"].([]RuntimeSchemaPositional) + if len(positionals) != 1 || positionals[0].Name != "scope" || !positionals[0].Variadic { + t.Fatalf("positionals = %#v", payload["positionals"]) + } +} + +func TestRuntimeSchemaAnnotationsDoNotChangeCobraValidation(t *testing.T) { + cmd := &cobra.Command{Use: "read", RunE: func(*cobra.Command, []string) error { return nil }} + cmd.Flags().String("node", "", "node ID") + cmd.Flags().String("url", "", "node URL alias") + AnnotateRuntimeRequiredFlags(cmd, "node") + cmd.SetArgs([]string{"--url", "https://example.com/doc"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("schema-only required annotation rejected alias invocation: %v", err) + } +} + +func TestRuntimeSchemaExcludesHumanOnlyHints(t *testing.T) { + root := &cobra.Command{Use: "dws"} + product := &cobra.Command{Use: "sheet"} + real := &cobra.Command{Use: "range-read", RunE: func(*cobra.Command, []string) error { return nil }} + hint := &cobra.Command{Use: "read", RunE: func(*cobra.Command, []string) error { return nil }} + AttachRuntimeSchema(real, "sheet", "range_read", "runtime:sheet") + AttachRuntimeSchema(hint, "sheet", "read", "runtime:sheet") + ExcludeFromRuntimeSchema(hint) + product.AddCommand(real, hint) + root.AddCommand(product) + + entries := collectRuntimeSchemaEntries(root) + if len(entries) != 1 || entries[0].CLIPath != "sheet range-read" { + t.Fatalf("entries = %#v, want only executable leaf", entries) + } +} + func TestRuntimeSchemaUsesCuratedRequiredSemantics(t *testing.T) { root := &cobra.Command{Use: "dws"} diff --git a/internal/cli/dev_schema.go b/internal/cli/dev_schema.go index 2de32695..802af298 100644 --- a/internal/cli/dev_schema.go +++ b/internal/cli/dev_schema.go @@ -116,7 +116,8 @@ func helperLocalLeafSchema(cmd *cobra.Command) map[string]any { canonicalPath = productID + "." + toolName } hint := schemaHintForCanonicalPath(canonicalPath) - parameters := runtimeCommandParameters(cmd, hint.Parameters, nil) + constraints := runtimeCommandConstraints(cmd) + parameters := runtimeCommandParameters(cmd, hint.Parameters, nil, constraints) if parameters == nil { parameters = map[string]any{} } @@ -141,6 +142,12 @@ func helperLocalLeafSchema(cmd *cobra.Command) map[string]any { "has_parameters": len(parameters) > 0, "parameter_count": len(parameters), } + if rendered := runtimeConstraintsPayload(constraints); len(rendered) > 0 { + payload["constraints"] = rendered + } + if positionals := runtimeCommandPositionals(cmd); len(positionals) > 0 { + payload["positionals"] = positionals + } if len(meta.Aliases) > 0 { payload["aliases"] = meta.Aliases } diff --git a/internal/cli/runtime_schema.go b/internal/cli/runtime_schema.go index f1a993cc..739ee2fd 100644 --- a/internal/cli/runtime_schema.go +++ b/internal/cli/runtime_schema.go @@ -34,13 +34,36 @@ const ( runtimeSchemaTitleAnnotation = "dws.schema.title" runtimeSchemaDescAnnotation = "dws.schema.description" runtimeSchemaMetaAnnotation = "dws.schema.metadata_source" + runtimeSchemaExcludeAnnotation = "dws.schema.exclude" + runtimeSchemaRulesAnnotation = "dws.schema.constraints" + runtimeSchemaArgsAnnotation = "dws.schema.positionals" runtimeSchemaFlagPropertyAnnotation = "dws.schema.property" runtimeSchemaFlagTypeAnnotation = "dws.schema.type" runtimeSchemaFlagRequiredAnnotation = "dws.schema.required" runtimeSchemaFlagDefaultAnnotation = "dws.schema.default" + runtimeSchemaFlagExampleAnnotation = "dws.schema.example" ) +// RuntimeSchemaConstraints describes cross-parameter rules that cannot be +// represented by an individual parameter's required bit. +type RuntimeSchemaConstraints struct { + MutuallyExclusive [][]string `json:"mutually_exclusive,omitempty"` + RequireOneOf [][]string `json:"require_one_of,omitempty"` + RequireTogether [][]string `json:"require_together,omitempty"` +} + +// RuntimeSchemaPositional describes one ordered CLI argument. Name is also +// used by RuntimeSchemaConstraints when a one-of group mixes flags and args. +type RuntimeSchemaPositional struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Required bool `json:"required"` + Variadic bool `json:"variadic,omitempty"` + Index int `json:"index"` +} + type embeddedMCPMetadata struct { Version int `json:"version"` Source string `json:"source"` @@ -155,6 +178,127 @@ func AnnotateRuntimeFlag(cmd *cobra.Command, flagName, propertyName, paramType s } } +// AnnotateRuntimeRequiredFlags records schema-only required semantics. Unlike +// cobra.MarkFlagRequired, it does not require the primary flag itself to be +// changed, so helper commands can keep accepting hidden --url/--id aliases. +func AnnotateRuntimeRequiredFlags(cmd *cobra.Command, flagNames ...string) { + if cmd == nil { + return + } + for _, name := range flagNames { + flag := cmd.Flags().Lookup(strings.TrimSpace(name)) + if flag != nil { + setFlagAnnotation(flag, runtimeSchemaFlagRequiredAnnotation, "true") + } + } +} + +// AnnotateRuntimeFlagFormat records a machine-readable value format without +// changing the Cobra flag type. +func AnnotateRuntimeFlagFormat(cmd *cobra.Command, flagName, format string) { + if cmd == nil { + return + } + if flag := cmd.Flags().Lookup(strings.TrimSpace(flagName)); flag != nil { + setFlagAnnotation(flag, "x-cli-format", strings.TrimSpace(format)) + } +} + +// AnnotateRuntimeFlagEnum records the accepted values for a flag. +func AnnotateRuntimeFlagEnum(cmd *cobra.Command, flagName string, values ...string) { + if cmd == nil { + return + } + flag := cmd.Flags().Lookup(strings.TrimSpace(flagName)) + if flag == nil { + return + } + clean := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + clean = append(clean, value) + } + } + if len(clean) == 0 { + return + } + if flag.Annotations == nil { + flag.Annotations = map[string][]string{} + } + flag.Annotations["x-cli-enum"] = clean +} + +// AnnotateRuntimeFlagExample records a valid representative CLI value. +func AnnotateRuntimeFlagExample(cmd *cobra.Command, flagName, example string) { + if cmd == nil { + return + } + if flag := cmd.Flags().Lookup(strings.TrimSpace(flagName)); flag != nil { + setFlagAnnotation(flag, runtimeSchemaFlagExampleAnnotation, strings.TrimSpace(example)) + } +} + +// AnnotateRuntimeConstraints records command-level parameter relationships. +func AnnotateRuntimeConstraints(cmd *cobra.Command, constraints RuntimeSchemaConstraints) { + if cmd == nil { + return + } + constraints = normalizeRuntimeSchemaConstraints(constraints) + if runtimeSchemaConstraintsEmpty(constraints) { + return + } + if existing := runtimeCommandConstraints(cmd); !runtimeSchemaConstraintsEmpty(existing) { + constraints.MutuallyExclusive = append(existing.MutuallyExclusive, constraints.MutuallyExclusive...) + constraints.RequireOneOf = append(existing.RequireOneOf, constraints.RequireOneOf...) + constraints.RequireTogether = append(existing.RequireTogether, constraints.RequireTogether...) + constraints = normalizeRuntimeSchemaConstraints(constraints) + } + encoded, _ := json.Marshal(constraints) + setRuntimeCommandAnnotation(cmd, runtimeSchemaRulesAnnotation, string(encoded)) +} + +// AnnotateRuntimePositionals records ordered positional arguments for agents. +func AnnotateRuntimePositionals(cmd *cobra.Command, positionals ...RuntimeSchemaPositional) { + if cmd == nil { + return + } + clean := make([]RuntimeSchemaPositional, 0, len(positionals)) + for _, positional := range positionals { + positional.Name = strings.TrimSpace(positional.Name) + positional.Type = strings.TrimSpace(positional.Type) + positional.Description = strings.TrimSpace(positional.Description) + if positional.Name == "" || positional.Index < 0 { + continue + } + if positional.Type == "" { + positional.Type = "string" + } + clean = append(clean, positional) + } + if len(clean) == 0 { + return + } + sort.SliceStable(clean, func(i, j int) bool { return clean[i].Index < clean[j].Index }) + encoded, _ := json.Marshal(clean) + setRuntimeCommandAnnotation(cmd, runtimeSchemaArgsAnnotation, string(encoded)) +} + +// ExcludeFromRuntimeSchema keeps a human-facing hint or redirect in --help +// while preventing it from being advertised as an executable agent tool. +func ExcludeFromRuntimeSchema(cmd *cobra.Command) { + setRuntimeCommandAnnotation(cmd, runtimeSchemaExcludeAnnotation, "true") +} + +func setRuntimeCommandAnnotation(cmd *cobra.Command, key, value string) { + if cmd == nil || strings.TrimSpace(value) == "" { + return + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[key] = value +} + func setFlagAnnotation(flag *pflag.Flag, key, value string) { if flag == nil || strings.TrimSpace(value) == "" { return @@ -206,6 +350,9 @@ func collectRuntimeSchemaEntries(root *cobra.Command) []runtimeSchemaEntry { entries := []runtimeSchemaEntry{} seen := map[string]bool{} walkLeafCommands(root, func(leaf *cobra.Command) { + if runtimeSchemaExcluded(leaf) { + return + } productID, toolName, source := runtimeSchemaAnnotations(leaf) if productID == "" || toolName == "" { return @@ -248,6 +395,9 @@ func collectRuntimeSchemaEntries(root *cobra.Command) []runtimeSchemaEntry { } productName := strings.TrimSpace(productRoot.Short) walkLeafCommands(productRoot, func(leaf *cobra.Command) { + if runtimeSchemaExcluded(leaf) { + return + } parts := commandPathParts(leaf) if len(parts) == 0 { return @@ -426,6 +576,11 @@ func runtimeSchemaAnnotations(cmd *cobra.Command) (productID, toolName, source s return productID, toolName, source } +func runtimeSchemaExcluded(cmd *cobra.Command) bool { + return cmd != nil && cmd.Annotations != nil && + strings.EqualFold(strings.TrimSpace(cmd.Annotations[runtimeSchemaExcludeAnnotation]), "true") +} + func runtimeCommandTitle(cmd *cobra.Command) string { if cmd == nil { return "" @@ -741,7 +896,8 @@ func runtimeToolPayload(entry runtimeSchemaEntry) map[string]any { hint := runtimeSchemaHintForEntry(entry) embeddedMeta, hasEmbeddedMeta := embeddedMCPMetadataForEntry(entry) title, description, metadataSource := runtimeToolTextMetadata(entry) - parameters := runtimeCommandParameters(entry.Command, hint.Parameters, embeddedMeta.Parameters) + constraints := runtimeCommandConstraints(entry.Command) + parameters := runtimeCommandParameters(entry.Command, hint.Parameters, embeddedMeta.Parameters, constraints) if parameters == nil { parameters = map[string]any{} } @@ -776,13 +932,19 @@ func runtimeToolPayload(entry runtimeSchemaEntry) map[string]any { if len(entry.Aliases) > 0 { payload["aliases"] = entry.Aliases } + if rendered := runtimeConstraintsPayload(constraints); len(rendered) > 0 { + payload["constraints"] = rendered + } + if positionals := runtimeCommandPositionals(entry.Command); len(positionals) > 0 { + payload["positionals"] = positionals + } paths := []string{entry.PrimaryCLIPath, entry.CLIPath, canonicalPath} paths = append(paths, entry.Aliases...) applyAgentToolMetadata(payload, true, paths...) return payload } -func runtimeCommandParameters(cmd *cobra.Command, hints map[string]ParameterSchemaHint, embeddedParams map[string]embeddedMCPParamMeta) map[string]any { +func runtimeCommandParameters(cmd *cobra.Command, hints map[string]ParameterSchemaHint, embeddedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) map[string]any { if cmd == nil { return nil } @@ -853,20 +1015,120 @@ func runtimeCommandParameters(cmd *cobra.Command, hints map[string]ParameterSche entry["format"] = format } else if hasEmbeddedParam && strings.TrimSpace(embeddedParam.Format) != "" { entry["format"] = strings.TrimSpace(embeddedParam.Format) + } else if format := inferredRuntimeFlagFormat(flag); format != "" { + entry["format"] = format } if enum := runtimeFlagEnum(flag); len(enum) > 0 { entry["enum"] = enum } else if hasEmbeddedParam && len(embeddedParam.Enum) > 0 { entry["enum"] = append([]string(nil), embeddedParam.Enum...) } + if example := firstFlagAnnotation(flag, runtimeSchemaFlagExampleAnnotation); example != "" { + entry["example"] = example + } params[flagName] = entry }) + // A member of a require-one-of group is conditionally required, not + // individually required. This also corrects usage text such as "群聊必填" + // that would otherwise make every alternative look globally mandatory. + for _, group := range constraints.RequireOneOf { + for _, name := range group { + if entry, ok := params[name].(map[string]any); ok { + entry["required"] = false + } + } + } if len(params) == 0 { return nil } return params } +func runtimeCommandConstraints(cmd *cobra.Command) RuntimeSchemaConstraints { + if cmd == nil || cmd.Annotations == nil { + return RuntimeSchemaConstraints{} + } + raw := strings.TrimSpace(cmd.Annotations[runtimeSchemaRulesAnnotation]) + if raw == "" { + return RuntimeSchemaConstraints{} + } + var constraints RuntimeSchemaConstraints + if json.Unmarshal([]byte(raw), &constraints) != nil { + return RuntimeSchemaConstraints{} + } + return normalizeRuntimeSchemaConstraints(constraints) +} + +func runtimeCommandPositionals(cmd *cobra.Command) []RuntimeSchemaPositional { + if cmd == nil || cmd.Annotations == nil { + return nil + } + raw := strings.TrimSpace(cmd.Annotations[runtimeSchemaArgsAnnotation]) + if raw == "" { + return nil + } + var positionals []RuntimeSchemaPositional + if json.Unmarshal([]byte(raw), &positionals) != nil { + return nil + } + sort.SliceStable(positionals, func(i, j int) bool { return positionals[i].Index < positionals[j].Index }) + return positionals +} + +func normalizeRuntimeSchemaConstraints(constraints RuntimeSchemaConstraints) RuntimeSchemaConstraints { + constraints.MutuallyExclusive = normalizeRuntimeSchemaGroups(constraints.MutuallyExclusive, 2) + constraints.RequireOneOf = normalizeRuntimeSchemaGroups(constraints.RequireOneOf, 1) + constraints.RequireTogether = normalizeRuntimeSchemaGroups(constraints.RequireTogether, 2) + return constraints +} + +func normalizeRuntimeSchemaGroups(groups [][]string, minimum int) [][]string { + out := make([][]string, 0, len(groups)) + seenGroups := map[string]bool{} + for _, group := range groups { + clean := make([]string, 0, len(group)) + seenNames := map[string]bool{} + for _, name := range group { + name = strings.TrimSpace(name) + if name == "" || seenNames[name] { + continue + } + seenNames[name] = true + clean = append(clean, name) + } + if len(clean) < minimum { + continue + } + key := strings.Join(clean, "\x00") + if seenGroups[key] { + continue + } + seenGroups[key] = true + out = append(out, clean) + } + return out +} + +func runtimeSchemaConstraintsEmpty(constraints RuntimeSchemaConstraints) bool { + return len(constraints.MutuallyExclusive) == 0 && + len(constraints.RequireOneOf) == 0 && + len(constraints.RequireTogether) == 0 +} + +func runtimeConstraintsPayload(constraints RuntimeSchemaConstraints) map[string]any { + payload := map[string]any{} + if len(constraints.MutuallyExclusive) > 0 { + payload["mutually_exclusive"] = constraints.MutuallyExclusive + } + if len(constraints.RequireOneOf) > 0 { + payload["require_one_of"] = constraints.RequireOneOf + } + if len(constraints.RequireTogether) > 0 { + payload["require_together"] = constraints.RequireTogether + } + return payload +} + func lookupEmbeddedMCPParam(params map[string]embeddedMCPParamMeta, property, flagName string) (embeddedMCPParamMeta, bool) { if len(params) == 0 { return embeddedMCPParamMeta{}, false @@ -920,10 +1182,7 @@ func runtimeFlagRequired(flag *pflag.Flag) bool { return true } usage := strings.ToLower(strings.TrimSpace(flag.Usage)) - if usageImpliesRequired(usage) { - return true - } - return false + return usageImpliesRequired(usage) } func usageImpliesRequired(usage string) bool { @@ -1012,6 +1271,16 @@ func runtimeFlagEnum(flag *pflag.Flag) []string { return out } +func inferredRuntimeFlagFormat(flag *pflag.Flag) string { + if flag == nil { + return "" + } + if strings.Contains(strings.ToLower(strings.TrimSpace(flag.Usage)), "a1") { + return "a1-range" + } + return "" +} + func strconvQuote(value string) string { return "\"" + strings.ReplaceAll(value, "\"", "\\\"") + "\"" } diff --git a/internal/cli/schema_agent_metadata/contact.json b/internal/cli/schema_agent_metadata/contact.json index 7f33be68..71eafd52 100644 --- a/internal/cli/schema_agent_metadata/contact.json +++ b/internal/cli/schema_agent_metadata/contact.json @@ -61,19 +61,6 @@ "skills/mono/references/products/contact.md" ] }, - "contact get": { - "use_when": [ - "查用户详情/部门/主管/管理员" - ], - "effect": "read", - "effect_source": "command-verb", - "examples": [ - "dws contact user get --ids userId1,userId2" - ], - "source_refs": [ - "skills/mono/references/products/contact.md" - ] - }, "contact relation list-my-followings": { "use_when": [ "我关注了谁/我的特别关注列表/我的星标联系人/特别关注的人有哪些" @@ -84,20 +71,6 @@ "skills/mono/references/products/contact.md" ] }, - "contact search": { - "use_when": [ - "2. 按名字搜索同事或好友 — 可提取 同事的userId,或好友的openDingTalkId" - ], - "effect": "read", - "effect_source": "command-verb", - "examples": [ - "dws contact user search --query \"张三\"", - "dws contact user search --query \"张三\" --format json" - ], - "source_refs": [ - "skills/mono/references/products/contact.md" - ] - }, "contact user dismission search": { "use_when": [ "13. 查询全部离职员工", @@ -114,6 +87,19 @@ "skills/mono/references/products/contact.md" ] }, + "contact user get": { + "use_when": [ + "查用户详情/部门/主管/管理员" + ], + "effect": "read", + "effect_source": "command-verb", + "examples": [ + "dws contact user get --ids userId1,userId2" + ], + "source_refs": [ + "skills/mono/references/products/contact.md" + ] + }, "contact user get-self": { "use_when": [ "1. 查看自己的信息 — 提取 userId", @@ -157,6 +143,20 @@ "skills/mono/references/products/contact.md" ] }, + "contact user search": { + "use_when": [ + "2. 按名字搜索同事或好友 — 可提取 同事的userId,或好友的openDingTalkId" + ], + "effect": "read", + "effect_source": "command-verb", + "examples": [ + "dws contact user search --query \"张三\"", + "dws contact user search --query \"张三\" --format json" + ], + "source_refs": [ + "skills/mono/references/products/contact.md" + ] + }, "contact user search-mobile": { "examples": [ "dws contact user search-mobile --mobile 13800138000" diff --git a/internal/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index 9500484e..e0cbf3e3 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,11 +1,11 @@ { "version": 1, "source_hash": "sha256:2a8994db5beb9389d5b0a2cf201e67c7160f3d23156996e5747b85bc8474ff74", - "surface_hash": "sha256:2499ae17d61030fa3aec1d4adc2efd9d0644d93bc277c4616a8dc327fe893fae", + "surface_hash": "sha256:bd65a90baa1568a7126eac505e3e1c7f943583333fb7ed6ef56413ca98d2c83f", "coverage": { "surface_products": 23, "products_with_metadata": 19, - "surface_tools": 518, + "surface_tools": 511, "tools_with_metadata": 304, "unmatched_skill_tools": 86 }, diff --git a/internal/cli/schema_agent_metadata/wiki.json b/internal/cli/schema_agent_metadata/wiki.json index 544a4157..d3d9f5c0 100644 --- a/internal/cli/schema_agent_metadata/wiki.json +++ b/internal/cli/schema_agent_metadata/wiki.json @@ -1,23 +1,6 @@ { "product_id": "wiki", "tools": { - "wiki doc list": { - "use_when": [ - "2. 列出根目录节点", - "3. 进入子目录", - "知识库下的文件/知识库里有哪些文档/浏览知识库内容", - "读某个知识库里的某篇文档" - ], - "effect": "read", - "effect_source": "command-verb", - "examples": [ - "dws wiki node list --workspace \u003cworkspaceId\u003e --folder \u003cparentNodeId\u003e --format json", - "dws wiki node list --workspace \u003cworkspaceId\u003e --format json" - ], - "source_refs": [ - "skills/mono/references/products/wiki.md" - ] - }, "wiki member add": { "use_when": [ "2. 添加成员", @@ -124,6 +107,23 @@ "skills/mono/references/products/wiki.md" ] }, + "wiki node list": { + "use_when": [ + "2. 列出根目录节点", + "3. 进入子目录", + "知识库下的文件/知识库里有哪些文档/浏览知识库内容", + "读某个知识库里的某篇文档" + ], + "effect": "read", + "effect_source": "command-verb", + "examples": [ + "dws wiki node list --workspace \u003cworkspaceId\u003e --folder \u003cparentNodeId\u003e --format json", + "dws wiki node list --workspace \u003cworkspaceId\u003e --format json" + ], + "source_refs": [ + "skills/mono/references/products/wiki.md" + ] + }, "wiki node move": { "use_when": [ "移动知识库里的文档", diff --git a/internal/cli/schema_command_surface.json b/internal/cli/schema_command_surface.json index 1c1c1290..ac0413ed 100644 --- a/internal/cli/schema_command_surface.json +++ b/internal/cli/schema_command_surface.json @@ -754,24 +754,14 @@ { "cli_path": "contact dept search" }, - { - "cli_path": "contact get", - "aliases": [ - "contact user get" - ] - }, { "cli_path": "contact relation list-my-followings" }, { - "cli_path": "contact search", - "aliases": [ - "contact user list", - "contact user search" - ] + "cli_path": "contact user dismission search" }, { - "cli_path": "contact user dismission search" + "cli_path": "contact user get" }, { "cli_path": "contact user get-self" @@ -782,6 +772,9 @@ { "cli_path": "contact user profile get" }, + { + "cli_path": "contact user search" + }, { "cli_path": "contact user search-mobile" } @@ -901,34 +894,19 @@ "id": "devdoc", "tools": [ { - "cli_path": "devdoc article legacy-search", - "aliases": [ - "devdoc article-legacy-search" - ] + "cli_path": "devdoc article legacy-search" }, { - "cli_path": "devdoc article legacy-search-open-platform", - "aliases": [ - "devdoc article-legacy-search-open-platform" - ] + "cli_path": "devdoc article legacy-search-open-platform" }, { - "cli_path": "devdoc article rag-pretest", - "aliases": [ - "devdoc article-rag-pretest" - ] + "cli_path": "devdoc article rag-pretest" }, { - "cli_path": "devdoc article search", - "aliases": [ - "devdoc article-search" - ] + "cli_path": "devdoc article search" }, { - "cli_path": "devdoc error diagnose", - "aliases": [ - "devdoc error-diagnose" - ] + "cli_path": "devdoc error diagnose" } ] }, @@ -1126,10 +1104,7 @@ "id": "live", "tools": [ { - "cli_path": "live list", - "aliases": [ - "live stream list" - ] + "cli_path": "live stream list" } ] }, @@ -1199,15 +1174,11 @@ { "cli_path": "mail message search", "aliases": [ - "mail message list", - "mail search" + "mail message list" ] }, { - "cli_path": "mail message send", - "aliases": [ - "mail send" - ] + "cli_path": "mail message send" }, { "cli_path": "mail tag list" @@ -1398,8 +1369,7 @@ { "cli_path": "report entry get", "aliases": [ - "report detail", - "report get" + "report detail" ] }, { @@ -1411,14 +1381,12 @@ { "cli_path": "report entry submit", "aliases": [ - "report create", - "report submit" + "report create" ] }, { "cli_path": "report inbox list", "aliases": [ - "report inbox-list", "report list" ] }, @@ -1426,22 +1394,17 @@ "cli_path": "report outbox list", "aliases": [ "report created", - "report outbox-list", "report sent" ] }, { "cli_path": "report template get", "aliases": [ - "report template detail", - "report template-get" + "report template detail" ] }, { - "cli_path": "report template list", - "aliases": [ - "report template-list" - ] + "cli_path": "report template list" } ] }, @@ -1544,9 +1507,6 @@ { "cli_path": "sheet find" }, - { - "cli_path": "sheet get" - }, { "cli_path": "sheet get-dropdown" }, @@ -1604,9 +1564,6 @@ { "cli_path": "sheet range update" }, - { - "cli_path": "sheet read" - }, { "cli_path": "sheet replace" }, @@ -1655,10 +1612,7 @@ "cli_path": "todo task add-reminder" }, { - "cli_path": "todo task create", - "aliases": [ - "todo create" - ] + "cli_path": "todo task create" }, { "cli_path": "todo task create-sub" @@ -1667,19 +1621,13 @@ "cli_path": "todo task delete" }, { - "cli_path": "todo task done", - "aliases": [ - "todo done" - ] + "cli_path": "todo task done" }, { "cli_path": "todo task get" }, { - "cli_path": "todo task list", - "aliases": [ - "todo list" - ] + "cli_path": "todo task list" }, { "cli_path": "todo task remove-executor" @@ -1698,24 +1646,6 @@ { "id": "wiki", "tools": [ - { - "cli_path": "wiki doc list", - "aliases": [ - "wiki node list" - ] - }, - { - "cli_path": "wiki doc read" - }, - { - "cli_path": "wiki doc search", - "aliases": [ - "wiki file search" - ] - }, - { - "cli_path": "wiki file list" - }, { "cli_path": "wiki member add" }, @@ -1738,46 +1668,28 @@ "cli_path": "wiki node delete" }, { - "cli_path": "wiki node info" + "cli_path": "wiki node list" }, { "cli_path": "wiki node move" }, - { - "cli_path": "wiki node read" - }, { "cli_path": "wiki node search" }, { - "cli_path": "wiki space create", - "aliases": [ - "wiki create" - ] + "cli_path": "wiki space create" }, { - "cli_path": "wiki space delete", - "aliases": [ - "wiki delete" - ] + "cli_path": "wiki space delete" }, { - "cli_path": "wiki space get", - "aliases": [ - "wiki get" - ] + "cli_path": "wiki space get" }, { - "cli_path": "wiki space list", - "aliases": [ - "wiki list" - ] + "cli_path": "wiki space list" }, { - "cli_path": "wiki space search", - "aliases": [ - "wiki search" - ] + "cli_path": "wiki space search" } ] } diff --git a/internal/cli/schema_hints_visible_aliases.go b/internal/cli/schema_hints_visible_aliases.go index 17379325..b7cc0c3c 100644 --- a/internal/cli/schema_hints_visible_aliases.go +++ b/internal/cli/schema_hints_visible_aliases.go @@ -47,7 +47,10 @@ func init() { "contact search": "search_contact_by_key_word", "contact user list": "search_contact_by_key_word", }, - nil, + map[string]string{ + "get_user_info_by_user_ids": "contact user get", + "search_contact_by_key_word": "contact user search", + }, )) RegisterRuntimeSchemaRoot("devdoc", newRuntimeAliasRoot("hardcoded:devdoc", diff --git a/internal/compat/dynamic_commands.go b/internal/compat/dynamic_commands.go index dd8b8a00..92311165 100644 --- a/internal/compat/dynamic_commands.go +++ b/internal/compat/dynamic_commands.go @@ -222,6 +222,7 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run route.OutputTransform = buildOutputTransform(override.OutputFormat) cmd := NewDirectCommand(route, runner) + annotateRuntimePositionals(cmd, bindings) // Enrich flags with typed parameters from Detail API toolRequest JSON Schema. if dt, ok := detailIndex[toolName]; ok { @@ -994,6 +995,7 @@ func buildRedirectCommand(name, description, target string) *cobra.Command { Use: name, Short: short, Long: fmt.Sprintf("This command has moved. Please use: %s", target), + Hidden: true, DisableFlagParsing: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -1001,6 +1003,7 @@ func buildRedirectCommand(name, description, target string) *cobra.Command { return nil }, } + schemacli.ExcludeFromRuntimeSchema(cmd) return cmd } @@ -1028,6 +1031,7 @@ func buildHintCommand(name string, def market.CLIHintDef) *cobra.Command { Use: name, Short: short, Long: short, + Hidden: true, DisableFlagParsing: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -1039,6 +1043,7 @@ func buildHintCommand(name string, def market.CLIHintDef) *cobra.Command { return apperrors.NewValidation(fmt.Sprintf("use: %s", target)) }, } + schemacli.ExcludeFromRuntimeSchema(cmd) return cmd } @@ -1062,12 +1067,14 @@ func applyFlagConstraints(cmd *cobra.Command, override market.CLIToolOverride) { return valid } + constraints := schemacli.RuntimeSchemaConstraints{} for _, group := range override.MutuallyExclusive { names := validate(group) if len(names) < 2 { continue } cmd.MarkFlagsMutuallyExclusive(names...) + constraints.MutuallyExclusive = append(constraints.MutuallyExclusive, names) } for _, group := range override.RequireOneOf { names := validate(group) @@ -1075,6 +1082,55 @@ func applyFlagConstraints(cmd *cobra.Command, override market.CLIToolOverride) { continue } cmd.MarkFlagsOneRequired(names...) + constraints.RequireOneOf = append(constraints.RequireOneOf, names) + } + for _, group := range override.RequireTogether { + names := validate(group) + if len(names) < 2 { + continue + } + constraints.RequireTogether = append(constraints.RequireTogether, names) + } + schemacli.AnnotateRuntimeConstraints(cmd, constraints) +} + +func annotateRuntimePositionals(cmd *cobra.Command, bindings []FlagBinding) { + positionals := make([]schemacli.RuntimeSchemaPositional, 0) + for _, binding := range bindings { + if !binding.Positional { + continue + } + name := strings.TrimSpace(binding.Property) + if name == "" { + name = strings.TrimSpace(binding.FlagName) + } + hasFlagAlias := strings.TrimSpace(binding.Alias) != "" || + strings.TrimSpace(binding.FlagName) != "" || len(binding.Aliases) > 0 + positionals = append(positionals, schemacli.RuntimeSchemaPositional{ + Name: name, + Type: runtimeSchemaBindingType(binding.Kind), + Description: strings.TrimSpace(binding.Usage), + Required: binding.Required || !hasFlagAlias, + Index: binding.PositionalIndex, + }) + } + schemacli.AnnotateRuntimePositionals(cmd, positionals...) +} + +func runtimeSchemaBindingType(kind ValueKind) string { + switch kind { + case ValueInt: + return "integer" + case ValueFloat: + return "number" + case ValueBool: + return "boolean" + case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice: + return "array" + case ValueJSON: + return "object" + default: + return "string" } } diff --git a/internal/compat/dynamic_commands_test.go b/internal/compat/dynamic_commands_test.go index 441ec08b..a230eba2 100644 --- a/internal/compat/dynamic_commands_test.go +++ b/internal/compat/dynamic_commands_test.go @@ -979,6 +979,9 @@ func TestBuildDynamicCommands_RedirectTo(t *testing.T) { if history == nil { t.Fatal("history stub not found") } + if !history.Hidden { + t.Fatal("redirect stub must be hidden from command help") + } out := &strings.Builder{} history.SetOut(out) history.SetErr(out) @@ -1037,6 +1040,9 @@ func TestBuildDynamicCommands_Hints(t *testing.T) { if history == nil { t.Fatal("hint 'history' missing under chat root") } + if !history.Hidden { + t.Fatal("hint command must be hidden from command help") + } if history.Short != "migrated to `message list`" { t.Fatalf("hint description not applied, got %q", history.Short) } diff --git a/internal/helpers/attendance.go b/internal/helpers/attendance.go index 45ce69d6..9e112245 100644 --- a/internal/helpers/attendance.go +++ b/internal/helpers/attendance.go @@ -324,6 +324,8 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("user", "", "钉钉用户 ID(必填)") cmd.Flags().String("date", "", "工作日期,格式 yyyy-MM-dd HH:mm:ss,如 2026-03-12 15:00:00(必填)") cmd.Flags().String("stats-type", "", "统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求)") + annotateFlagExample(cmd, "date", "2026-03-12 15:00:00") + annotateFlagEnum(cmd, "stats-type", "week", "month") preferLegacyLeaf(cmd) return cmd } diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index dfca217b..a9732f70 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -555,6 +555,11 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("text", "", "消息内容 Markdown (必填)") cmd.Flags().String("title", "", "消息标题 (必填)") cmd.Flags().String("users", "", "接收者 userId 列表,逗号分隔,最多 20 个 (单聊必填)") + annotateFlagConstraints(cmd, + [][]string{{"group", "users"}}, + [][]string{{"group", "users"}}, + nil, + ) return cmd } @@ -595,11 +600,14 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command { } threadEnabled, _ := cmd.Flags().GetBool("thread") - currentUserID, err := getCurrentUserID(cmd.Context(), runner) - if err != nil { - return err + allMembers := memberUserIDs + if !commandDryRun(cmd) { + currentUserID, err := getCurrentUserID(cmd.Context(), runner) + if err != nil { + return err + } + allMembers = prependOwner(currentUserID, memberUserIDs) } - allMembers := prependOwner(currentUserID, memberUserIDs) // create_group_conversation (multi-type + thread support) and the // legacy create_internal_group live on two different MCP servers diff --git a/internal/helpers/chat_file.go b/internal/helpers/chat_file.go index 434e0444..8a4bb4c7 100644 --- a/internal/helpers/chat_file.go +++ b/internal/helpers/chat_file.go @@ -174,6 +174,18 @@ func newChatFileUploadCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("file-name", "", "文件名(可选)") cmd.Flags().String("md5", "", "文件 MD5(可选,本地不传自动计算)") cmd.Flags().String("uuid", "", "幂等 UUID(可选)") + annotateFlagFormat(cmd, "file", "file-path") + annotateFlagConstraints(cmd, + [][]string{ + {"group", "user", "open-dingtalk-id"}, + {"file", "url"}, + }, + [][]string{ + {"group", "user", "open-dingtalk-id"}, + {"file", "url"}, + }, + nil, + ) return cmd } diff --git a/internal/helpers/common.go b/internal/helpers/common.go index 52cab375..a671a74f 100644 --- a/internal/helpers/common.go +++ b/internal/helpers/common.go @@ -29,6 +29,30 @@ func preferLegacyLeaf(cmd *cobra.Command) { cli.SetOverridePriority(cmd, 100) } +func annotateRequiredFlags(cmd *cobra.Command, names ...string) { + cli.AnnotateRuntimeRequiredFlags(cmd, names...) +} + +func annotateFlagFormat(cmd *cobra.Command, name, format string) { + cli.AnnotateRuntimeFlagFormat(cmd, name, format) +} + +func annotateFlagEnum(cmd *cobra.Command, name string, values ...string) { + cli.AnnotateRuntimeFlagEnum(cmd, name, values...) +} + +func annotateFlagExample(cmd *cobra.Command, name, example string) { + cli.AnnotateRuntimeFlagExample(cmd, name, example) +} + +func annotateFlagConstraints(cmd *cobra.Command, mutuallyExclusive, requireOneOf, requireTogether [][]string) { + cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: mutuallyExclusive, + RequireOneOf: requireOneOf, + RequireTogether: requireTogether, + }) +} + // resolveStringFlag reads a string flag, resolves @file/@- input sources, // and falls back to stdin pipe when the flag is the designated primary // content flag and the user did not provide an explicit value. diff --git a/internal/helpers/connect_daemon.go b/internal/helpers/connect_daemon.go index 767a89a5..ce3225f4 100644 --- a/internal/helpers/connect_daemon.go +++ b/internal/helpers/connect_daemon.go @@ -497,6 +497,7 @@ func newDevAppRobotConnectStatusCommand() *cobra.Command { preferLegacyLeaf(cmd) cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)") cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)") + annotateFlagConstraints(cmd, nil, [][]string{{"robot-client-id", "unified-app-id"}}, nil) return cmd } @@ -520,6 +521,7 @@ func newDevAppRobotConnectStopCommand() *cobra.Command { preferLegacyLeaf(cmd) cmd.Flags().String("robot-client-id", "", "机器人 clientId(定位守护进程)") cmd.Flags().String("unified-app-id", "", "统一应用 ID(当未用 clientId 起守护进程时定位)") + annotateFlagConstraints(cmd, nil, [][]string{{"robot-client-id", "unified-app-id"}}, nil) return cmd } diff --git a/internal/helpers/devapp.go b/internal/helpers/devapp.go index 9b3a8614..9e6a8445 100644 --- a/internal/helpers/devapp.go +++ b/internal/helpers/devapp.go @@ -297,6 +297,7 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command { } addDevAppUnifiedIDFlag(cmd) cmd.Flags().String("event-codes", "", "事件码,多个用逗号或分号分隔") + annotateRequiredFlags(cmd, "event-codes") preferLegacyLeaf(cmd) annotateDevAppTool(cmd, devAppEventSubscribeTool) return cmd @@ -328,6 +329,7 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command { } addDevAppUnifiedIDFlag(cmd) cmd.Flags().String("event-codes", "", "事件码,多个用逗号或分号分隔") + annotateRequiredFlags(cmd, "event-codes") preferLegacyLeaf(cmd) annotateDevAppTool(cmd, devAppEventUnsubscribeTool) return cmd @@ -738,6 +740,7 @@ func newDevAppPermissionAddCommand(runner executor.Runner) *cobra.Command { } addDevAppUnifiedIDFlag(cmd) cmd.Flags().String("scope-values", "", "权限点 scopeValue,多个用逗号或分号分隔") + annotateRequiredFlags(cmd, "scope-values") preferLegacyLeaf(cmd) annotateDevAppTool(cmd, devAppPermissionAddTool) return cmd @@ -769,6 +772,7 @@ func newDevAppPermissionRemoveCommand(runner executor.Runner) *cobra.Command { } addDevAppUnifiedIDFlag(cmd) cmd.Flags().String("scope-values", "", "待取消权限点 scopeValue,多个用逗号或分号分隔") + annotateRequiredFlags(cmd, "scope-values") preferLegacyLeaf(cmd) annotateDevAppTool(cmd, devAppPermissionRmTool) return cmd diff --git a/internal/helpers/devdoc.go b/internal/helpers/devdoc.go index 10e9c772..5730830c 100644 --- a/internal/helpers/devdoc.go +++ b/internal/helpers/devdoc.go @@ -210,6 +210,9 @@ func newDevdocErrorDiagnoseCommand(runner executor.Runner) *cobra.Command { cmd.Flags().Int("page", 1, "分页页码 (从 1 开始,默认 1)") cmd.Flags().String("cursor", "", "分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page") cmd.Flags().Int("size", 10, "分页大小 (默认 10)") + annotateFlagConstraints(cmd, nil, [][]string{{ + "query", "request-id", "error-code", "error-message", "context", + }}, nil) return cmd } diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 9a36a489..a74a7d26 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -577,6 +577,13 @@ func newDocUpdateCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("content-format", "", i18n.T("内容格式: markdown / jsonml")) cmd.Flags().Int("revision", 0, i18n.T("文档编辑版本号(JSONML 场景透传)")) addDocJSONMLControlFlags(cmd) + annotateRequiredFlags(cmd, "mode") + annotateFlagEnum(cmd, "mode", "append", "overwrite") + annotateFlagConstraints(cmd, + [][]string{{"content", "content-file"}}, + [][]string{{"content", "content-file"}}, + nil, + ) return cmd } @@ -1059,6 +1066,7 @@ func newDocBlockInsertCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("content-format", "", i18n.T("输入格式: element / jsonml")) cmd.Flags().String("parent-block", "", i18n.T("父容器块 ID(JSONML 场景透传)")) addDocJSONMLControlFlags(cmd) + annotateFlagConstraints(cmd, nil, [][]string{{"text", "heading", "element", "type"}}, nil) return cmd } @@ -1122,6 +1130,7 @@ func newDocBlockUpdateCommand(runner executor.Runner) *cobra.Command { cmd.Flags().Int("level", 1, i18n.T("标题级别")) cmd.Flags().String("content-format", "", i18n.T("输入格式: element / jsonml")) addDocJSONMLControlFlags(cmd) + annotateFlagConstraints(cmd, nil, [][]string{{"text", "heading", "element", "type"}}, nil) return cmd } @@ -1534,6 +1543,7 @@ func addDocNodeFlags(cmd *cobra.Command) { addDocHiddenStringFlag(cmd, "node-id", "--node alias") addDocHiddenStringFlag(cmd, "doc-id", "--node alias") addDocHiddenStringFlag(cmd, "file-id", "--node alias") + annotateRequiredFlags(cmd, "node") } func addDocJSONMLControlFlags(cmd *cobra.Command) { @@ -2014,6 +2024,7 @@ func newDocExportGetCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("job-id", "", i18n.T("导出任务 ID (必填)")) cmd.Flags().String("output", "", i18n.T("本地落盘路径(可选,提供则自动下载 docx 到本地)")) cmd.Flags().Int("timeout-sec", 300, i18n.T("整体轮询超时(秒),默认 300")) + annotateFlagFormat(cmd, "job-id", "numeric-id") return cmd } @@ -2151,6 +2162,7 @@ func newDocPermissionAddCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("user", "", i18n.T("被授权用户 userId 列表,逗号分隔,单次最多 30 (必填)")) addDocHiddenStringFlag(cmd, "users", "--user alias") cmd.Flags().String("role", "", i18n.T("权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")) + annotateFlagEnum(cmd, "role", "MANAGER", "EDITOR", "DOWNLOADER", "READER") cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填,辅助构造返回的 docUrl)")) addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias") return cmd @@ -2180,6 +2192,7 @@ func newDocPermissionUpdateCommand(runner executor.Runner) *cobra.Command { addDocHiddenStringFlag(cmd, "users", "--user alias") addDocHiddenStringFlag(cmd, "uid", "--user alias") cmd.Flags().String("role", "", i18n.T("新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")) + annotateFlagEnum(cmd, "role", "MANAGER", "EDITOR", "DOWNLOADER", "READER") cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)")) addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias") return cmd diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index ae7a4cf8..dc9f2ae0 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -847,6 +847,7 @@ func newDrivePermissionMutationCommand(runner executor.Runner, use, tool string, addDriveHiddenStringFlag(cmd, "uid", "--users 的兼容别名") if requireRole { cmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)") + annotateFlagEnum(cmd, "role", "MANAGER", "EDITOR", "DOWNLOADER", "READER") } cmd.Flags().String("workspace", "", "知识库 ID (选填)") addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名") diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index 76745061..aadf9bb1 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -257,6 +257,9 @@ func newSheetUpdateCommand(runner executor.Runner) *cobra.Command { cmd.Flags().Int("frozen-row-count", 0, "冻结行数") cmd.Flags().Int("frozen-column-count", 0, "冻结列数") cmd.Flags().String("tab-color", "", "工作表标签颜色,Hex 格式如 #FF0000;传空字符串清除颜色") + annotateFlagConstraints(cmd, nil, [][]string{{ + "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color", + }}, nil) return cmd } @@ -617,6 +620,8 @@ func newSheetDimensionPositionCommand(runner executor.Runner, use, short, tool s cmd.Flags().String("dimension", "", "ROWS 或 COLUMNS (必填)") cmd.Flags().String("position", "", "位置,A1 表示法 (必填)") cmd.Flags().String("length", "", "数量,正整数 (必填)") + annotateFlagEnum(cmd, "dimension", "ROWS", "COLUMNS") + annotateFlagFormat(cmd, "length", "positive-integer") return cmd } @@ -664,6 +669,9 @@ func newSheetUpdateDimensionCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("length", "", "数量,正整数 (必填)") cmd.Flags().Bool("hidden", false, "是否隐藏") cmd.Flags().Int("pixel-size", 0, "行高或列宽") + annotateFlagEnum(cmd, "dimension", "ROWS", "COLUMNS") + annotateFlagFormat(cmd, "length", "positive-integer") + annotateFlagConstraints(cmd, nil, [][]string{{"hidden", "pixel-size"}}, nil) return cmd } @@ -690,6 +698,7 @@ func newSheetMoveDimensionCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("start-index", "", "源起始位置 (必填)") cmd.Flags().String("end-index", "", "源结束位置 (必填)") cmd.Flags().String("destination-index", "", "目标位置 (必填)") + annotateFlagEnum(cmd, "dimension", "ROWS", "COLUMNS") return cmd } @@ -716,6 +725,7 @@ func newSheetAddDimensionCommand(runner executor.Runner) *cobra.Command { addSheetBaseFlags(cmd) cmd.Flags().String("dimension", "", "ROWS 或 COLUMNS (必填)") cmd.Flags().Int("length", 0, "追加数量,正整数 (必填)") + annotateFlagEnum(cmd, "dimension", "ROWS", "COLUMNS") return cmd } @@ -774,6 +784,7 @@ func newSheetSetDropdownCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("range", "", "目标单元格范围 (必填)") cmd.Flags().String("options", "", "下拉选项 JSON 数组 (必填)") cmd.Flags().Bool("multi-select", false, "是否允许多选") + annotateFlagExample(cmd, "options", `[{"value":"SchemaSmoke"}]`) return cmd } @@ -960,6 +971,7 @@ func newSheetFilterViewUpdateCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("name", "", "筛选视图新名称") cmd.Flags().String("range", "", "筛选视图新范围") cmd.Flags().String("criteria", "", "筛选条件 JSON 数组") + annotateFlagConstraints(cmd, nil, [][]string{{"name", "range", "criteria"}}, nil) return cmd } @@ -1031,6 +1043,9 @@ func newSheetFilterViewReadCommand(runner executor.Runner, use, short, mode stri if err != nil { return err } + if commandDryRun(cmd) { + return writeCommandPayload(cmd, result) + } filterViews := sheetResultFilterViews(result.Response) // 不再对空列表提前返回成功:请求了具体 filter-view-id 却找不到(含一个都没有) // 应报"未找到"错误,而非静默成功(与 wukong 对齐)。 @@ -1095,6 +1110,7 @@ func newSheetCondFormatCreateCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("condition", "", "条件类型及参数 JSON 对象 (必填)") cmd.Flags().String("cell-style", "", "单元格样式 JSON 对象") cmd.Flags().String("data-bar-style", "", "数据条样式 JSON 对象") + annotateFlagExample(cmd, "ranges", `["A1:B2"]`) return cmd } @@ -1115,6 +1131,10 @@ func newSheetCondFormatUpdateCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("condition", "", "条件类型及参数 JSON 对象") cmd.Flags().String("cell-style", "", "单元格样式 JSON 对象") cmd.Flags().String("data-bar-style", "", "数据条样式 JSON 对象") + annotateFlagExample(cmd, "ranges", `["A1:B2"]`) + annotateFlagConstraints(cmd, nil, [][]string{{ + "ranges", "condition", "cell-style", "data-bar-style", + }}, nil) return cmd } @@ -1217,6 +1237,9 @@ func newSheetUpdateFloatImageCommand(runner executor.Runner) *cobra.Command { cmd.Flags().Int("height", 0, "新的图片高度") cmd.Flags().Int("offset-x", 0, "新的水平偏移量") cmd.Flags().Int("offset-y", 0, "新的垂直偏移量") + annotateFlagConstraints(cmd, nil, [][]string{{ + "src", "range", "width", "height", "offset-x", "offset-y", + }}, nil) return cmd } @@ -1410,21 +1433,25 @@ func addSheetNodeFlags(cmd *cobra.Command) { addSheetHiddenStringFlag(cmd, "node-id", "--node alias") addSheetHiddenStringFlag(cmd, "doc-id", "--node alias") addSheetHiddenStringFlag(cmd, "file-id", "--node alias") + annotateRequiredFlags(cmd, "node") } func addSheetBaseFlags(cmd *cobra.Command) { addSheetNodeFlags(cmd) cmd.Flags().String("sheet-id", "", "工作表 ID 或名称") + annotateRequiredFlags(cmd, "sheet-id") } func addSheetFilterViewBaseFlags(cmd *cobra.Command) { addSheetBaseFlags(cmd) cmd.Flags().String("filter-view-id", "", "筛选视图 ID") + annotateRequiredFlags(cmd, "filter-view-id") } func addSheetFloatImageBaseFlags(cmd *cobra.Command) { addSheetBaseFlags(cmd) cmd.Flags().String("float-image-id", "", "浮动图片 ID") + annotateRequiredFlags(cmd, "float-image-id") } func addSheetRangeTransferFlags(cmd *cobra.Command) { @@ -1963,6 +1990,8 @@ func newSheetRangeSetStyleCommand(runner executor.Runner) *cobra.Command { addSheetBaseFlags(cmd) cmd.Flags().String("range", "", "目标单元格区域地址 (必填)") bindSheetStyleFlags(cmd) + annotateFlagFormat(cmd, "range", "a1-range") + annotateSheetStyleConstraints(cmd) return cmd } @@ -2034,9 +2063,30 @@ func newSheetRangeBatchSetStyleCommand(runner executor.Runner) *cobra.Command { addSheetNodeFlags(cmd) cmd.Flags().String("batch", "", "批次配置 JSON 文件路径 (必填)") cmd.Flags().Bool("continue-on-error", false, "遇到失败时继续执行") + annotateFlagFormat(cmd, "batch", "file-path") return cmd } +func annotateSheetStyleConstraints(cmd *cobra.Command) { + annotateFlagConstraints(cmd, + [][]string{ + {"bg-color", "bg-colors-json"}, + {"font-size", "font-sizes-json"}, + {"h-align", "h-aligns-json"}, + {"v-align", "v-aligns-json"}, + {"font-color", "font-colors-json"}, + {"font-weight", "font-weights-json"}, + }, + [][]string{{ + "bg-color", "bg-colors-json", "font-size", "font-sizes-json", + "h-align", "h-aligns-json", "v-align", "v-aligns-json", + "font-color", "font-colors-json", "font-weight", "font-weights-json", + "word-wrap", "number-format", + }}, + nil, + ) +} + func bindSheetStyleFlags(cmd *cobra.Command) { cmd.Flags().String("bg-color", "", "背景色") cmd.Flags().String("bg-colors-json", "", "背景色二维 JSON 数组") diff --git a/internal/helpers/wiki.go b/internal/helpers/wiki.go index 692fe3bc..5f35f89f 100644 --- a/internal/helpers/wiki.go +++ b/internal/helpers/wiki.go @@ -226,6 +226,8 @@ func newWikiSpaceSearchCommand(runner executor.Runner) *cobra.Command { addWikiHiddenStringFlag(cmd, "keyword", "--query 的兼容别名") cmd.Flags().String("type", "", "知识库类型;仅 --type myWikiSpace 支持无 keyword 查询个人知识库") cmd.Flags().String("limit", "", "返回数量 1-20 (默认 10)") + annotateFlagEnum(cmd, "type", "myWikiSpace") + annotateFlagConstraints(cmd, nil, [][]string{{"query", "type"}}, nil) return cmd } diff --git a/internal/helpers/wiki_proxy.go b/internal/helpers/wiki_proxy.go index f7811610..3084fc1e 100644 --- a/internal/helpers/wiki_proxy.go +++ b/internal/helpers/wiki_proxy.go @@ -17,6 +17,7 @@ import ( "fmt" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/spf13/cobra" ) @@ -54,12 +55,16 @@ func addWikiProxyCommands(root *cobra.Command, runner executor.Runner) { ) file := newWikiProxyGroup("file", "知识库文件兼容入口") + file.Hidden = true + cli.ExcludeFromRuntimeSchema(file) file.AddCommand( newWikiProxyLeaf(runner, "list", wikiProxyTargetDoc, []string{"list"}, wikiProxyOptions{}), newWikiProxyLeaf(runner, "search", wikiProxyTargetDoc, []string{"search"}, wikiProxyOptions{workspaceToWorkspaceIDs: true}), ) doc := newWikiProxyGroup("doc", "知识库文档兼容入口") + doc.Hidden = true + cli.ExcludeFromRuntimeSchema(doc) doc.AddCommand( newWikiProxyLeaf(runner, "list", wikiProxyTargetDoc, []string{"list"}, wikiProxyOptions{}), newWikiProxyLeaf(runner, "read", wikiProxyTargetDoc, []string{"read"}, wikiProxyOptions{}), @@ -88,6 +93,7 @@ func newWikiProxyLeaf(runner executor.Runner, use string, target wikiProxyTarget cmd := &cobra.Command{ Use: use, Short: "兼容入口,透明转发到新命令", + Hidden: true, DisableFlagParsing: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -99,6 +105,7 @@ func newWikiProxyLeaf(runner executor.Runner, use string, target wikiProxyTarget }, } preferLegacyLeaf(cmd) + cli.ExcludeFromRuntimeSchema(cmd) return cmd } @@ -187,4 +194,7 @@ func configureWikiProxyTargetRoot(source, root *cobra.Command) { if root.PersistentFlags().Lookup("dry-run") == nil { root.PersistentFlags().Bool("dry-run", false, "仅打印即将发送的请求,不真正执行") } + if root.PersistentFlags().Lookup("yes") == nil { + root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示 (AI Agent 模式)") + } } diff --git a/internal/helpers/wiki_proxy_test.go b/internal/helpers/wiki_proxy_test.go index 08c6c53b..98074b50 100644 --- a/internal/helpers/wiki_proxy_test.go +++ b/internal/helpers/wiki_proxy_test.go @@ -20,6 +20,30 @@ import ( "testing" ) +func TestWikiCompatibilityProxiesAreHidden(t *testing.T) { + t.Parallel() + + root := wikiHandler{}.Command(&wikiCommandRunner{}) + for _, name := range []string{"list", "search", "create", "get", "delete"} { + proxy, _, err := root.Find([]string{name}) + if err != nil || proxy == nil || proxy.Name() != name { + t.Fatalf("proxy %q not found: command=%v error=%v", name, proxy, err) + } + if !proxy.Hidden { + t.Fatalf("proxy %q must be hidden from command help", name) + } + } + for _, name := range []string{"file", "doc"} { + group, _, err := root.Find([]string{name}) + if err != nil || group == nil || group.Name() != name { + t.Fatalf("proxy group %q not found: command=%v error=%v", name, group, err) + } + if !group.Hidden { + t.Fatalf("proxy group %q must be hidden from command help", name) + } + } +} + func TestWikiTopLevelListProxyExecutesSpaceList(t *testing.T) { t.Parallel() diff --git a/internal/pat/browser_policy.go b/internal/pat/browser_policy.go index 5667c9fc..6ca3ef9b 100644 --- a/internal/pat/browser_policy.go +++ b/internal/pat/browser_policy.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/cobra" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "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/pkg/edition" @@ -224,5 +225,6 @@ func newBrowserPolicyCommand() *cobra.Command { cmd.Flags().Bool("enabled", false, "PAT 撞墙时是否允许本地打开浏览器") cmd.Flags().String("agentCode", "", "Agent 唯一标识(可选;不填则写入全局默认策略,不从 env DINGTALK_DWS_AGENTCODE 回退)") + cli.AnnotateRuntimeRequiredFlags(cmd, "enabled") return cmd } diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 30c03f35..2a42b541 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -28,6 +28,7 @@ import ( "github.com/spf13/cobra" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -412,6 +413,18 @@ agentCode 配置: chmodCmd.Flags().StringSliceVar(&domainsFlag, "domains", nil, "产品域/产品编码列表,逗号分隔;执行批量授权需 --yes") chmodCmd.Flags().BoolVar(&recommend, "recommend", false, "使用推荐 scope 集合批量授权;执行授权需 --yes") + cli.AnnotateRuntimeFlagEnum(chmodCmd, "grant-type", "once", "session", "permanent") + cli.AnnotateRuntimePositionals(chmodCmd, cli.RuntimeSchemaPositional{ + Name: "scope", + Type: "array", + Description: "权限 scope,格式为 .:;可重复", + Variadic: true, + Index: 0, + }) + cli.AnnotateRuntimeConstraints(chmodCmd, cli.RuntimeSchemaConstraints{ + RequireOneOf: [][]string{{"scope", "product", "products", "domain", "domains", "recommend"}}, + }) + return chmodCmd } diff --git a/scripts/dev/schema_command_smoke.py b/scripts/dev/schema_command_smoke.py index 7cfe13ae..7f8ae2a6 100644 --- a/scripts/dev/schema_command_smoke.py +++ b/scripts/dev/schema_command_smoke.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""Run every `dws schema` leaf command in dry-run mode. +"""Run every executable `dws schema --all` leaf in dry-run mode. -The script treats `dws schema --format json` as the command inventory, expands +The script treats `dws schema --all --format json` as the command inventory, expands each leaf schema, synthesizes flag values from parameter metadata, and executes the runnable CLI path with `--dry-run --yes --format json`. """ @@ -74,6 +74,21 @@ def kebab_to_words(value: str) -> str: def scalar_value(flag: str, param: dict[str, Any], canonical_path: str) -> str: + enum = param.get("enum") or [] + if enum: + return str(enum[0]) + + if param.get("example") not in (None, ""): + return str(param["example"]) + + value_format = str(param.get("format", "")).lower() + if value_format in {"numeric-id", "positive-integer"}: + return "1" + if value_format == "a1-range": + return "A1:B2" + if value_format == "file-path": + return str(Path(tempfile.gettempdir()) / "dws-schema-smoke-fixture.txt") + if "default" in param and param["default"] not in (None, ""): return str(param["default"]) @@ -106,6 +121,8 @@ def scalar_value(flag: str, param: dict[str, Any], canonical_path: str) -> str: return "leave" if canonical_path == "attendance.save_self_setting" and flag == "setting-scene": return "checkResultNotify" + if canonical_path == "attendance.get_attendance_summary" and flag == "date": + return "2026-07-09 10:00:00" if "json" in haystack: if "array" in haystack or "数组" in haystack: return "[]" @@ -160,6 +177,11 @@ def scalar_value(flag: str, param: dict[str, Any], canonical_path: str) -> str: def value_for(flag: str, param: dict[str, Any], canonical_path: str) -> str | None: + if canonical_path == "pat.batch_grant" and flag == "scope": + return "aitable.record:read" + if canonical_path == "sheet.range_batch_set_style" and flag == "batch": + return str(Path(tempfile.gettempdir()) / "dws-schema-smoke-style.json") + typ = str(param.get("type", "string")).lower() prop = str(param.get("property", "")) haystack = " ".join([kebab_to_words(flag), kebab_to_words(prop)]) @@ -195,15 +217,88 @@ def smoke_extra_flags(canonical_path: str) -> set[str]: }.get(canonical_path, set()) +def constraint_groups(leaf: dict[str, Any], name: str) -> list[list[str]]: + constraints = leaf.get("constraints") or {} + groups = constraints.get(name) or [] + return [[str(item) for item in group] for group in groups if group] + + +def selected_inputs(leaf: dict[str, Any], include_optional: bool) -> tuple[set[str], set[str]]: + params = leaf.get("parameters") or {} + positionals = { + str(item.get("name")): item + for item in (leaf.get("positionals") or []) + if item.get("name") + } + selected_params = { + name for name, param in params.items() + if include_optional or bool((param or {}).get("required")) + } + selected_positionals = { + name for name, positional in positionals.items() + if bool((positional or {}).get("required")) + } + + def is_selected(name: str) -> bool: + return name in selected_params or name in selected_positionals + + def select(name: str) -> bool: + if name in params: + selected_params.add(name) + return True + if name in positionals: + selected_positionals.add(name) + return True + return False + + for group in constraint_groups(leaf, "require_one_of"): + if any(is_selected(name) for name in group): + continue + for name in group: + if select(name): + break + + changed = True + while changed: + changed = False + for group in constraint_groups(leaf, "require_together"): + if not any(is_selected(name) for name in group): + continue + for name in group: + if not is_selected(name) and select(name): + changed = True + + for group in constraint_groups(leaf, "mutually_exclusive"): + selected = [name for name in group if is_selected(name)] + for name in selected[1:]: + selected_params.discard(name) + selected_positionals.discard(name) + + return selected_params, selected_positionals + + def build_command(binary: str, leaf: dict[str, Any], include_optional: bool) -> list[str]: cli_path = str(leaf["cli_path"]) canonical_path = str(leaf.get("canonical_path", "")) extra_flags = smoke_extra_flags(canonical_path) argv = [binary, *shlex.split(cli_path)] params = leaf.get("parameters") or {} + selected_params, selected_positionals = selected_inputs(leaf, include_optional) + + positionals = sorted( + (leaf.get("positionals") or []), + key=lambda item: int(item.get("index", 0)), + ) + for positional in positionals: + name = str(positional.get("name", "")) + if name not in selected_positionals: + continue + value = value_for(name, positional, canonical_path) + argv.append("" if value is None else value) + for flag in sorted(params): param = params[flag] or {} - if not include_optional and not bool(param.get("required")) and flag not in extra_flags: + if flag not in selected_params and flag not in extra_flags: continue if str(param.get("type", "")).lower() == "boolean": argv.append(f"--{flag}") @@ -361,11 +456,16 @@ def main() -> int: cwd = Path.cwd() fixture = Path(tempfile.gettempdir()) / "dws-schema-smoke-fixture.txt" fixture.write_text("schema smoke fixture\n", encoding="utf-8") + style_fixture = Path(tempfile.gettempdir()) / "dws-schema-smoke-style.json" + style_fixture.write_text( + '[{"sheetId":"sheet-smoke","range":"A1","fontSize":12}]\n', + encoding="utf-8", + ) if args.path: paths = args.path else: - listing = run_json([args.binary, "schema", "--format", "json"], cwd, args.timeout) + listing = run_json([args.binary, "schema", "--all", "--format", "json"], cwd, args.timeout) paths = [tool["canonical_path"] for product in listing["products"] for tool in product["tools"]] with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as executor: