// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package helpers import ( "encoding/json" "fmt" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" "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/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) const ( devAppProduct = "devapp" // 工具名 = 服务端 op-app 网关**实际注册**的名字(已用 tools 真实联调逐个核对)。 // 注意:服务端这批命名本身并不统一——前缀 dev_app 与 extension 混用、list 用复数、 // permission 用 apply 而 member 用 add、robot 建号流程保留旧名(submit_robot_create_task/ // query_robot_create_result)。CLI 这里**对齐服务端现状以跑通联调**,不在 CLI 做映射; // 命名统一是服务端待办,详见 obsidian《dev 命令树 - 服务端 MCP 待改造清单》。 // 集中声明、调用点不写字面量,避免拼写漂移。 // // 应用主体 + 子资源(凭证/网页/权限)。 devAppListTool = "list_dev_app" devAppGetTool = "get_dev_app" devAppCreateTool = "create_dev_app" devAppUpdateTool = "update_dev_app" devAppDeleteTool = "delete_dev_app" devAppEnableTool = "enable_dev_app" devAppDisableTool = "disable_dev_app" devAppCredentialsGetTool = "get_dev_app_credentials" devAppWebappGetTool = "get_extension_webapp_config" devAppWebappSetTool = "set_extension_webapp_config" devAppPermissionListTool = "list_dev_app_permissions" devAppPermissionAddTool = "apply_dev_app_permissions" devAppPermissionRmTool = "remove_dev_app_permissions" devAppMemberListTool = "list_dev_app_members" devAppMemberAddTool = "add_dev_app_members" devAppMemberRemoveTool = "remove_dev_app_members" devAppSecurityConfigTool = "update_dev_app_security_config" // 机器人能力(op-app MCP 工具,硬编码不走服务发现)。 devAppRobotSubmitTool = "submit_robot_create_task" devAppRobotResultTool = "query_robot_create_result" devAppRobotConfigGetTool = "get_extension_robot_config" // 上游待合并:create/update 两个 tool 合成一个 upsert(建/改判断在服务端)。 // 见 docs/upstream-todo.md。上游上线前 CLI 调此名待联调。 devAppRobotConfigUpsertTool = "set_extension_robot_config" devAppRobotEnableTool = "enable_dev_app_robot" devAppRobotOfflineTool = "disable_dev_app_robot" // 事件订阅能力(op-app MCP 工具,服务端新增)。 devAppEventListTool = "list_dev_app_events" devAppEventSubscribeTool = "subscribe_dev_app_events" devAppEventUnsubscribeTool = "unsubscribe_dev_app_events" // 版本发布能力(op-app MCP 工具,硬编码不走服务发现)。 devAppVersionCreateTool = "create_dev_app_version" devAppVersionListTool = "list_dev_app_versions" devAppVersionDetailTool = "get_dev_app_version_detail" devAppVersionPublishTool = "publish_dev_app_version" devAppVersionStatusTool = "get_dev_app_version_status" ) func devAppPaginatedItemsResult(description, itemDescription string) *contract.ResultSpec { schema, _ := json.Marshal(map[string]any{ "type": "object", "description": description, "additionalProperties": true, "properties": map[string]any{ "items": map[string]any{ "type": "array", "description": itemDescription + ";分页控制信息只读取 meta.pagination", "items": map[string]any{ "type": "object", "additionalProperties": true, }, }, }, "required": []string{"items"}, }) return &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, DataSchema: schema, } } func devAppCursorPagination() *contract.PaginationSpec { return &contract.PaginationSpec{ Kind: contract.PaginationKindCursor, CursorParameter: "cursor", } } // newDevAppCommand builds the `app` subtree of `dws dev`. The cobra path is // `dws dev app ...` while the MCP product id stays "devapp" — the id is a // backend contract (SupplementServers/StaticServers injection key and the // pinned op-app endpoint), decoupled from the user-facing command name. func newDevAppCommand(runner executor.Runner) *cobra.Command { // Product-level Agent routing Decl (migrated from selection/devapp.json // products.devapp). Catalog assembly stamps provenance contract_final. contract.RegisterProductDecl(contract.ProductDecl{ ID: "devapp", Selection: contract.ProductSelectionDecl{ AgentSummary: "管理钉钉开放平台企业内部应用、成员、权限、机器人、事件与版本", UseWhen: []string{ "请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理", }, AvoidWhen: []string{ "个人 IM/OA 实时事件监听使用 event;开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令", }, }, }) root := &cobra.Command{ Use: "app", Short: "开放平台应用", Long: "管理开放平台开发者应用:查询、详情、创建、更新、启停、删除、权限、网页应用、成员、安全配置、机器人、版本发布和事件订阅。", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } cmdutil.MarkGroup(root) webapp := &cobra.Command{ Use: "webapp", Short: "开放平台网页应用配置", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } cmdutil.MarkGroup(webapp) webapp.AddCommand( newDevAppWebappGetCommand(runner), newDevAppWebappConfigCommand(runner), ) permission := &cobra.Command{ Use: "permission", Short: "开放平台应用权限", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } cmdutil.MarkGroup(permission) permission.AddCommand( newDevAppPermissionListCommand(runner), newDevAppPermissionAddCommand(runner), newDevAppPermissionRemoveCommand(runner), ) credentials := &cobra.Command{ Use: "credentials", Short: "开放平台应用凭证", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } cmdutil.MarkGroup(credentials) credentials.AddCommand(newDevAppCredentialsGetCommand(runner)) member := &cobra.Command{ Use: "member", Short: "开放平台应用成员管理", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } cmdutil.MarkGroup(member) member.AddCommand( newDevAppMemberListCommand(runner), newDevAppMemberAddCommand(runner), newDevAppMemberRemoveCommand(runner), ) security := &cobra.Command{ Use: "security", Short: "开放平台应用安全设置", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } cmdutil.MarkGroup(security) security.AddCommand(newDevAppSecurityConfigCommand(runner)) robot := &cobra.Command{ Use: "robot", Short: "开放平台应用机器人能力", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } cmdutil.MarkGroup(robot) robot.AddCommand( newDevAppRobotSubmitCommand(runner), newDevAppRobotResultCommand(runner), newDevAppRobotConfigGetCommand(runner), newDevAppRobotConfigCommand(runner), newDevAppRobotEnableCommand(runner), newDevAppRobotOfflineCommand(runner), ) version := &cobra.Command{ Use: "version", Short: "开放平台应用版本发布", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } cmdutil.MarkGroup(version) version.AddCommand( newDevAppVersionCreateCommand(runner), newDevAppVersionListCommand(runner), newDevAppVersionGetCommand(runner), newDevAppVersionCheckApprovalCommand(runner), newDevAppVersionPublishCommand(runner), newDevAppVersionStatusCommand(runner), ) event := &cobra.Command{ Use: "event", Short: "开放平台应用事件订阅", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } cmdutil.MarkGroup(event) event.AddCommand( newDevAppEventListCommand(runner), newDevAppEventSubscribeCommand(runner), newDevAppEventUnsubscribeCommand(runner), ) root.AddCommand( newDevAppListCommand(runner), newDevAppGetCommand(runner), newDevAppCreateCommand(runner), newDevAppUpdateCommand(runner), newDevAppDeleteCommand(runner), newDevAppDisableCommand(runner), newDevAppEnableCommand(runner), credentials, webapp, permission, member, security, robot, version, event, ) return root } // --------------------------------------------------------------------------- // 事件订阅能力(服务端新增 list/subscribe/unsubscribe) // --------------------------------------------------------------------------- func newDevAppEventListCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "list", Short: "查询应用已订阅的事件列表", Example: " dws dev app event list --unified-app-id UNIFIED_APP_ID --page-size 20 --format json", Tool: devAppEventListTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "keyword", Usage: "事件搜索关键词,支持按事件码或事件名称模糊匹配", Bind: "keyword", Trim: true, OmitEmpty: true}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "list_dev_app_events", CanonicalPath: "dev.list_dev_app_events", CLIPath: "dev app event list", PrimaryCLIPath: "dev app event list", }, Description: "查询应用已订阅的事件列表", DryRun: devAppDryRun, Result: devAppPaginatedItemsResult("当前页应用订阅事件查询结果", "当前页事件记录"), Pagination: devAppCursorPagination(), Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "列出或搜索应用可订阅的事件", UseWhen: []string{"需要查事件码、事件名称或当前事件列表时"}, AvoidWhen: []string{"订阅或退订应用回调事件使用对应写命令;监听当前用户个人 IM/OA 事件使用 event"}, Examples: []string{`dws dev app event list --unified-app-id --keyword "审批" --page-size 20`}, }, }, Call: devAppCallCursor(runner), PostMount: devAppMetaCursor(devAppEventListTool), }) } func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "subscribe", Short: "订阅应用事件回调", Example: " dws dev app event subscribe --unified-app-id UNIFIED_APP_ID --event-codes bpms_task_change --dry-run --format json", Tool: devAppEventSubscribeTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "event-codes", Usage: "事件码,多个用逗号或分号分隔", Bind: "eventCodes", Trim: true, Required: true, RequiredHint: "--event-codes 为必填", Transform: transformDevAppListParam}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "subscribe_dev_app_events", CanonicalPath: "dev.subscribe_dev_app_events", CLIPath: "dev app event subscribe", PrimaryCLIPath: "dev app event subscribe", }, Description: "订阅应用事件回调", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "为应用订阅指定事件码", UseWhen: []string{"已确认事件码并需要新增事件订阅时"}, AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list;个人 IM/OA 事件长连接监听使用 event"}, Examples: []string{"dws dev app event subscribe --unified-app-id --event-codes bpms_task_change --dry-run"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppEventSubscribeTool), }) } func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "unsubscribe", Short: "取消订阅应用事件", Example: " dws dev app event unsubscribe --unified-app-id UNIFIED_APP_ID --event-codes bpms_task_change --dry-run --format json", Tool: devAppEventUnsubscribeTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "event-codes", Usage: "事件码,多个用逗号或分号分隔", Bind: "eventCodes", Trim: true, Required: true, RequiredHint: "--event-codes 为必填", Transform: transformDevAppListParam}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "unsubscribe_dev_app_events", CanonicalPath: "dev.unsubscribe_dev_app_events", CLIPath: "dev app event unsubscribe", PrimaryCLIPath: "dev app event unsubscribe", }, Description: "取消订阅应用事件", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "取消应用的指定事件订阅", UseWhen: []string{"需要停止接收一个或多个已订阅事件时"}, AvoidWhen: []string{"只是查看应用事件订阅时使用 dev app event list;停止个人事件监听使用 event stop"}, Examples: []string{"dws dev app event unsubscribe --unified-app-id --event-codes bpms_task_change --dry-run"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppEventUnsubscribeTool), }) } func newDevAppListCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "list", Short: "查询开放平台企业内部应用列表", Example: " dws dev app list --name DemoApp --page-size 20 --format json", Tool: devAppListTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "name", Usage: "应用名称关键词", Bind: "name", Trim: true, OmitEmpty: true, Aliases: []string{"keyword"}}, {Name: "app-key", Usage: "按 appKey/clientId 过滤", Bind: "appKey", Trim: true, OmitEmpty: true}, {Name: "app-group-id", Usage: "应用分组 ID", Kind: LeafInt, Bind: "appGroupId"}, {Name: "creator", Usage: "创建人名称关键词", Bind: "creator", Trim: true, OmitEmpty: true}, {Name: "robot-name", Usage: "机器人名称关键词", Bind: "robotName", Trim: true, OmitEmpty: true}, {Name: "develop-type", Usage: "开发类型枚举;不确定时不要传", Kind: LeafInt, Bind: "developType"}, {Name: "filter-cool-app", Usage: "酷应用过滤枚举;不确定时不要传", Kind: LeafInt, Bind: "filterCoolApp"}, {Name: "sort-type", Usage: "排序字段,如 gmt_modified", Bind: "sortType", Trim: true, OmitEmpty: true}, {Name: "sort-order", Usage: "排序方向 asc 或 desc", Bind: "sortOrder", Trim: true, OmitEmpty: true}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "list_dev_app", CanonicalPath: "dev.list_dev_app", CLIPath: "dev app list", PrimaryCLIPath: "dev app list", }, Description: "查询开放平台企业内部应用列表", DryRun: devAppDryRun, Result: &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"当前页应用查询结果","properties":{"items":{"type":"array","description":"当前页应用记录","items":{"type":"object","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"name":{"type":"string","description":"应用名称"},"appKey":{"type":"string","description":"应用 AppKey"}},"additionalProperties":true}}},"required":["items"],"additionalProperties":true}`), }, Pagination: devAppCursorPagination(), Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "按条件分页查询开放平台应用", UseWhen: []string{"需要按名称、创建人或应用键筛选应用时"}, AvoidWhen: []string{"已经持有明确 unifiedAppId 时使用 dev app get"}, Examples: []string{`dws dev app list --name "DemoApp" --page-size 20 --format json`}, }, }, Call: devAppCallCursor(runner), PostMount: devAppMetaCursor(devAppListTool), }) } func newDevAppGetCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "get", Short: "查询开放平台企业内部应用详情", Example: " dws dev app get --unified-app-id UNIFIED_APP_ID --format json\n dws dev app get --app-key APP_KEY --format json", Tool: devAppGetTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(与 --app-key 二选一)", Bind: "unifiedAppId", Trim: true, OmitEmpty: true}, {Name: "app-key", Usage: "按 appKey/clientId 查询应用详情(与 --unified-app-id 二选一)", Bind: "appKey", Trim: true, OmitEmpty: true}, }, // 二选一走 Validate 而非类型化 Constraints:发布 constraints 会改变已 // 交付的 Schema 契约(merge-base 为 null),本 PR 承诺零契约变更。 // 声明化发布留给独立的契约变更 PR。 Validate: func(cmd *cobra.Command, args []string) error { if devAppStringFlag(cmd, "unified-app-id") == "" && devAppStringFlag(cmd, "app-key") == "" { return apperrors.NewValidation("请传入 --unified-app-id 或 --app-key") } return nil }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "get_dev_app", CanonicalPath: "dev.get_dev_app", CLIPath: "dev app get", PrimaryCLIPath: "dev app get", }, Description: "查询开放平台企业内部应用详情", DryRun: devAppDryRun, Result: &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, DataSchema: json.RawMessage(`{"type":"object","description":"开放平台企业内部应用详情","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"name":{"type":"string","description":"应用名称"},"appKey":{"type":"string","description":"应用 AppKey"},"agentId":{"description":"应用 Agent ID;具体类型由服务端返回"},"status":{"description":"应用当前状态;具体类型由服务端返回"}},"required":["unifiedAppId"],"additionalProperties":true}`), }, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "获取指定开放平台应用详情", UseWhen: []string{"已知 unifiedAppId 或 appKey 并需要核对应用配置或状态时"}, AvoidWhen: []string{"需要搜索或分页浏览多个应用时使用 dev app list"}, Examples: []string{ "dws dev app get --unified-app-id --format json", "dws dev app get --app-key --format json", }, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppGetTool), }) } func newDevAppCreateCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "create", Short: "创建开放平台企业内部应用", Example: " dws dev app create --name DemoApp --desc 内部应用 --dry-run --format json", Tool: devAppCreateTool, Safety: devAppSafetyHighWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "name", Usage: "应用名称 (必填)", Bind: "name", Trim: true, Required: true, RequiredHint: "--name 为必填"}, {Name: "desc", Usage: "应用描述", Bind: "desc", Trim: true, OmitEmpty: true}, {Name: "icon-media-id", Usage: "应用图标 mediaId", Bind: "iconMediaId", Trim: true, OmitEmpty: true}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "create_dev_app", CanonicalPath: "dev.create_dev_app", CLIPath: "dev app create", PrimaryCLIPath: "dev app create", }, Description: "创建开放平台企业内部应用", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "创建钉钉开放平台应用并返回 unifiedAppId/凭证信息", UseWhen: []string{"需要新建企业内部应用或三方个人应用并拿到 unifiedAppId"}, AvoidWhen: []string{"应用已存在只需改信息时用 dev app update", "只查文档时用 devdoc"}, Examples: []string{`dws dev app create --name "我的 AI 机器人" --desc "接 opencode" --dry-run --format json`}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppCreateTool), }) } func newDevAppUpdateCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "update", Short: "修改开放平台企业内部应用基础信息", Long: "修改开放平台企业内部应用基础信息:名称、描述或图标至少提供一项。", Example: " dws dev app update --unified-app-id UNIFIED_APP_ID --name DemoApp2 --dry-run --format json", Tool: devAppUpdateTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "name", Usage: "新的应用名称", Bind: "name", Trim: true, OmitEmpty: true}, {Name: "desc", Usage: "新的应用描述", Bind: "desc", Trim: true, OmitEmpty: true}, {Name: "icon-media-id", Usage: "新的应用图标 mediaId", Bind: "iconMediaId", Trim: true, OmitEmpty: true}, }, // 至少一项走 Validate 而非类型化 Constraints:零契约变更(见 get 命令注释)。 Validate: func(cmd *cobra.Command, args []string) error { if devAppStringFlag(cmd, "name") == "" && devAppStringFlag(cmd, "desc") == "" && devAppStringFlag(cmd, "icon-media-id") == "" { return apperrors.NewValidation("至少提供一项待更新字段:--name、--desc 或 --icon-media-id") } return nil }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "update_dev_app", CanonicalPath: "dev.update_dev_app", CLIPath: "dev app update", PrimaryCLIPath: "dev app update", }, Description: "修改开放平台企业内部应用基础信息", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "更新开放平台应用名称/描述/图标等基础信息", UseWhen: []string{"已有 unifiedAppId,需要修改应用基础信息"}, AvoidWhen: []string{"创建新应用用 create;停用/启用用 disable/enable;改安全配置用 security config"}, Examples: []string{"dws dev app update --unified-app-id --name <新名称> --dry-run --format json"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppUpdateTool), }) } func newDevAppCredentialsGetCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "get", Short: "读取开放平台应用凭证", Example: " dws dev app credentials get --unified-app-id UNIFIED_APP_ID --format json", Tool: devAppCredentialsGetTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "get_dev_app_credentials", CanonicalPath: "dev.get_dev_app_credentials", CLIPath: "dev app credentials get", PrimaryCLIPath: "dev app credentials get", }, Description: "读取开放平台应用凭证", DryRun: devAppDryRun, Result: &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, DataSchema: json.RawMessage(`{"type":"object","description":"开放平台应用凭证;敏感字段必须经过统一脱敏策略","properties":{"clientId":{"type":"string","description":"OAuth 客户端 ID"},"clientSecret":{"type":"string","description":"OAuth 客户端密钥"},"appKey":{"type":"string","description":"应用 AppKey"},"appSecret":{"type":"string","description":"应用 AppSecret"}},"additionalProperties":true}`), SensitivePaths: []string{"appSecret", "clientSecret"}, }, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "读取指定应用的客户端凭证", UseWhen: []string{"已知 unifiedAppId 且需要 clientId 或 clientSecret 时"}, AvoidWhen: []string{"不要把凭证内容用于普通应用详情查询或写入日志"}, Examples: []string{"dws dev app credentials get --unified-app-id --format json"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppCredentialsGetTool), }) } func newDevAppDisableCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "disable", Short: "停用开放平台企业内部应用", Tool: devAppDisableTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "disable_dev_app", CanonicalPath: "dev.disable_dev_app", CLIPath: "dev app disable", PrimaryCLIPath: "dev app disable", }, Description: "停用开放平台企业内部应用", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "停用指定开放平台应用", UseWhen: []string{"需要让应用暂时不可用但保留配置时"}, AvoidWhen: []string{"永久删除应用使用 dev app delete"}, Examples: []string{"dws dev app disable --unified-app-id --dry-run --format json"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppDisableTool), }) } func newDevAppEnableCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "enable", Short: "启用开放平台企业内部应用", Tool: devAppEnableTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "enable_dev_app", CanonicalPath: "dev.enable_dev_app", CLIPath: "dev app enable", PrimaryCLIPath: "dev app enable", }, Description: "启用开放平台企业内部应用", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "启用指定开放平台应用", UseWhen: []string{"需要恢复一个已停用应用时"}, AvoidWhen: []string{"启用应用内机器人能力使用 dev app robot enable"}, Examples: []string{"dws dev app enable --unified-app-id --dry-run --format json"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppEnableTool), }) } // newDevAppDeleteCommand is delete with a danger tier: deleting an app is // irreversible, so beyond the high-write confirmation it requires // --confirm-name to match the located app's real name. This guards against // "located the wrong app and deleted it" — the agent must first know the name // (via `get`/dry-run) before it can delete. The match is verified client-side // (a `get` then compare), standard practice for destructive CLI ops // (gh repo delete, gcloud). // // LeafSpec+RunE:confirm-name 的 get-then-compare 多步二次确认属多步编排, // 由自定义 RunE 承载;框架仍按同一 SafetySpec 执行确认门并发布 Schema。 func newDevAppDeleteCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "delete", Short: "删除开放平台企业内部应用(不可逆,需 --confirm-name 二次确认)", Example: " dws dev app delete --unified-app-id UNIFIED_APP_ID --confirm-name 应用名 --yes --format json", Tool: devAppDeleteTool, Safety: devAppSafetyDestructive(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "confirm-name", Usage: "二次确认:必须与被删应用的名称一致(不可逆操作的防误删)", Bind: "confirmName", Trim: true, OmitEmpty: true}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "delete_dev_app", CanonicalPath: "dev.delete_dev_app", CLIPath: "dev app delete", PrimaryCLIPath: "dev app delete", }, Description: "删除开放平台企业内部应用(不可逆,需 --confirm-name 二次确认)", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "删除开放平台企业内部应用(不可恢复)", UseWhen: []string{"用户明确要求永久删除应用,且接受不可恢复后果"}, AvoidWhen: []string{"只需临时下架时用 dev app disable", "用户未确认应用名/影响范围时不要删除"}, Examples: []string{"dws dev app delete --unified-app-id --dry-run --format json"}, }, }, RunE: func(cmd *cobra.Command, args []string) error { appID, err := requiredDevAppUnifiedID(cmd) if err != nil { return err } params := map[string]any{"unifiedAppId": appID} // Dry-run previews the delete without requiring confirmation — // the agent uses it (or `get`) to read the app name first. if commandDryRun(cmd) { return runDevAppTool(runner, cmd, devAppDeleteTool, params) } // Real execution: require --confirm-name and verify it matches. confirmName := devAppStringFlag(cmd, "confirm-name") if confirmName == "" { return apperrors.NewValidation("删除不可逆,需二次确认:先用 `dev app get` 看应用名,再加 --confirm-name=<应用名>") } actualName, err := devAppFetchAppName(runner, cmd, params) if err != nil { return err } // 读不到应用名时 fail-closed:不可逆删除不能在无法校验 --confirm-name // 的情况下放行,否则二次确认形同虚设。 if actualName == "" { return apperrors.NewValidation("无法读取应用名以校验 --confirm-name,已中止删除;请确认 --unified-app-id 正确,或先用 --dry-run / `dev app get` 预览") } if confirmName != actualName { return apperrors.NewValidation(fmt.Sprintf("名称不匹配:--confirm-name=%q 但定位到的应用名是 %q,已中止删除", confirmName, actualName)) } return runDevAppTool(runner, cmd, devAppDeleteTool, params) }, PostMount: devAppMeta(devAppDeleteTool), }) } // devAppFetchAppName resolves the located app's name via get_dev_app // so delete can verify --confirm-name. Returns "" if the name can't be found; // the caller treats "" as fail-closed (aborts the irreversible delete) rather // than silently proceeding. func devAppFetchAppName(runner executor.Runner, cmd *cobra.Command, locator map[string]any) (string, error) { inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), devAppProduct, devAppGetTool, locator, ) result, err := runner.Run(cmd.Context(), inv) if err != nil { return "", err } // get_dev_app 返回的应用名字段是 name(credentials 才用 appName); // 取 name、appName 兜底,否则 delete 永远读不到名、二次确认必然 fail-closed。 if name := devAppExtractString(result.Response, "name"); name != "" { return name, nil } return devAppExtractString(result.Response, "appName"), nil } // devAppExtractString descends the helper response (content → result) and reads // a string field. Returns "" if absent. func devAppExtractString(response map[string]any, key string) string { node := response if inner, ok := node["content"].(map[string]any); ok { node = inner } if inner, ok := node["result"].(map[string]any); ok { node = inner } if v, ok := node[key].(string); ok { return v } return "" } func newDevAppWebappGetCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "get", Short: "查询网页应用配置", Example: " dws dev app webapp get --unified-app-id UNIFIED_APP_ID --format json", Tool: devAppWebappGetTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "get_extension_webapp_config", CanonicalPath: "dev.get_extension_webapp_config", CLIPath: "dev app webapp get", PrimaryCLIPath: "dev app webapp get", }, Description: "查询网页应用配置", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "获取指定应用的网页入口配置", UseWhen: []string{"需要核对应用 H5 或 PC 首页地址时"}, AvoidWhen: []string{"修改网页入口时使用 dev app webapp config"}, Examples: []string{"dws dev app webapp get --unified-app-id --format json"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppWebappGetTool), }) } func newDevAppWebappConfigCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "config", Short: "配置网页应用能力", Example: " dws dev app webapp config --unified-app-id UNIFIED_APP_ID --homepage-url https://example.com --dry-run --format json", Tool: devAppWebappSetTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "h5-page-type", Usage: "网页应用生效端/页面类型", Bind: "h5PageType", Trim: true, OmitEmpty: true}, {Name: "homepage-url", Usage: "移动端首页地址", Bind: "homepageUrl", Trim: true, OmitEmpty: true}, {Name: "pc-homepage-url", Usage: "PC 端首页地址", Bind: "pcHomepageUrl", Trim: true, OmitEmpty: true}, {Name: "omp-url", Usage: "管理后台地址", Bind: "ompUrl", Trim: true, OmitEmpty: true}, }, // 至少一项走 Validate 而非类型化 Constraints:零契约变更(见 get 命令注释)。 Validate: func(cmd *cobra.Command, args []string) error { if devAppStringFlag(cmd, "h5-page-type") == "" && devAppStringFlag(cmd, "homepage-url") == "" && devAppStringFlag(cmd, "pc-homepage-url") == "" && devAppStringFlag(cmd, "omp-url") == "" { return apperrors.NewValidation("至少提供一项网页应用配置:--h5-page-type、--homepage-url、--pc-homepage-url 或 --omp-url") } return nil }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "set_extension_webapp_config", CanonicalPath: "dev.set_extension_webapp_config", CLIPath: "dev app webapp config", PrimaryCLIPath: "dev app webapp config", }, Description: "配置网页应用能力", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "创建或更新应用网页入口配置", UseWhen: []string{"需要设置 H5、PC 首页或 OMP 地址时"}, AvoidWhen: []string{"只查看当前网页配置时使用 dev app webapp get"}, Examples: []string{"dws dev app webapp config --unified-app-id --homepage-url https://example.com --dry-run"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppWebappSetTool), }) } func newDevAppPermissionListCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "list", Short: "查询开放平台应用权限列表", Example: " dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword 通讯录 --page-size 20 --format json", Tool: devAppPermissionListTool, Safety: devAppSafetyRead(), // 命令级别名 "search" 由 PostMount 设回(LeafSpec 无 Command Aliases 字段)。 Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "keyword", Usage: "权限名、权限点、接口名关键词", Bind: "keyword", Trim: true, OmitEmpty: true}, {Name: "scope-value", Usage: "精确权限点 scopeValue", Bind: "scopeValue", Trim: true, OmitEmpty: true}, {Name: "auth-status", Usage: "权限状态:ALL、AUTHED、UNAUTHED", Default: "ALL", Bind: "authStatus", Trim: true, OmitEmpty: true, Transform: func(raw string) (any, error) { return strings.ToUpper(raw), nil }}, {Name: "scope-type", Usage: "权限一级类型:APP 或 SNS", Bind: "scopeType", Trim: true, OmitEmpty: true, Transform: func(raw string) (any, error) { return strings.ToUpper(raw), nil }}, {Name: "api-status", Usage: "开发者后台 apiStatus 过滤", Bind: "apiStatus", Trim: true, OmitEmpty: true}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "list_dev_app_permissions", CanonicalPath: "dev.list_dev_app_permissions", CLIPath: "dev app permission list", PrimaryCLIPath: "dev app permission list", Aliases: []string{"dev app permission search"}, }, Description: "查询开放平台应用权限列表", DryRun: devAppDryRun, Result: devAppPaginatedItemsResult("当前页开放平台应用权限查询结果", "当前页权限记录"), Pagination: devAppCursorPagination(), Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "查询应用权限及其授权状态", UseWhen: []string{"需要按关键词、范围或状态查权限时"}, AvoidWhen: []string{"新增或移除权限使用对应 permission 写命令"}, Examples: []string{`dws dev app permission list --unified-app-id --keyword "通讯录" --page-size 20`}, }, }, Call: devAppCallCursor(runner), PostMount: func(cmd *cobra.Command) { cmd.Aliases = []string{"search"} devAppMetaCursor(devAppPermissionListTool)(cmd) }, }) } func newDevAppPermissionAddCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "add", Short: "申请开放平台应用权限点", Example: " dws dev app permission add --unified-app-id UNIFIED_APP_ID --scope-values Contact.User.mobile --dry-run --format json", Tool: devAppPermissionAddTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "scope-values", Usage: "权限点 scopeValue,多个用逗号或分号分隔", Bind: "scopeValues", Trim: true, Required: true, RequiredHint: "--scope-values 为必填", Transform: transformDevAppScopeValues}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "apply_dev_app_permissions", CanonicalPath: "dev.apply_dev_app_permissions", CLIPath: "dev app permission add", PrimaryCLIPath: "dev app permission add", }, Description: "申请开放平台应用权限点", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "为应用申请一个或多个接口权限", UseWhen: []string{"已确认 scopeValue 并需要新增权限时"}, AvoidWhen: []string{"查找或核对已有权限时使用 dev app permission list"}, Examples: []string{"dws dev app permission add --unified-app-id --scope-values Contact.User.mobile --dry-run"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppPermissionAddTool), }) } func newDevAppPermissionRemoveCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "remove", Short: "取消开放平台应用权限点", Example: " dws dev app permission remove --unified-app-id UNIFIED_APP_ID --scope-values Contact.User.mobile,qyapi_robot_sendmsg --dry-run --format json", Tool: devAppPermissionRmTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "scope-values", Usage: "待取消权限点 scopeValue,多个用逗号或分号分隔", Bind: "scopeValues", Trim: true, Required: true, RequiredHint: "--scope-values 为必填", Transform: transformDevAppScopeValues}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "remove_dev_app_permissions", CanonicalPath: "dev.remove_dev_app_permissions", CLIPath: "dev app permission remove", PrimaryCLIPath: "dev app permission remove", }, Description: "取消开放平台应用权限点", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "移除应用的一个或多个接口权限", UseWhen: []string{"需要撤销已知 scopeValue 的权限时"}, AvoidWhen: []string{"只查看权限状态时使用 dev app permission list"}, Examples: []string{"dws dev app permission remove --unified-app-id --scope-values Contact.User.mobile --dry-run"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppPermissionRmTool), }) } func newDevAppMemberListCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "list", Short: "查询开放平台应用成员", Example: " dws dev app member list --unified-app-id ", Tool: devAppMemberListTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "list_dev_app_members", CanonicalPath: "dev.list_dev_app_members", CLIPath: "dev app member list", PrimaryCLIPath: "dev app member list", }, Description: "查询开放平台应用成员", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "列出指定应用的协作成员", UseWhen: []string{"需要查看应用开发者和管理员清单时"}, AvoidWhen: []string{"新增或移除成员使用对应 member 写命令"}, Examples: []string{"dws dev app member list --unified-app-id "}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppMemberListTool), }) } func newDevAppMemberAddCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "add", Short: "添加开放平台应用成员", Example: " dws dev app member add --unified-app-id --user-ids userId1,userId2 --member-type DEVELOPER --dry-run", Tool: devAppMemberAddTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "user-ids", Usage: "成员 userId 列表,多个用逗号分隔 (必填)", Bind: "userIds", Trim: true, Required: true, RequiredHint: "--user-ids 为必填", Aliases: []string{"member-user-ids"}, Transform: transformDevAppListParam}, {Name: "member-type", Usage: "成员类型,如 DEVELOPER (必填)", Bind: "memberType", Trim: true, Required: true, RequiredHint: "--member-type 为必填"}, }, // 纯分隔符输入(如 ",")通过 Required 但解析为空:保持旧版「至少包含一个」 // 拦截。member-user-ids 是 user-ids 的注册别名,需一并检查。 Validate: func(cmd *cobra.Command, args []string) error { if len(parseDevAppListFlag(cmd, "user-ids")) == 0 && len(parseDevAppListFlag(cmd, "member-user-ids")) == 0 { return apperrors.NewValidation("--user-ids 至少包含一个 userId") } return nil }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "add_dev_app_members", CanonicalPath: "dev.add_dev_app_members", CLIPath: "dev app member add", PrimaryCLIPath: "dev app member add", }, Description: "添加开放平台应用成员", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "向应用添加开发者或管理员成员", UseWhen: []string{"需要授予指定用户应用协作角色时"}, AvoidWhen: []string{"只查看现有成员时使用 dev app member list"}, Examples: []string{"dws dev app member add --unified-app-id --user-ids userId1,userId2 --member-type DEVELOPER --dry-run"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppMemberAddTool), }) } func newDevAppMemberRemoveCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "remove", Short: "移除开放平台应用成员", Example: " dws dev app member remove --unified-app-id --user-ids userId1,userId2 --member-type DEVELOPER --dry-run", Tool: devAppMemberRemoveTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "user-ids", Usage: "成员 userId 列表,多个用逗号分隔 (必填)", Bind: "userIds", Trim: true, Required: true, RequiredHint: "--user-ids 为必填", Aliases: []string{"member-user-ids"}, Transform: transformDevAppListParam}, {Name: "member-type", Usage: "成员类型,如 DEVELOPER (必填)", Bind: "memberType", Trim: true, Required: true, RequiredHint: "--member-type 为必填"}, }, // 纯分隔符输入(如 ",")通过 Required 但解析为空:保持旧版「至少包含一个」 // 拦截。member-user-ids 是 user-ids 的注册别名,需一并检查。 Validate: func(cmd *cobra.Command, args []string) error { if len(parseDevAppListFlag(cmd, "user-ids")) == 0 && len(parseDevAppListFlag(cmd, "member-user-ids")) == 0 { return apperrors.NewValidation("--user-ids 至少包含一个 userId") } return nil }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "remove_dev_app_members", CanonicalPath: "dev.remove_dev_app_members", CLIPath: "dev app member remove", PrimaryCLIPath: "dev app member remove", }, Description: "移除开放平台应用成员", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "从应用移除开发者或管理员成员", UseWhen: []string{"需要撤销指定用户的应用协作角色时"}, AvoidWhen: []string{"删除应用本身或只查看成员时不要使用"}, Examples: []string{"dws dev app member remove --unified-app-id --user-ids userId1,userId2 --member-type DEVELOPER --dry-run"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppMemberRemoveTool), }) } func newDevAppSecurityConfigCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "config", Short: "更新开放平台应用安全配置", Example: " dws dev app security config --unified-app-id " + "--ip-whitelist 192.0.2.10 --redirect-urls https://callback.example.invalid/callback --sso-urls https://sso.example.invalid/sso --dry-run", Tool: devAppSecurityConfigTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "ip-whitelist", Usage: "出口 IP 白名单,多个用逗号或分号分隔(整组覆盖,非追加)", Bind: "ipWhitelist", Trim: true, Transform: transformDevAppListParam}, {Name: "redirect-urls", Usage: "登录重定向 URL,多个用逗号或分号分隔(整组覆盖,非追加)", Bind: "redirectUrls", Trim: true, Transform: transformDevAppListParam}, {Name: "sso-urls", Usage: "端内免登地址,多个用逗号或分号分隔(整组覆盖,非追加)", Bind: "ssoUrls", Trim: true, Transform: transformDevAppListParam}, }, // 至少一项走 Validate 而非类型化 Constraints:零契约变更(见 get 命令注释)。 Validate: func(cmd *cobra.Command, args []string) error { if len(parseDevAppListFlag(cmd, "ip-whitelist")) == 0 && len(parseDevAppListFlag(cmd, "redirect-urls")) == 0 && len(parseDevAppListFlag(cmd, "sso-urls")) == 0 { return apperrors.NewValidation("至少提供一项安全配置:--ip-whitelist、--redirect-urls 或 --sso-urls") } return nil }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "update_dev_app_security_config", CanonicalPath: "dev.update_dev_app_security_config", CLIPath: "dev app security config", PrimaryCLIPath: "dev app security config", }, Description: "更新开放平台应用安全配置", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "更新应用 IP 白名单、回调和单点登录地址", UseWhen: []string{"需要调整应用安全相关 URL 或白名单时"}, AvoidWhen: []string{"普通应用名称和描述更新使用 dev app update"}, Examples: []string{"dws dev app security config --unified-app-id --ip-whitelist 192.0.2.10 --redirect-urls https://callback.example.invalid/callback --dry-run"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppSecurityConfigTool), }) } // --------------------------------------------------------------------------- // 机器人能力 // --------------------------------------------------------------------------- // LeafSpec+RunE:devAppRobotCreateParams 的 icon/preview 空串占位与失败重试 // taskId 编排由自定义 RunE 承载;框架按 SafetySpec 执行确认门,必填仍由 // devAppRobotCreateParams 报错,Required 标记仅用于 Schema/help 投影。 func newDevAppRobotSubmitCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "submit", Short: "异步提交钉钉智能体机器人创建任务(支持失败重试)", Example: " dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc \"处理审批问答\" --dry-run --format json", Tool: devAppRobotSubmitTool, Safety: devAppSafetyHighWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "name", Usage: "智能体应用名称,长度 2-20,企业内唯一 (必填)", Bind: "name", Trim: true, Required: true, RequiredHint: "--name 为必填"}, {Name: "robot-name", Usage: "承载机器人名称,用于客户端展示 (必填)", Bind: "robotName", Trim: true, Required: true, RequiredHint: "--robot-name 为必填"}, {Name: "desc", Usage: "机器人功能描述,不超过 200 字 (必填)", Bind: "desc", Trim: true, Required: true, RequiredHint: "--desc 为必填"}, {Name: "icon-media-id", Usage: "机器人图标 mediaId;为空时使用默认图标", Bind: "iconMediaId", Trim: true, OmitEmpty: true}, {Name: "preview-media-id", Usage: "机器人预览图 mediaId;为空时复用图标", Bind: "previewMediaId", Trim: true, OmitEmpty: true}, {Name: "task-id", Usage: "失败重试时传入原 taskId;为空时服务端自动生成", Bind: "taskId", Trim: true, OmitEmpty: true}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "submit_robot_create_task", CanonicalPath: "dev.submit_robot_create_task", CLIPath: "dev app robot submit", PrimaryCLIPath: "dev app robot submit", }, Description: "异步提交钉钉智能体机器人创建任务(支持失败重试)", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "提交机器人建号异步任务", UseWhen: []string{"需要创建新的机器人账号并取得 taskId 时"}, AvoidWhen: []string{"已有任务应先用 robot result 轮询,不能重复提交"}, Examples: []string{`dws dev app robot submit --name "我的智能体" --robot-name "小助手" --desc "处理审批问答" --dry-run`}, }, }, RunE: func(cmd *cobra.Command, args []string) error { params, err := devAppRobotCreateParams(cmd) if err != nil { return err } // submit_robot_create 的 schema 把图标字段标为必填(空值时服务端用默认图标), // 因此即使用户未提供也补空串占位。 if _, ok := params["iconMediaId"]; !ok { params["iconMediaId"] = "" } if _, ok := params["previewMediaId"]; !ok { params["previewMediaId"] = "" } devAppPutString(params, "taskId", devAppStringFlag(cmd, "task-id")) return runDevAppTool(runner, cmd, devAppRobotSubmitTool, params) }, PostMount: devAppMeta(devAppRobotSubmitTool), }) } func newDevAppRobotResultCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "result", Short: "查询机器人异步创建任务结果", Example: " dws dev app robot result --task-id TASK_ID --format json", Tool: devAppRobotResultTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "task-id", Usage: "提交创建任务时返回的 taskId (必填)", Bind: "taskId", Trim: true, Required: true, RequiredHint: "--task-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "query_robot_create_result", CanonicalPath: "dev.query_robot_create_result", CLIPath: "dev app robot result", PrimaryCLIPath: "dev app robot result", }, Description: "查询机器人异步创建任务结果", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "查询机器人建号异步任务结果", UseWhen: []string{"已有 taskId,需要轮询 WAITING、SUCCESS 或审批状态时"}, AvoidWhen: []string{"没有建号任务时先使用 dev app robot submit"}, Examples: []string{"dws dev app robot result --task-id --format json"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppRobotResultTool), }) } func newDevAppRobotConfigGetCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "get", Short: "查询现有应用的机器人配置", Example: " dws dev app robot get --unified-app-id UNIFIED_APP_ID --format json", Tool: devAppRobotConfigGetTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "get_extension_robot_config", CanonicalPath: "dev.get_extension_robot_config", CLIPath: "dev app robot get", PrimaryCLIPath: "dev app robot get", }, Description: "查询现有应用的机器人配置", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "获取指定应用的机器人配置和状态", UseWhen: []string{"需要判断机器人是否未配置、离线或在线时"}, AvoidWhen: []string{"查询机器人建号异步任务使用 dev app robot result"}, Examples: []string{"dws dev app robot get --unified-app-id --format json"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppRobotConfigGetTool), }) } // newDevAppRobotConfigCommand is the upsert command for an app's robot config: // one command for both "首次创建" and "更新" — the create-vs-update decision is // the upstream tool's job, not the CLI's (see docs/upstream-todo.md, where the // old create/update tools merge into one `set_dev_app_robot_config`). // `enable` (pure enable, no config fields) is a separate command. // // LeafSpec+RunE:devAppRobotConfigParams 的 mode enum 校验、Bool Changed 语义、 // skills 列表、i18n JSON 解析与至少一项计数由自定义 RunE 承载;框架按 // SafetySpec 执行确认门并发布 Schema。 func newDevAppRobotConfigCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "config", Short: "创建或更新现有应用的机器人配置(upsert)", Example: " dws dev app robot config --unified-app-id UNIFIED_APP_ID --name 小助手 --brief 审批助手 --dry-run --format json", Tool: devAppRobotConfigUpsertTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "name", Usage: "机器人名称", Bind: "name", Trim: true, OmitEmpty: true}, {Name: "brief", Usage: "机器人简介", Bind: "brief", Trim: true, OmitEmpty: true}, {Name: "desc", Usage: "机器人描述", Bind: "desc", Trim: true, OmitEmpty: true}, {Name: "icon-media-id", Usage: "机器人图标 mediaId", Bind: "iconMediaId", Trim: true, OmitEmpty: true}, {Name: "outgoing-url", Usage: "消息回调地址", Bind: "outgoingUrl", Trim: true, OmitEmpty: true}, {Name: "event-callback-url", Usage: "事件回调地址", Bind: "eventCallbackUrl", Trim: true, OmitEmpty: true}, {Name: "mode", Usage: "机器人模式:HTTPS / STREAM / AISKILL", Bind: "mode", Trim: true, OmitEmpty: true}, {Name: "skills", Usage: "技能列表,多个用逗号或分号分隔", Bind: "skills", Trim: true, OmitEmpty: true}, {Name: "add-scope", Usage: "是否自动添加机器人相关权限", Kind: LeafBool, Bind: "addScope", OmitEmpty: true}, {Name: "disable-ssl-verify", Usage: "回调地址是否关闭 SSL 校验", Kind: LeafBool, Bind: "disableSSLVerify", OmitEmpty: true}, {Name: "i18n-name", Usage: "机器人名称国际化 JSON,如 '{\"en_US\":\"Bot\"}'", Bind: "i18nName", Trim: true, OmitEmpty: true}, {Name: "i18n-brief", Usage: "机器人简介国际化 JSON", Bind: "i18nBrief", Trim: true, OmitEmpty: true}, {Name: "i18n-description", Usage: "机器人描述国际化 JSON", Bind: "i18nDescription", Trim: true, OmitEmpty: true}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "set_extension_robot_config", CanonicalPath: "dev.set_extension_robot_config", CLIPath: "dev app robot config", PrimaryCLIPath: "dev app robot config", }, Description: "创建或更新现有应用的机器人配置(upsert)", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "创建或更新应用的机器人能力配置", UseWhen: []string{"已有 unifiedAppId 并需要配置机器人名称、回调或技能时"}, AvoidWhen: []string{"机器人建号使用 robot submit,本地建联使用 dev connect"}, Examples: []string{`dws dev app robot config --unified-app-id --name "小助手" --brief "审批助手" --dry-run`}, }, }, RunE: func(cmd *cobra.Command, args []string) error { appID, err := requiredDevAppUnifiedID(cmd) if err != nil { return err } params, updates, err := devAppRobotConfigParams(cmd, appID) if err != nil { return err } if updates == 0 { return apperrors.NewValidation("至少提供一项机器人配置字段,如 --name、--brief、--desc、--icon-media-id、--outgoing-url、--event-callback-url、--mode、--skills") } return runDevAppTool(runner, cmd, devAppRobotConfigUpsertTool, params) }, PostMount: devAppMeta(devAppRobotConfigUpsertTool), }) } // newDevAppRobotEnableCommand enables an app's robot capability. Unlike config, // it needs no config fields — pure enable, only the app locator. func newDevAppRobotEnableCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "enable", Short: "启用现有应用机器人能力(纯启用,无需配置字段)", Example: " dws dev app robot enable --unified-app-id UNIFIED_APP_ID --dry-run --format json", Tool: devAppRobotEnableTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "enable_dev_app_robot", CanonicalPath: "dev.enable_dev_app_robot", CLIPath: "dev app robot enable", PrimaryCLIPath: "dev app robot enable", }, Description: "启用现有应用机器人能力(纯启用,无需配置字段)", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "启用指定应用的机器人能力", UseWhen: []string{"机器人配置存在但处于 OFFLINE 时"}, AvoidWhen: []string{"建立本地 Stream 连接使用 dev connect"}, Examples: []string{"dws dev app robot enable --unified-app-id --dry-run"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppRobotEnableTool), }) } func newDevAppRobotOfflineCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "disable", Short: "停用现有应用的机器人能力", Example: " dws dev app robot disable --unified-app-id UNIFIED_APP_ID --dry-run --format json", Tool: devAppRobotOfflineTool, Safety: devAppSafetyWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "disable_dev_app_robot", CanonicalPath: "dev.disable_dev_app_robot", CLIPath: "dev app robot disable", PrimaryCLIPath: "dev app robot disable", }, Description: "停用现有应用的机器人能力", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "停用指定应用的机器人能力", UseWhen: []string{"需要暂时让应用机器人离线时"}, AvoidWhen: []string{"停用整个应用使用 dev app disable"}, Examples: []string{"dws dev app robot disable --unified-app-id --dry-run"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppRobotOfflineTool), }) } func devAppRobotCreateParams(cmd *cobra.Command) (map[string]any, error) { name := devAppStringFlag(cmd, "name") if name == "" { return nil, apperrors.NewValidation("--name 为必填") } robotName := devAppStringFlag(cmd, "robot-name") if robotName == "" { return nil, apperrors.NewValidation("--robot-name 为必填") } desc := devAppStringFlag(cmd, "desc") if desc == "" { return nil, apperrors.NewValidation("--desc 为必填") } params := map[string]any{ "name": name, "robotName": robotName, "desc": desc, } devAppPutString(params, "iconMediaId", devAppStringFlag(cmd, "icon-media-id")) devAppPutString(params, "previewMediaId", devAppStringFlag(cmd, "preview-media-id")) return params, nil } func devAppRobotConfigParams(cmd *cobra.Command, appID string) (map[string]any, int, error) { params := map[string]any{"unifiedAppId": appID} updates := 0 setString := func(key, flag string) { if v := devAppStringFlag(cmd, flag); v != "" { params[key] = v updates++ } } setString("name", "name") setString("brief", "brief") setString("desc", "desc") setString("iconMediaId", "icon-media-id") setString("outgoingUrl", "outgoing-url") setString("eventCallbackUrl", "event-callback-url") if cmd.Flags().Changed("mode") { mode := strings.ToUpper(strings.TrimSpace(devAppStringFlag(cmd, "mode"))) switch mode { case "HTTPS", "STREAM", "AISKILL": params["mode"] = mode default: return nil, 0, apperrors.NewValidation("--mode 仅支持 HTTPS、STREAM、AISKILL") } updates++ } if cmd.Flags().Changed("add-scope") { value, _ := cmd.Flags().GetBool("add-scope") params["addScope"] = value updates++ } if cmd.Flags().Changed("disable-ssl-verify") { value, _ := cmd.Flags().GetBool("disable-ssl-verify") params["disableSSLVerify"] = value updates++ } if values := parseDevAppListFlag(cmd, "skills"); len(values) > 0 { params["skills"] = values updates++ } for _, item := range []struct{ key, flag string }{ {"i18nName", "i18n-name"}, {"i18nBrief", "i18n-brief"}, {"i18nDescription", "i18n-description"}, } { raw := devAppStringFlag(cmd, item.flag) if raw == "" { continue } parsed := map[string]any{} if err := json.Unmarshal([]byte(raw), &parsed); err != nil { return nil, 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是合法 JSON 对象:%v", item.flag, err)) } params[item.key] = parsed updates++ } return params, updates, nil } // --------------------------------------------------------------------------- // 版本发布能力 // --------------------------------------------------------------------------- func newDevAppVersionCreateCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "create", Short: "基于当前配置创建应用新版本", Example: " dws dev app version create --unified-app-id UNIFIED_APP_ID --desc \"新增机器人能力\" --dry-run --format json", Tool: devAppVersionCreateTool, Safety: devAppSafetyHighWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "version", Usage: "高级可选:显式版本号,如 1.0.1;默认不传,由服务端基于最新已发布版本自动递增", Bind: "version", Trim: true, OmitEmpty: true}, {Name: "desc", Usage: "版本描述", Bind: "desc", Trim: true, OmitEmpty: true}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "create_dev_app_version", CanonicalPath: "dev.create_dev_app_version", CLIPath: "dev app version create", PrimaryCLIPath: "dev app version create", }, Description: "基于当前配置创建应用新版本", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "为应用当前配置创建待发布版本", UseWhen: []string{"配置变更完成后需要生成版本进入发布流程时"}, AvoidWhen: []string{"只是查看已有版本时使用 dev app version list"}, Examples: []string{`dws dev app version create --unified-app-id --desc "新增机器人能力" --dry-run`}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppVersionCreateTool), }) } func newDevAppVersionListCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "list", Short: "分页查询应用版本列表", Example: " dws dev app version list --unified-app-id UNIFIED_APP_ID --page-size 20 --format json", Tool: devAppVersionListTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "list_dev_app_versions", CanonicalPath: "dev.list_dev_app_versions", CLIPath: "dev app version list", PrimaryCLIPath: "dev app version list", }, Description: "分页查询应用版本列表", DryRun: devAppDryRun, Result: devAppPaginatedItemsResult("当前页开放平台应用版本查询结果", "当前页版本记录"), Pagination: devAppCursorPagination(), Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "分页列出应用的历史和待发布版本", UseWhen: []string{"需要查找 versionId 或浏览版本记录时"}, AvoidWhen: []string{"已知版本并需详情或状态时使用 get 或 status"}, Examples: []string{"dws dev app version list --unified-app-id --page-size 20"}, }, }, Call: devAppCallCursor(runner), PostMount: devAppMetaCursor(devAppVersionListTool), }) } func newDevAppVersionGetCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "get", Short: "查询指定版本详情", Example: " dws dev app version get --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", Tool: devAppVersionDetailTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "get_dev_app_version_detail", CanonicalPath: "dev.get_dev_app_version_detail", CLIPath: "dev app version get", PrimaryCLIPath: "dev app version get", }, Description: "查询指定版本详情", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "获取指定应用版本的详细内容", UseWhen: []string{"已知 versionId 并需要核对版本配置时"}, AvoidWhen: []string{"查看发布进度时使用 dev app version status"}, Examples: []string{"dws dev app version get --unified-app-id --version-id "}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppVersionDetailTool), }) } func newDevAppVersionCheckApprovalCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "check-approval", Short: "预检版本发布是否需要审批(不实际发布)", Example: " dws dev app version check-approval --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", Tool: devAppVersionPublishTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"}, }, ConstParams: map[string]any{"precheckOnly": true}, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "version_check_approval", CanonicalPath: "dev.version_check_approval", CLIPath: "dev app version check-approval", PrimaryCLIPath: "dev app version check-approval", }, Description: "预检版本发布是否需要审批,不执行发布", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "预检应用版本的审批要求和候选审批人", UseWhen: []string{"发布版本前确认是否需要审批及候选审批人"}, AvoidWhen: []string{"实际发布版本时使用 dev app version publish"}, Examples: []string{"dws dev app version check-approval --unified-app-id --version-id "}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppVersionPublishTool), }) } func newDevAppVersionPublishCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "publish", Short: "发布指定版本(含高敏权限需 --confirmed-sensitive)", Example: " dws dev app version publish --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --dry-run --format json", Tool: devAppVersionPublishTool, Safety: devAppSafetyHighWrite(), // devapp 旧版写守卫为 guard-first:确认门先于参数校验。 ConfirmFirst: true, Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"}, {Name: "approver-user-id", Usage: "灰度选人模式下指定审批人 userId", Bind: "approverUserId", Trim: true, OmitEmpty: true}, {Name: "confirmed-sensitive", Usage: "确认发布包含高敏权限的版本", Kind: LeafBool, Bind: "confirmedSensitive"}, }, ConstParams: map[string]any{"precheckOnly": false}, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "publish_dev_app_version", CanonicalPath: "dev.publish_dev_app_version", CLIPath: "dev app version publish", PrimaryCLIPath: "dev app version publish", }, Description: "发布指定版本(含高敏权限需 --confirmed-sensitive)", DryRun: devAppDryRun, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "发布开放平台应用指定版本(可先预检)", UseWhen: []string{"版本已创建,需要预检或正式发布到线上"}, AvoidWhen: []string{"还没有 versionId 时先 version create / list / get", "含高敏权限但尚未确认时不要正式发布"}, Examples: []string{"dws dev app version publish --unified-app-id --version-id --dry-run --format json"}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppVersionPublishTool), }) } func newDevAppVersionStatusCommand(runner executor.Runner) *cobra.Command { return NewLeafCommand(LeafSpec{ Use: "status", Short: "查询版本发布/审批状态", Example: " dws dev app version status --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", Tool: devAppVersionStatusTool, Safety: devAppSafetyRead(), Flags: []LeafFlag{ {Name: "unified-app-id", Usage: "开放平台统一应用 ID(必填)", Bind: "unifiedAppId", Trim: true, Required: true, RequiredHint: "--unified-app-id 为必填"}, {Name: "version-id", Usage: "版本 ID (必填)", Bind: "versionId", Trim: true, Required: true, RequiredHint: "--version-id 为必填"}, }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "dev", Name: "get_dev_app_version_status", CanonicalPath: "dev.get_dev_app_version_status", CLIPath: "dev app version status", PrimaryCLIPath: "dev app version status", }, Description: "查询版本发布/审批状态", DryRun: devAppDryRun, Result: &contract.ResultSpec{ Outcomes: []contract.ResultOutcome{contract.ResultOutcomePending, contract.ResultOutcomeFailure, contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"应用版本发布或审批状态","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"versionId":{"type":"string","description":"应用版本 ID"},"status":{"type":"string","description":"归一化版本状态"},"versionStatus":{"type":"string","description":"服务端版本状态"},"approvalStatus":{"type":"string","description":"版本审批状态"},"nextCommand":{"type":"string","description":"状态未终结时可执行的下一条命令"}},"required":["versionId"],"additionalProperties":true}`), }, Interface: devAppCompositeInterface(), Selection: contract.SelectionSpec{ AgentSummary: "查询指定应用版本的发布或审批状态", UseWhen: []string{"需要判断版本是否已发布、审核中或受阻时"}, AvoidWhen: []string{"需要版本配置详情时使用 dev app version get"}, Examples: []string{"dws dev app version status --unified-app-id --version-id "}, }, }, Call: devAppCall(runner), PostMount: devAppMeta(devAppVersionStatusTool), }) } func requiredDevAppUnifiedID(cmd *cobra.Command) (string, error) { appID := devAppStringFlag(cmd, "unified-app-id") if appID == "" { return "", apperrors.NewValidation("--unified-app-id 为必填") } return appID, nil } // annotateDevAppTool tags a leaf command with the MCP tool name it invokes, so // `dws schema dev.app...` can resolve command → tool → live op-app schema // without re-deriving the mapping. The annotation is the single source of truth // for the schema renderer (see internal/cli/dev_schema.go). func annotateDevAppTool(cmd *cobra.Command, tool string) *cobra.Command { if cmd.Annotations == nil { cmd.Annotations = map[string]string{} } cmd.Annotations["mcp-tool"] = tool cmd.Annotations["mcp-source"] = "op-app" return cmd } // devAppLeafMeta 是 devapp 叶子命令统一的 PostMount 收尾:设置 NoArgs / // DisableAutoGenTag,并调用 preferLegacyLeaf + annotateDevAppTool。供迁移到 // LeafSpec 的命令在 LeafSpec.PostMount 里复用,保持与手写版逐字等价。 func devAppLeafMeta(cmd *cobra.Command, tool string) { cmd.Args = cobra.NoArgs cmd.DisableAutoGenTag = true preferLegacyLeaf(cmd) annotateDevAppTool(cmd, tool) // This leaf has migrated to the unified result framework. Consumers keep using --format; // the active contract is a release property, not an Agent-selected flag. output.SetCommandRollout(cmd, output.RolloutUnifiedActive) } // devAppCall 返回统一派发闭包(替代各命令重复的 Call: runDevAppTool 透传)。 // 参数装配由 Flags/ConstParams 完成;本闭包只负责执行与响应处理。 func devAppCall(runner executor.Runner) func(*cobra.Command, string, map[string]any) error { return func(cmd *cobra.Command, tool string, params map[string]any) error { return runDevAppTool(runner, cmd, tool, params) } } // devAppMeta 返回纯收尾 PostMount 闭包(无额外 flag 的命令用)。 func devAppMeta(tool string) func(*cobra.Command) { return func(cmd *cobra.Command) { devAppLeafMeta(cmd, tool) } } // devAppCompositeInterfaceReason 是 devapp 全树共用的评审 interface 说明(非 pin // MCP 元数据的远程适配器),作为 contract.InterfaceSpec.Reason 的最终发布值。 const devAppCompositeInterfaceReason = "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." // devapp 的 SafetySpec 直接对齐 Agent Runtime Schema。函数每次返回一个值 // 副本,避免共享可变状态;四个字段彼此独立,不从 effect 或 risk 推导。 func devAppSafetyRead() contract.SafetySpec { return contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", } } func devAppSafetyWrite() contract.SafetySpec { return contract.SafetySpec{ Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", } } func devAppSafetyHighWrite() contract.SafetySpec { return contract.SafetySpec{ Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", } } func devAppSafetyDestructive() contract.SafetySpec { return contract.SafetySpec{ Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown", } } // devAppDryRun 是 devapp 全树共用的 dry_run 最终声明:本地拼装调用预览 // (invocation preview),dry-run 不发起任何远端读。 var devAppDryRun = &contract.DryRunSpec{ PreviewKind: "invocation", RemoteReads: false, } // devAppCompositeInterface 是 devapp 全树共用的 Interface 最终声明。 func devAppCompositeInterface() *contract.InterfaceSpec { return &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: devAppCompositeInterfaceReason} } func runDevAppTool(runner executor.Runner, cmd *cobra.Command, tool string, params map[string]any) error { invocation := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), devAppProduct, tool, params, ) invocation.DryRun = commandDryRun(cmd) result, err := runner.Run(cmd.Context(), invocation) if err != nil { return err } // The requested tool is the local contract authority. Do not let an empty or // stale runner echo disable tool-specific fail-closed projection rules. result.Invocation.Tool = tool // Unwrap the ServiceResult envelope and apply per-tool response fixes before // rendering, so agents read the inner payload directly and pretty-annotation // walks the already-normalized content. result = normalizeDevAppToolResult(tool, normalizeDevAppServiceResult(result)) if devAppPrettyWanted(cmd) { devAppPrettyAnnotate(tool, result.Response) } return writeDevAppEnvelope(cmd, result) } func devAppCommandResult(result executor.Result) output.CommandResult { data := devAppEnvelopeData(result) if content, ok := data.(map[string]any); ok { if partial := devAppMultiProfileResult(content); partial != nil { return partial } if failure := devAppFailureResult(content); failure != nil { return failure } // check-approval reports what a later publish would require; it does not // itself accept an asynchronous operation. precheckOnly, _ := result.Invocation.Params["precheckOnly"].(bool) if !precheckOnly { if pending := devAppPendingResult(content); pending != nil { return pending } } } successOptions := make([]output.ResultOption, 0, 2) if result.Invocation.DryRun { successOptions = append(successOptions, output.WithDryRun()) } if meta, err := devAppPaginationMeta(data); err != nil { return output.Failure(&output.ErrorInfo{ Type: "api", Subtype: "pagination_inconsistent", Message: err.Error(), Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。", }) } else if meta != nil { successOptions = append(successOptions, output.WithMeta(meta)) return output.Success(devAppDataWithoutPagination(data), successOptions...) } else if devAppToolRequiresPagination(result.Invocation.Tool) && !result.Invocation.DryRun { // A dry-run payload is a completed local invocation preview, not a // server list response. Only real responses must prove pagination. return output.Failure(&output.ErrorInfo{ Type: "api", Subtype: "pagination_inconsistent", Message: "declared paginated response is missing hasMore and nextCursor", Hint: "保留原始响应并停止翻页;不要把当前页当作完整结果。", }) } return output.Success(data, successOptions...) } func devAppToolRequiresPagination(tool string) bool { switch strings.TrimSpace(tool) { case devAppListTool, devAppPermissionListTool, devAppEventListTool, devAppVersionListTool: return true default: return false } } // DevAppCommandResultFromPayload is the shared dingtalk-dev outcome mapper for // the native `dev ...` tree and the existing `devapp +...` shortcut tree. Both // entry points must classify the same upstream payload into the same unified // outcome; only command routing and projection differ. func DevAppCommandResultFromPayload(tool string, payload any, dryRun bool) output.CommandResult { response := map[string]any{"content": payload} if object, ok := payload.(map[string]any); ok { if _, wrapped := object["content"]; wrapped { response = object } } result := executor.Result{ Invocation: executor.Invocation{ Implemented: true, Kind: "helper_invocation", DryRun: dryRun, Tool: tool, }, Response: response, } result = normalizeDevAppServiceResult(result) if strings.TrimSpace(tool) != "" { result = normalizeDevAppToolResult(tool, result) } return devAppCommandResult(result) } // writeDevRolloutResult is the gradual migration seam shared by dingtalk-dev. // The operation is executed exactly once; only the renderer changes. Legacy // remains active only for commands that have not advanced to unified_active. func writeDevRolloutResult(cmd *cobra.Command, result output.CommandResult, legacy *output.Envelope, fallback output.Format) error { if output.UsesUnifiedResult(cmd) { return output.StoreResult(cmd.Context(), result) } if output.CommandRollout(cmd) == output.RolloutDualValidate { // Shadow-build/validate the unified result without a second business invocation and // without changing stdout. Metrics can be added around this seam later. if err := output.ValidateResult(result); err != nil { return err } } return output.WriteEnvelope(cmd, legacy, fallback) } // writeDevAppEnvelope 是 dev app 全树的统一信封出口(统一输出 dev 域试点, // 队列 Phase F)。成功 → ok:true/outcome:success + data;--dry-run → // ok:true/outcome:success + dry_run:true:dry-run 是已完成的预演,不是异步未终态。 // exit 0,参数非法仍走 validation 报错——错误路径继续由 apperrors 通道承载)。 // json(默认)输出完整信封(唯一 JSON 契约);其余 format 渲染业务数据。 // 复用 internal/output 的权威 Envelope 类型与 WriteEnvelope 出口函数。 func writeDevAppEnvelope(cmd *cobra.Command, result executor.Result) error { env := &output.Envelope{ OK: true, Outcome: output.OutcomeSuccess, Data: devAppEnvelopeData(result), } if result.Invocation.DryRun { env.DryRun = true } else { env.Meta, _ = devAppPaginationMeta(env.Data) } return writeDevRolloutResult(cmd, devAppCommandResult(result), env, output.FormatJSON) } // devAppEnvelopeData 从工具调用结果中提取业务载荷(L2):已实现的 // helper/compat 调用把载荷放在 response.content 下;其余形态(如 dry-run // 预演的 Result 整体)原样透传,与 output.unwrapCompatRuntimePayload 的 // 解包规则保持一致,保证 data 即既有消费方看到的载荷。 func devAppEnvelopeData(result executor.Result) any { if result.Invocation.Implemented { switch result.Invocation.Kind { case "compat_invocation", "helper_invocation": if content, ok := result.Response["content"]; ok { return content } } } return result } // devAppPaginationMeta 把列表载荷里的 cursor 分页字段投影到 meta.pagination // (契约规范 §3:分页元数据挂 meta 层)。CLI 只观察服务端 // 返回的 hasMore/nextCursor,不做合成。hasMore=true 且带 nextCursor → // endpoint_exhausted:false + next_token(可续跑);hasMore=false → // endpoint_exhausted:true。hasMore=true 却无 cursor 时不产出分页元数据, // 避免违反「endpoint_exhausted:false 必须携带 next_token」。统一结果通过 // devAppDataWithoutPagination 从 data 剥离源控制字段;legacy renderer 不变。 func devAppPaginationMeta(payload any) (*output.Meta, error) { m, ok := payload.(map[string]any) if !ok { return nil, nil } rawHasMore, hasFlag := m["hasMore"] hasMore, hasMoreBool := rawHasMore.(bool) if hasFlag && !hasMoreBool { return nil, fmt.Errorf("pagination hasMore must be a JSON boolean") } cursor := "" rawCursor, hasCursor := m["nextCursor"] if hasCursor { value, stringOK := rawCursor.(string) if !stringOK { return nil, fmt.Errorf("pagination nextCursor must be a JSON string") } cursor = strings.TrimSpace(value) } if !hasFlag && !hasCursor { return nil, nil } pg := &output.Pagination{} switch { case hasMore && cursor != "": pg.EndpointExhausted = false pg.NextToken = cursor case hasFlag && hasMore: return nil, fmt.Errorf("pagination hasMore=true is missing nextCursor") case hasFlag && !hasMore: // DingTalk may echo a terminal cursor even when hasMore=false. The // boolean is the authoritative exhaustion signal; never expose that // non-resumable cursor as meta.pagination.next_token. pg.EndpointExhausted = true case !hasFlag && hasCursor && cursor != "": pg.EndpointExhausted = false pg.NextToken = cursor default: return nil, fmt.Errorf("pagination nextCursor is empty without an exhaustion signal") } return &output.Meta{Pagination: pg}, nil } func devAppDataWithoutPagination(payload any) any { object, ok := payload.(map[string]any) if !ok { return payload } data := make(map[string]any, len(object)) for key, value := range object { switch key { case "hasMore", "nextCursor": continue default: data[key] = value } } return data } func devAppMultiProfileResult(content map[string]any) output.CommandResult { if !devAppContentBool(content, "multiProfile") { return nil } profiles, ok := content["profiles"].([]any) if !ok || len(profiles) == 0 { return nil } succeeded := make([]any, 0, len(profiles)) failed := make([]output.PartialFailedEntry, 0) unknown := make([]output.PartialUnknownEntry, 0) for i, raw := range profiles { entry, ok := raw.(map[string]any) if !ok { unknown = append(unknown, output.PartialUnknownEntry{ ID: fmt.Sprintf("profile-%d", i+1), Reason: "profile result is malformed; terminal state cannot be confirmed", }) continue } id := devAppFirstContentString(entry, "selector", "profile") if id == "" { id = fmt.Sprintf("profile-%d", i+1) } if devAppContentBool(entry, "ok") { preserved := make(map[string]any, len(entry)+1) for key, value := range entry { preserved[key] = value } if _, exists := preserved["id"]; !exists { preserved["id"] = id } succeeded = append(succeeded, preserved) continue } errorInfo := &output.ErrorInfo{Type: "api", Message: "profile execution failed"} if errorMap, ok := entry["error"].(map[string]any); ok { errorInfo = devAppErrorInfo(errorMap, "profile execution failed") if category := devAppFirstContentString(errorMap, "type", "category"); category != "" { errorInfo.Type = devAppWireErrorType(category) } errorInfo.Subtype = devAppFirstContentString(errorMap, "subtype", "reason") errorInfo.Stage = devAppFirstContentString(errorMap, "stage") errorInfo.Origin = devAppFirstContentString(errorMap, "origin") errorInfo.Operation = devAppFirstContentString(errorMap, "operation") errorInfo.RequestID = devAppFirstContentString(errorMap, "request_id", "requestId") errorInfo.TraceID = devAppFirstContentString(errorMap, "trace_id", "traceId") errorInfo.Hint = devAppFirstContentString(errorMap, "hint") if retryable, present := errorMap["retryable"].(bool); present { errorInfo.Retryable = retryable } if executionStarted, present := errorMap["execution_started"].(bool); present { errorInfo.ExecutionStarted = &executionStarted } if actions, present := errorMap["actions"].([]string); present { errorInfo.Actions = append([]string(nil), actions...) } else if rawActions, present := errorMap["actions"].([]any); present { for _, rawAction := range rawActions { if action, ok := rawAction.(string); ok { errorInfo.Actions = append(errorInfo.Actions, action) } } } if details, present := errorMap["details"].(map[string]any); present { errorInfo.Details = details } } failed = append(failed, output.PartialFailedEntry{ID: id, Error: errorInfo}) } if len(failed) == 0 && len(unknown) == 0 { return nil } if len(succeeded) == 0 { details := make([]any, 0, len(failed)+len(unknown)) for _, entry := range failed { details = append(details, map[string]any{"id": entry.ID, "error": entry.Error}) } for _, entry := range unknown { details = append(details, map[string]any{"id": entry.ID, "unknown_reason": entry.Reason}) } return output.Failure(&output.ErrorInfo{ Type: "api", Message: "no selected profile has a confirmed success", Details: map[string]any{"profiles": details}, }) } partial, err := output.NewPartialData(len(profiles), succeeded, failed, unknown) if err != nil { return output.Failure(&output.ErrorInfo{Type: "internal", Message: err.Error()}) } return output.Partial(partial) } func devAppFailureResult(content map[string]any) output.CommandResult { status := strings.ToUpper(devAppFirstContentString(content, "status", "taskStatus", "versionStatus", "processStatus")) if rawSuccess, present := content["success"]; present { success, isBool := rawSuccess.(bool) if !isBool { return output.Failure(&output.ErrorInfo{ Type: "api", Subtype: "invalid_success_type", Message: "dev response success field must be a JSON boolean", Hint: "写操作先核查目标状态;读取操作保留脱敏响应证据后排查上游。", Operation: "devapp.response_projection", }) } if !success { return output.Failure(devAppErrorInfo(content, "dev operation failed")) } } if status == "FAIL" || status == "FAILED" || status == "EXPIRED" { return output.Failure(devAppErrorInfo(content, "dev operation "+strings.ToLower(status))) } return nil } func devAppErrorInfo(content map[string]any, fallback string) *output.ErrorInfo { message := devAppFirstContentString(content, "errorMsg", "errorMessage", "message") if message == "" { message = fallback } info := &output.ErrorInfo{Type: "api", Message: message} if code, ok := content["errorCode"]; ok { info.UpstreamCode = code } else if code, ok := content["code"]; ok { info.UpstreamCode = code } return info } func devAppWireErrorType(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "api", "auth", "validation", "permission", "discovery", "internal": return strings.ToLower(strings.TrimSpace(raw)) case "authorization", "forbidden": return "permission" default: // Upstream transport labels such as network/timeout are subtypes of an // API operation failure, not new top-level Agent branch keys. return "api" } } func devAppPendingResult(content map[string]any) output.CommandResult { state := strings.ToUpper(devAppFirstContentString(content, "completionState", "status", "taskStatus", "versionStatus", "processStatus", "approvalStatus")) nonTerminal := !devAppContentBool(content, "terminal") && (devAppContentBool(content, "mustContinue") || devAppContentBool(content, "mustAskUser")) approvalPending := devAppContentBool(content, "approvalSubmitted") && !devAppContentBool(content, "published") isPendingState := state == "WAITING" || state == "PENDING" || state == "PROCESSING" || state == "AUDIT" || state == "UNDER_REVIEW" || strings.HasPrefix(state, "WAITING_") || strings.HasPrefix(state, "BLOCKED_BY_") if !isPendingState && !nonTerminal && !approvalPending { return nil } if state == "" { state = "WAITING_FOR_ACTION" } id := devAppFirstContentString(content, "taskId", "versionId", "unifiedAppId", "requestId") if id == "" { return output.Failure(&output.ErrorInfo{Type: "internal", Message: "non-terminal dev response is missing an operation identifier"}) } next := devAppFirstNextCommand(content) if next == "" { next = devAppRecoveryCommand(content) } if next == "" { return output.Failure(&output.ErrorInfo{Type: "internal", Message: "non-terminal dev response is missing a recovery command"}) } return output.Pending(content, &output.OperationInfo{ID: id, State: strings.ToLower(state), NextCommand: next}) } func devAppRecoveryCommand(content map[string]any) string { if taskID := devAppFirstContentString(content, "taskId"); taskID != "" { return fmt.Sprintf("dws dev app robot result --task-id %s --format json", taskID) } appID := devAppFirstContentString(content, "unifiedAppId") versionID := devAppFirstContentString(content, "versionId") if appID != "" && versionID != "" { return fmt.Sprintf("dws dev app version status --unified-app-id %s --version-id %s --format json", appID, versionID) } return "" } func devAppFirstNextCommand(content map[string]any) string { steps, ok := content["nextSteps"].([]map[string]any) if ok { for _, step := range steps { if command := devAppFirstContentString(step, "command", "dryRunCommand"); command != "" { return command } } } if rawSteps, ok := content["nextSteps"].([]any); ok { for _, raw := range rawSteps { if step, ok := raw.(map[string]any); ok { if command := devAppFirstContentString(step, "command", "dryRunCommand"); command != "" { return command } } } } return "" } // normalizeDevAppServiceResult unwraps the op-app ServiceResult envelope // ({content:{success:true, result:{...}}}) down to its inner result, so a // successful tool call renders its payload directly instead of the wrapper. func normalizeDevAppServiceResult(result executor.Result) executor.Result { content, ok := result.Response["content"].(map[string]any) if !ok { return result } if success, ok := content["success"].(bool); !ok || !success { return result } value, ok := content["result"] if !ok || value == nil { return result } result.Response["content"] = value return result } // normalizeDevAppToolResult applies per-tool response shape fixes: flatten // remove-permission's removedScopeValues to a string array, stamp explicit // lifecycle booleans, and enrich async robot creation results with next steps. func normalizeDevAppToolResult(tool string, result executor.Result) executor.Result { content, ok := result.Response["content"].(map[string]any) if !ok { return result } switch tool { case devAppPermissionRmTool: normalizeDevAppScopeValueArray(content, "removedScopeValues") case devAppDisableTool: if _, ok := content["disabled"]; !ok { content["disabled"] = true } case devAppEnableTool: if _, ok := content["enabled"]; !ok { content["enabled"] = true } case devAppVersionPublishTool: normalizeDevAppVersionApproval(content) case devAppRobotResultTool: normalizeDevAppRobotResult(content) } return result } func normalizeDevAppVersionApproval(content map[string]any) { candidates, ok := content["approvalCandidates"].([]any) if !ok || len(candidates) == 0 { return } options := make([]map[string]any, 0, len(candidates)) for i, raw := range candidates { candidate, ok := raw.(map[string]any) if !ok { continue } userID := devAppFirstContentString(candidate, "userId", "userID", "userid", "staffId") name := devAppFirstContentString(candidate, "name", "userName", "displayName", "nick", "nickName") mainAdmin := devAppContentBool(candidate, "mainAdmin") label := devAppApprovalCandidateLabel(name, userID, mainAdmin) if label == "" { label = fmt.Sprintf("候选审批人 %d", i+1) } option := map[string]any{ "index": i + 1, "key": devAppOptionKey(i), "label": label, "name": name, "userId": userID, "mainAdmin": mainAdmin, } options = append(options, option) } if len(options) == 0 { return } content["approvalOptions"] = options approvalMode := strings.ToUpper(devAppContentString(content, "approvalMode")) if approvalMode != "SELECT_APPROVER" { return } unifiedAppID := devAppContentString(content, "unifiedAppId") if unifiedAppID == "" { unifiedAppID = "" } versionID := devAppContentString(content, "versionId") if versionID == "" { versionID = "" } // 预渲染一段"原样照抄即可"的审批人列表:序号复用 approvalOptions[].key // (A-Z 后转数字),label 已是「姓名(userId: xxx)」。agent 直接展示 // approvalPromptText 即可,无需自己遍历 approvalOptions——此前有 agent 误把 // approvalOptions 当成 [{options:[...]}]、取空后只回退显示 userId,姓名全丢。 title := fmt.Sprintf("版本发布需要审批,请选择一位审批人(共 %d 位):", len(options)) var promptBuilder strings.Builder promptBuilder.WriteString(title) for _, opt := range options { key, _ := opt["key"].(string) label, _ := opt["label"].(string) promptBuilder.WriteString(fmt.Sprintf("\n%s. %s", key, label)) } promptText := promptBuilder.String() content["completionState"] = "WAITING_FOR_APPROVER_SELECTION" content["actionRequired"] = "select_approver" content["mustAskUser"] = true content["requiresUserInput"] = true content["terminal"] = false content["approvalPromptText"] = promptText content["message"] = "版本发布需要选择审批人;请原样展示 approvalPromptText 的完整内容,等待用户选择,不要只显示 userId、不要自行截取、不能默认取第一个" content["nextSteps"] = []map[string]any{ { "id": "select_approver", "blocking": true, "requiresUserInput": true, "doneWhen": "用户从 approvalOptions 中选择一位审批人,得到对应 userId", }, devAppNextStep(devAppStep{ ID: "publish_version", Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id --format json", unifiedAppID, versionID), DryRunCommand: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id %s --approver-user-id --dry-run --format json", unifiedAppID, versionID), DoneWhen: "approvalSubmitted=true、versionStatus=AUDIT 或 processStatus=UNDER_REVIEW 表示已提交审批;published=true 表示已发布", Blocking: true, }), } } func devAppApprovalCandidateLabel(name, userID string, mainAdmin bool) string { label := strings.TrimSpace(name) switch { case label != "" && userID != "": label = fmt.Sprintf("%s(userId: %s)", label, userID) case label == "" && userID != "": label = "userId: " + userID } if label != "" && mainAdmin { label += "(主管理员)" } return label } func devAppOptionKey(index int) string { const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if index >= 0 && index < len(letters) { return string(letters[index]) } return fmt.Sprintf("%d", index+1) } func normalizeDevAppRobotResult(content map[string]any) { status := strings.ToUpper(devAppContentString(content, "status")) if status == "" { return } taskID := devAppContentString(content, "taskId") clientID := devAppFirstContentString(content, "clientId", "appKey") clientSecret := devAppFirstContentString(content, "clientSecret", "appSecret") unifiedAppID := devAppContentString(content, "unifiedAppId") localConnectReady := clientID != "" && clientSecret != "" lifecycle := map[string]any{ "status": status, "localConnectReady": false, "localOnlyReady": false, "publicUseReady": false, "requiresVersionPublish": false, "robotTaskDone": false, "overallComplete": false, } var steps []map[string]any switch status { case "WAITING": lifecycle["phase"] = "creating" lifecycle["completionGate"] = "robot_result" if interval := content["intervalSeconds"]; interval != nil { lifecycle["retryAfterSeconds"] = interval } steps = append(steps, devAppRobotPollStep(taskID)) case "SUCCESS", "APPROVAL_REQUIRED": lifecycle["phase"] = "created_pending_publish" lifecycle["localConnectReady"] = localConnectReady lifecycle["localOnlyReady"] = localConnectReady lifecycle["requiresVersionPublish"] = true lifecycle["robotTaskDone"] = true if unifiedAppID == "" { lifecycle["completionGate"] = "provide_unified_app_id" lifecycle["blockingStepIds"] = []string{"provide_unified_app_id"} steps = append(steps, devAppRobotProvideUnifiedAppIDStep()) devAppMarkMissingUnifiedAppIDBlocked(content) } else { lifecycle["completionGate"] = "version_publish" lifecycle["blockingStepIds"] = devAppRobotPublishStepIDs() steps = append(steps, devAppRobotPublishSteps(unifiedAppID)...) devAppMarkVersionPublishBlocked(content) } if localConnectReady { steps = append(steps, devAppRobotConnectStep(clientID, unifiedAppID)) } case "FAIL": lifecycle["phase"] = "failed" lifecycle["robotTaskDone"] = true lifecycle["completionGate"] = "retry_robot_submit" steps = append(steps, devAppRobotRetryStep(taskID, true)) case "EXPIRED": lifecycle["phase"] = "expired" lifecycle["robotTaskDone"] = true lifecycle["completionGate"] = "retry_robot_submit" steps = append(steps, devAppRobotRetryStep(taskID, false)) default: lifecycle["phase"] = "unknown" } content["lifecycle"] = lifecycle if len(steps) > 0 { content["nextSteps"] = steps } } func devAppMarkVersionPublishBlocked(content map[string]any) { content["completionState"] = "BLOCKED_BY_VERSION_PUBLISH" content["mustContinue"] = true content["actionRequired"] = "submit_version_publish" content["message"] = "本地建联可用,但线上发布/审批未完成;必须继续执行 blocking nextSteps" content["terminal"] = false } func devAppMarkMissingUnifiedAppIDBlocked(content map[string]any) { content["completionState"] = "BLOCKED_BY_MISSING_UNIFIED_APP_ID" content["mustContinue"] = true content["mustAskUser"] = true content["actionRequired"] = "provide_unified_app_id" content["message"] = "缺少明确来源的 unifiedAppId,不能用 clientId/appKey 反查后写版本;请提供 dev app create 或 robot result 返回的 unifiedAppId" content["terminal"] = false } func devAppRobotPublishSteps(appID string) []map[string]any { steps := []map[string]any{ devAppNextStep(devAppStep{ ID: "create_version", Command: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --format json", appID), DryRunCommand: fmt.Sprintf("dws dev app version create --unified-app-id %s --desc \"发布机器人能力\" --dry-run --format json", appID), DoneWhen: "返回 versionId", Blocking: true, }), devAppNextStep(devAppStep{ ID: "check_approval", Command: fmt.Sprintf("dws dev app version check-approval --unified-app-id %s --version-id --format json", appID), DoneWhen: "返回 requiresApproval、approvalMode、approvalCandidates 等审批信息", Blocking: true, }), devAppNextStep(devAppStep{ ID: "publish_version", Command: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id --format json", appID), DryRunCommand: fmt.Sprintf("dws dev app version publish --unified-app-id %s --version-id --dry-run --format json", appID), DoneWhen: "published=true 表示已发布;approvalSubmitted=true、versionStatus=AUDIT 或 processStatus=UNDER_REVIEW 表示已提交审批;SELECT_APPROVER 时必须先让用户从 approvalCandidates 选择审批人后追加 --approver-user-id", RequiresUserInput: true, Blocking: true, }), devAppNextStep(devAppStep{ ID: "wait_release", Command: fmt.Sprintf("dws dev app version status --unified-app-id %s --version-id --format json", appID), DoneWhen: "versionStatus=RELEASE 表示已生效;versionStatus=AUDIT 或 processStatus=UNDER_REVIEW 表示已提交审批,等待审批通过", Blocking: true, }), } return steps } func devAppRobotPublishStepIDs() []string { return []string{"create_version", "check_approval", "publish_version", "wait_release"} } func devAppRobotProvideUnifiedAppIDStep() map[string]any { return map[string]any{ "id": "provide_unified_app_id", "blocking": true, "requiresUserInput": true, "doneWhen": "用户提供 dev app create 或 robot result 返回的明确 unifiedAppId;不能用 clientId/appKey 自动反查后继续写版本", } } func devAppRobotPollStep(taskID string) map[string]any { if taskID == "" { taskID = "" } return devAppNextStep(devAppStep{ ID: "poll_robot_result", Command: fmt.Sprintf("dws dev app robot result --task-id %s --format json", taskID), DoneWhen: "status 变为 SUCCESS、APPROVAL_REQUIRED、FAIL 或 EXPIRED", Blocking: true, }) } func devAppRobotRetryStep(taskID string, reuseTaskID bool) map[string]any { taskIDFlag := "" if reuseTaskID { if taskID == "" { taskID = "" } taskIDFlag = " --task-id " + taskID } return devAppNextStep(devAppStep{ ID: "retry_robot_submit", Command: fmt.Sprintf("dws dev app robot submit --name --robot-name --desc %s --format json", taskIDFlag), DryRunCommand: fmt.Sprintf("dws dev app robot submit --name --robot-name --desc %s --dry-run --format json", taskIDFlag), DoneWhen: "返回新的 WAITING taskId;FAIL 场景优先复用原 taskId,EXPIRED 场景重新提交", Blocking: true, }) } // devAppRobotConnectStep advertises the local-debug connect command. The // preferred form is `--unified-app-id`, which reuses `dev app credentials get` // to fetch clientSecret at runtime — the secret never appears in argv, so it // stays hidden from `ps` / journald / shell history. Only when unifiedAppID is // unavailable do we fall back to `--robot-client-id`, and even then we point // the caller at the safe path in doneWhen instead of hardcoding a // clientSecret placeholder into the command string. func devAppRobotConnectStep(clientID, unifiedAppID string) map[string]any { var command, doneWhen string if unifiedAppID != "" { command = fmt.Sprintf("dws dev connect --unified-app-id %s --format json", unifiedAppID) doneWhen = "本地 Stream 建联成功,进程保持运行;密钥由 credentials get 后台取回,命令行不出现 clientSecret" } else { if clientID == "" { clientID = "" } command = fmt.Sprintf("dws dev connect --robot-client-id %s --format json", clientID) doneWhen = "本地 Stream 建联成功;建议改用 --unified-app-id ,避免 clientSecret 出现在命令行被 ps 看到" } step := devAppNextStep(devAppStep{ ID: "connect_local", Command: command, DoneWhen: doneWhen, }) step["sensitiveFields"] = []string{"clientSecret"} step["optional"] = true step["scope"] = "local_debug_only" return step } // devAppStep describes one nextSteps entry. Using named fields keeps call sites // self-documenting instead of relying on a trailing pair of positional bools. type devAppStep struct { ID string Command string DryRunCommand string DoneWhen string RequiresUserInput bool Blocking bool } func devAppNextStep(step devAppStep) map[string]any { out := map[string]any{ "id": step.ID, "requiresUserInput": step.RequiresUserInput, "blocking": step.Blocking, "doneWhen": step.DoneWhen, } if step.Command != "" { out["command"] = step.Command } if step.DryRunCommand != "" { out["dryRunCommand"] = step.DryRunCommand } return out } func devAppFirstContentString(content map[string]any, keys ...string) string { for _, key := range keys { if value := devAppContentString(content, key); value != "" { return value } } return "" } func devAppContentString(content map[string]any, key string) string { value, ok := content[key] if !ok || value == nil { return "" } if text, ok := value.(string); ok { return strings.TrimSpace(text) } return strings.TrimSpace(fmt.Sprint(value)) } func devAppContentBool(content map[string]any, key string) bool { value, ok := content[key] if !ok || value == nil { return false } switch v := value.(type) { case bool: return v case string: return strings.EqualFold(strings.TrimSpace(v), "true") default: return strings.EqualFold(strings.TrimSpace(fmt.Sprint(v)), "true") } } // normalizeDevAppScopeValueArray rewrites an array of scope objects (or strings) // into a flat string array of scopeValues, leaving the field untouched if any // element is an unexpected shape. func normalizeDevAppScopeValueArray(content map[string]any, key string) { values, ok := content[key].([]any) if !ok { return } out := make([]string, 0, len(values)) for _, value := range values { switch typed := value.(type) { case string: if typed != "" { out = append(out, typed) } case map[string]any: if scopeValue, _ := typed["scopeValue"].(string); scopeValue != "" { out = append(out, scopeValue) } } } if len(out) == len(values) { content[key] = out } } func parseDevAppListFlag(cmd *cobra.Command, name string) []string { raw, _ := cmd.Flags().GetString(name) return splitDevAppList(raw) } func splitDevAppList(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } raw = strings.ReplaceAll(raw, ";", ",") parts := strings.Split(raw, ",") values := make([]string, 0, len(parts)) for _, part := range parts { if value := strings.TrimSpace(part); value != "" { values = append(values, value) } } return values } // transformDevAppListParam splits a comma/semicolon list for LeafFlag.Transform. // Empty input (including separator-only) returns nil so optional flags omit the // key from toolArgs. Required flags that collapse to empty are rejected by // corecmd.BuildArgs after transform. func transformDevAppListParam(raw string) (any, error) { values := splitDevAppList(raw) if len(values) == 0 { return nil, nil } return values, nil } // transformDevAppScopeValues preserves the double-split semantics (each // comma-separated token may itself be a list). func transformDevAppScopeValues(raw string) (any, error) { values := splitDevAppList(raw) out := make([]string, 0, len(values)) for _, value := range values { for _, part := range splitDevAppList(value) { if part != "" { out = append(out, part) } } } if len(out) == 0 { return nil, nil } return out, nil } // 应用定位:写操作统一只用 --unified-app-id;dev app get 额外支持只读 --app-key。 // --name 定位已下线(列表搜索的 --name/--app-key 是过滤参数、不在此列)。 // 写叶确认门已声明化为 SafetySpec.Confirmation=user_required。 func devAppStringFlag(cmd *cobra.Command, name string) string { value, _ := cmd.Flags().GetString(name) return strings.TrimSpace(value) } func devAppIntFlag(cmd *cobra.Command, name string) int { value, _ := cmd.Flags().GetInt(name) return value } func devAppFlagOrFallback(cmd *cobra.Command, primary, fallback string) string { if value := devAppStringFlag(cmd, primary); value != "" { return value } return devAppStringFlag(cmd, fallback) } func devAppPutString(params map[string]any, key, value string) { if value != "" { params[key] = value } } func devAppPutInt(params map[string]any, key string, value int) { if value != 0 { params[key] = value } }