diff --git a/.github/workflows/auto-dev-release.yml b/.github/workflows/auto-dev-release.yml new file mode 100644 index 00000000..9cc2426f --- /dev/null +++ b/.github/workflows/auto-dev-release.yml @@ -0,0 +1,103 @@ +name: Auto Dev Release + +# 推到 dev 分支 → 跑测试 → 通过才自动出一个 dev 预览 release。 +# install-devapp.sh / .ps1 取 fork 最新 release,所以发完安装链接即最新。 +# +# 设计要点: +# - 自包含:测试 + 算版本 + 打 tag + 发 release 全在一个 job,不依赖 tag 触发 +# release.yml(GITHUB_TOKEN 推的 tag 本来也不会触发别的 workflow)。 +# - 质量门:测试这一步失败,后面发布步骤就不会跑。 +# - 不含 npm publish,所以自动发布天然是绿的。 +on: + push: + branches: + - feat/dws-devapp + +# 串行:两次 push 不会同时算版本号撞车 +concurrency: + group: auto-dev-release + cancel-in-progress: false + +permissions: + contents: write + +jobs: + test-and-release: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install archive tooling + run: sudo apt-get update && sudo apt-get install -y zip unzip + + # ---- 质量门:测试不过就不发 ---- + - name: Build + run: make build + + - name: Test + run: go test -race -count=1 -timeout=5m ./cmd/... ./internal/... + + # ---- 算下一个 dev 版本号并打 tag ---- + - name: Compute next dev version + id: ver + run: | + set -eu + LATEST=$(git tag -l 'v*-dev.*' --sort=-v:refname | head -1 || true) + if [ -z "$LATEST" ]; then + BASE="v1.0.39"; N=0 + else + BASE=$(printf '%s' "$LATEST" | sed -E 's/-dev\.[0-9]+$//') + N=$(printf '%s' "$LATEST" | sed -E 's/.*-dev\.([0-9]+)$/\1/') + fi + NEW="${BASE}-dev.$((N + 1))" + echo "version=$NEW" >> "$GITHUB_OUTPUT" + echo "next dev release: $NEW (prev: ${LATEST:-none})" + + - name: Create and push tag + run: | + set -eu + V="${{ steps.ver.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$V" + git push origin "$V" + + # ---- 自己发 release(不靠 tag 触发 release.yml)---- + - name: Install rcodesign (ad-hoc sign darwin binaries from Linux) + run: | + set -eu + RCS_VERSION="0.27.0" + curl -fsSL -o /tmp/rcodesign.tar.gz \ + "https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz" + mkdir -p /tmp/rcodesign + tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1 + sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign + rcodesign --version + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Post-release packaging + run: ./scripts/release/post-goreleaser.sh + env: + DWS_PACKAGE_VERSION: ${{ steps.ver.outputs.version }} + + - name: Upload dws-skills.zip to release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "${{ steps.ver.outputs.version }}" dist/dws-skills.zip --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f65b6892..c8101f2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,14 +69,14 @@ jobs: gh release edit "${{ github.ref_name }}" --prerelease=false --latest - name: Setup Node.js - if: github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' uses: actions/setup-node@v4 with: node-version: "20" registry-url: "https://registry.npmjs.org" - name: Publish to npm - if: github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' + # 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉 + if: ${{ github.repository_owner == 'DingTalk-Real-AI' }} working-directory: dist/npm/dingtalk-workspace-cli run: npm publish --access public env: diff --git a/.gitignore b/.gitignore index 58e28be5..ae035cd2 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ test/cli_compat/testdata/ *.pem *.key credentials* -!**/credentials-webapp.md +!skills/**/credentials.md plans _docs dws.zip @@ -38,3 +38,7 @@ dws.zip # local/pre-release MCP service configs may contain personal gateway keys /docs/mcp/serviceconfig-pre* + +# 功能测试运行产物 +results.jsonl +test/dev_functional/results.jsonl diff --git a/README.md b/README.md index 9b7385c2..6dd42b7c 100644 --- a/README.md +++ b/README.md @@ -492,7 +492,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ | Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search | | Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write | | Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files | -| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation | +| DevDoc | `devdoc` | 2 | `article` `error` | Search the DingTalk Open Platform documentation and diagnose API errors | | AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) | | Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives | | Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management | @@ -557,7 +557,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ - [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance - [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion - [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer -- [Open Platform App Command Routing](./docs/devapp-yulan-command-routing.md) — yulan devapp/app command design, MCP overlay, permission flow, and Agent routing +- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing - [Changelog](./CHANGELOG.md) — release history and migration notes ## Contributing diff --git a/README_zh.md b/README_zh.md index ac4309b1..a389c6dc 100644 --- a/README_zh.md +++ b/README_zh.md @@ -488,7 +488,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ | 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 | | 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 | | 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 | -| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 | +| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档并排查 API 调用错误 | | AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) | | 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 | | Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token | @@ -555,7 +555,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \ - [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景 - [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全 - [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层 -- [开放平台应用指令设计](./docs/devapp-yulan-command-routing.md) — yulan devapp/app 应用侧命令、MCP overlay、权限流程与 Agent 路由 +- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由 - [更新日志](./CHANGELOG.md) — 版本历史与迁移说明 ## 贡献指南 diff --git a/docs/command-index.md b/docs/command-index.md index dfd964fc..b012b5fb 100644 --- a/docs/command-index.md +++ b/docs/command-index.md @@ -36,7 +36,7 @@ Every command inherits these flags (documented here once, not repeated per comma - [`dws calendar` — Calendar](#dws-calendar) · 14 commands - [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands - [`dws contact` — Contact Directory](#dws-contact) · 6 commands -- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 commands +- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands - [`dws ding` — DING Messages](#dws-ding) · 2 commands - [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands - [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands @@ -182,11 +182,12 @@ _Users, departments, and directory lookups._ _Search the DingTalk Open Platform documentation._ -**1 commands** +**2 commands** | Command | Description | When to use | |---|---|---| | `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. | +| `dws devdoc error diagnose` | Diagnose DingTalk Open Platform API errors by error code, requestId, or context. | When the agent needs troubleshooting material for a failed OpenAPI call. | ## `dws ding` — DING Messages @@ -320,4 +321,3 @@ _Personal todo task management._ | `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. | | `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. | | `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. | - diff --git a/docs/dev-agent-test-cases.md b/docs/dev-agent-test-cases.md new file mode 100644 index 00000000..e41112f2 --- /dev/null +++ b/docs/dev-agent-test-cases.md @@ -0,0 +1,137 @@ +# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例) + +> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。 +> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。 +> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。 +> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。 + +## 手工评测流程 + +逐条执行,每条三步: + +1. **发起**:在一个干净的 agent 会话里,把该条的「用户说」原样发给 agent(不给额外提示)。 +2. **观察**:看 agent 选了哪些命令、什么 flag、做了哪些判断/追问。 +3. **判分**:对照「通过判据」人工打分。复合用例含多个判据,**全部满足才记 PASS**;部分满足记 PASS\*(半通过)并在备注写清缺哪条。记一行 `用例# | PASS / PASS* / FAIL | 备注(错在哪)`。 + +> 「易错点」是常见扣分项,重点盯。建议每次技能改动后整套重跑,对比上次。 + +## 覆盖矩阵 + +| 用例 | 覆盖的子命令 | 横切行为 | +|------|-------------|---------| +| C1 建应用配齐基础 | app create / get / credentials get / update | dry-run/yes、定位符、密钥脱敏 | +| C2 列表与定位 | app list | cursor 分页、按名定位、多命中候选 | +| C3 生命周期 | app disable / enable / delete | 写后回读、appStatus、pretty 标签、confirm-name 防误删 | +| C4 网页应用到生效 | webapp get / config | 生效模型(改配置≠生效) | +| C5 版本发布全流程 | version create / list / get / check-approval / publish / status | 生效模型、审批人由用户拍板 | +| C6 权限全流程 | permission list / add / remove | 过滤分页、生效模型、批量聚合出参 | +| C7 成员与安全 | member list / add / remove、security config | 整组覆盖语义 | +| C8 机器人与建联 | robot submit / result / get / config / enable / disable、dev connect | 异步轮询、robot info not exist、建联依赖预检、长驻进程 | +| C9 事件与文档排查 | event list / subscribe / unsubscribe、dev doc search | 错误码透传、文档 RAG | +| C10 意图消歧 | (不进 dev,先澄清) | 泛词边界、转其它技能出口 | + +--- + +## 用例 + +### C1. 新建应用并配齐基础 +- **用户说**:「建一个内部应用叫 DemoApp,描述『内部测试』;建好后给我看看它的详情,把它的 AppKey/AppSecret 也取出来;对了名字再改成 DemoApp2。」 +- **覆盖**:`app create` / `get` / `credentials get` / `update`;dry-run/yes、定位符、密钥脱敏。 +- **期望(分步)**: + 1. `app create --name DemoApp --desc 内部测试 --dry-run` → 给用户看 `invocation.params` 确认 → `--yes`,记下返回的 `unifiedAppId`。 + 2. `app get --unified-app-id --format json` 看详情。 + 3. `credentials get --unified-app-id --format json` 取凭证。 + 4. `app update --unified-app-id --name DemoApp2 --dry-run` → `--yes`。 +- **通过判据**:每个写操作先 dry-run 再 yes;全程用 `unifiedAppId` 定位;取凭证走 `credentials get`(不是 app get);`clientSecret/appSecret` 按敏感处理、不明文写进回答。 +- **易错点**:不 dry-run 直接 yes;把 secret 打印给用户;用 `app get` 当取凭证。 + +### C2. 应用列表与按名定位 +- **用户说**:「列出我们企业的开放平台应用,一页 20 条,有下一页继续翻;再帮我找名字叫『早晚会』的那个应用,看它详情。」 +- **覆盖**:`app list`;cursor 分页、按名定位、多命中。 +- **期望(分步)**: + 1. `app list --page-size 20 --format json`;出参有 `nextCursor` 则续翻 `--cursor <上次 nextCursor>` 直到为空。 + 2. `app list --name 早晚会 --format json` 找 `unifiedAppId` → 唯一命中后 `app get --unified-app-id `。 +- **通过判据**:首次不传 `--cursor`,续翻原样回传 `nextCursor`,不自己构造/解析、不跨命令复用;用 list 过滤拿 id 再 get;多条命中时展示候选让用户选、不取第一条。 +- **易错点**:用 `--page/--offset` 翻页;`app get --name xxx`(get 不接受 name 定位)。 + +### C3. 应用生命周期(停用 / 启用 / 删除) +- **用户说**:「先把 DemoApp2 停用,确认停好了告诉我;然后再启用回来;最后这个应用不要了,删掉。」 +- **覆盖**:`app disable` / `enable` / `delete`;写后回读、appStatus、pretty、confirm-name。 +- **期望(分步)**: + 1. `disable --unified-app-id --dry-run` → `--yes` → 回读 `app get`(可 `--format pretty` 看 `appStatusText`),确认 `appStatus=0` 才算停用完成。 + 2. `enable --dry-run` → `--yes` → 回读确认 `appStatus=1`。 + 3. 删除:先 `app get` 展示摘要 → `delete --dry-run` → 真删需 `--confirm-name <应用真实名>`(与定位到的名一致)+ `--yes`。 +- **通过判据**:写成功 ≠ 状态已变,每步回读 appStatus(0停/1激活/2待激活/3过期);删除前展示摘要并让用户确认;confirm-name 匹配才删,读不到应用名时中止(fail-closed)。 +- **易错点**:看到 success 就回报已停/已删不回读;不带 confirm-name 直接删。 + +### C4. 网页应用配置到生效 +- **用户说**:「给这个应用配个钉钉里打开的移动端首页 https://example.com/m,配完要真正能用。」 +- **覆盖**:`webapp config` / `get`;生效模型。 +- **期望(分步)**:`webapp config --unified-app-id --homepage-url https://example.com/m --dry-run` → `--yes` → `webapp get` 回读;明确说明「改配置 ≠ 线上生效,需走版本通道」:`version create → check-approval → publish`(详见 C5)。 +- **通过判据**:先 dry-run 再 yes;配完回读 webapp get;主动点明需发版本才生效,不谎称「已生效」。 +- **易错点**:配完直接说已生效,不提版本通道。 + +### C5. 版本发布全流程(含选审批人) +- **用户说**:「我刚改了配置,发个版本上线;先看下历史版本和这次要发的版本详情;需要审批的话我来选审批人。」 +- **覆盖**:`version create` / `list` / `get` / `check-approval` / `publish` / `status`;生效模型、审批人由用户拍板。 +- **期望(分步)**: + 1. `version create --unified-app-id --version <号> --desc <说明> --yes`,记 `versionId`(新应用 `version list` 空时先 create,不要误判无可发布)。 + 2. `version list` 看历史、`version get --version-id ` 看详情。 + 3. `version check-approval --version-id `(预检,不发布,返回是否需审批 + 候选审批人)。 + 4. 把候选审批人列表给用户选 → `version publish --version-id --approver <用户选的> --yes`(含高敏权限加 `--confirm-sensitive`)。 + 5. `version status --version-id ` 跟踪到 `versionStatus=RELEASE` 才算生效。 +- **通过判据**:check-approval 不实际发布;审批人由用户拍板、agent 不默认取第一个;发布后回读 status 到 RELEASE。 +- **易错点**:跳过 check-approval 直接 publish;agent 自己选审批人;version list 空就说没东西可发。 + +### C6. 权限全流程(查 / 申请 / 批量取消) +- **用户说**:「查下跟『机器人发消息』有关、还没开通的权限;开通其中合适的那个,要真正生效;再把另外两个不需要的权限点 A、B 一起取消掉。」 +- **覆盖**:`permission list` / `add` / `remove`;过滤分页、生效模型、批量聚合。 +- **期望(分步)**: + 1. `permission list --unified-app-id --keyword 机器人发消息 --status UNAUTHED --page-size 50` 找 `scopeValue`(150+ 时用 `nextCursor` 续翻)。 + 2. `permission add --permissions --dry-run` → `--yes`;若 `requiredApproval=true`,走版本通道生效(接 C5)。 + 3. `permission remove --permissions A,B --dry-run` → `--yes`,读出参 `{results, ok, total, failedCount}` 逐条判断。 +- **通过判据**:只传 `scopeValue`(不传 API/分组名);用 keyword+status 过滤、分页不漏;需审批的明确走版本;批量取消读 `ok/failedCount` 报告部分失败,不只看命令成功。 +- **易错点**:把 API 名当权限点;add 后就说开通了;批量 remove 漏报部分失败。 + +### C7. 成员与安全配置 +- **用户说**:「把 userId 张三、李四加成这个应用的开发者,加完看下成员列表,回头把李四移除;另外给应用加一个登录重定向地址 https://b.example.com/cb,别把原来的地址冲掉。」 +- **覆盖**:`member list` / `add` / `remove`、`security config`;整组覆盖。 +- **期望(分步)**: + 1. `member add --unified-app-id --user-ids 张三id,李四id --member-type DEVELOPER --dry-run` → `--yes` → `member list` 回读 → `member remove --user-ids 李四id --member-type DEVELOPER --dry-run` → `--yes`。 + 2. 安全配置:提醒 `--redirect-urls` 是**整组覆盖、不是追加**——要保留原地址需把旧+新一起传:`security config --redirect-urls <旧1,旧2,新> --dry-run` → `--yes`。 +- **通过判据**:`--user-ids` 逗号分隔、`--member-type` 必填、用 userId 不用姓名;识别整组覆盖语义、避免只传新地址冲掉旧的;未提供的字段(如 ip-whitelist)不动。 +- **易错点**:漏 `--member-type`;security 只传新 redirect-urls 把旧的清空。 + +### C8. 机器人建号、配置与本地建联 +- **用户说**:「帮我建一个叫『小助手』的答疑机器人;另外这个现有应用还没机器人,给它也配上并启用;最后把机器人接到我本地的 Claude Code 调试。」 +- **覆盖**:`robot submit` / `result` / `get` / `config` / `enable` / `disable`、`dev connect`;异步轮询、robot info not exist、建联依赖预检、长驻进程、密钥脱敏。 +- **期望(分步)**: + 1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id `,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。 + 2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。 + 3. 建联:`dev connect --channel auto --robot-client-id x --robot-client-secret y --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。 +- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号)。 +- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话。 + +### C9. 事件订阅与上游错误排查 +- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」 +- **覆盖**:`event list` / `subscribe` / `unsubscribe`、`dev doc search`;错误码透传、文档 RAG。 +- **期望(分步)**: + 1. `event list --unified-app-id --page-size 20 --format json` 取 `eventCode` → `event subscribe --unified-app-id --event-codes chat_add_member_org --dry-run` → `--yes` → `event list` 回读 → `event unsubscribe --unified-app-id --event-codes chat_add_member_org --dry-run` → `--yes`。事件码不确定先 `event list` 翻页查。 + 2. 错误码:业务错误 `ServiceResult.success=false` 原样透传 `errorCode/errorMsg`,再 `dev doc search --keyword "errcode 62012 " --format json` 做官方文档 RAG,结论基于命中条目。 +- **通过判据**:`--event-codes` 逗号分隔,写操作先 dry-run;`event list` 使用 `hasMore/nextCursor` 翻页;不编造事件码/错误含义;先透传原始错误再走 RAG,结论不臆测、不编不存在的命令。 +- **易错点**:编事件码;把事件回调地址塞进事件订阅命令;凭空解释错误码。 + +### C10. 意图消歧(泛词边界) +- **用户说**:「帮我建个机器人。」(无任何开放平台上下文) +- **覆盖**:泛词消歧、边界与角色。 +- **期望**:`应用`/`机器人` 是泛词——先追问确认是不是开发者后台的「企业内部应用机器人」,还是工作台应用、或群里发消息的机器人(→ `dingtalk-chat`);确认是开放平台场景后才走 dev 流程(接 C8)。 +- **通过判据**:不直接假设走 dev,先澄清;能正确指向其它技能出口。 +- **易错点**:上来就 `robot submit`,没确认是不是开放平台场景。 + +--- + +## 备注 + +- 10 条合起来覆盖全部 34 个子命令 + 8 类横切行为(见覆盖矩阵)。 +- 评测可分两层:**静态**——无环境,只看 agent 选的命令/flag/判断是否符合「期望/通过判据」;**真机**——有联调环境时核对真实出参。 +- 真机注意:`dev connect` 正式连接是长驻进程;`version publish`/`app delete` 等写操作请用占位应用或停在 dry-run,避免动真实数据。 diff --git a/docs/devapp-agent-install-guide.md b/docs/devapp-agent-install-guide.md index 80d940bb..48d392d4 100644 --- a/docs/devapp-agent-install-guide.md +++ b/docs/devapp-agent-install-guide.md @@ -17,7 +17,7 @@ curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/fe 1. 拉取 `wxianfeng/dingtalk-workspace-cli` 的 `feat/dws-devapp` 分支。 2. 使用本地源码构建 `dws`。 3. 安装 `dws` 到默认目录 `~/.local/bin`。 -4. 安装 Agent Skill 到本机已检测到的 Agent 目录。 +4. 安装 Agent Skill 到本机已检测到的 Agent 目录,只安装通用 `dws` 和 DevApp 专用 `dws-devapp` 两个 skill。 > 预览分支安装需要本机已有 `git`、`go` 和 `make`。Go 版本要求以仓库 `go.mod` 为准。 @@ -41,6 +41,8 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma | `DEVAPP_BRANCH` | 覆盖安装分支,默认 `feat/dws-devapp` | | `DEVAPP_SOURCE_DIR` | 使用已有源码目录安装,跳过 clone | | `DEVAPP_KEEP_SOURCE=1` | 保留临时源码目录,便于调试 | +| `DEVAPP_SKIP_SKILL_SETUP=1` | 跳过自动安装 `dws` 与 `dws-devapp` skill | +| `DEVAPP_SKILL_NAME` | 覆盖 DevApp skill 安装名称,默认 `dws-devapp` | | `DWS_INSTALL_DIR` | 传给底层 `scripts/install.sh`,覆盖 `dws` 安装目录 | | `DWS_SKILL_MODE` | 传给底层 `scripts/install.sh`,选择 `mono` 或 `multi` | @@ -104,7 +106,7 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装 ``` ```text -帮我给 agentId= 的应用配置机器人,先 dry-run 给我确认。 +帮我给 unifiedAppId= 的应用配置机器人,先 dry-run 给我确认。 ``` ```text @@ -132,7 +134,7 @@ dws devapp robot config \ --unified-app-id \ --name "告警机器人" \ --brief "告警通知" \ - --description "处理告警通知和事件回调" \ + --desc "处理告警通知和事件回调" \ --dry-run \ --format json ``` @@ -144,7 +146,7 @@ dws devapp robot config \ --unified-app-id \ --name "告警机器人" \ --brief "告警通知" \ - --description "处理告警通知和事件回调" \ + --desc "处理告警通知和事件回调" \ --yes \ --format json ``` @@ -161,18 +163,18 @@ dws devapp robot get --unified-app-id --format json ```bash dws devapp list --format json -dws devapp get --agent-id --format json -dws devapp create --app-name "考勤应用" --dry-run --format json -dws devapp update --agent-id --app-name "新应用名" --dry-run --format json -dws devapp inactive --agent-id --dry-run --format json -dws devapp active --agent-id --dry-run --format json -dws devapp delete --agent-id --dry-run --format json +dws devapp get --unified-app-id --format json +dws devapp create --name "考勤应用" --dry-run --format json +dws devapp update --unified-app-id --name "新应用名" --dry-run --format json +dws devapp inactive --unified-app-id --dry-run --format json +dws devapp active --unified-app-id --dry-run --format json +dws devapp delete --unified-app-id --dry-run --format json ``` ### 凭证查询 ```bash -dws devapp credentials get --agent-id --format json +dws devapp credentials get --unified-app-id --format json ``` 凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。 @@ -180,9 +182,9 @@ dws devapp credentials get --agent-id --format json ### 权限点管理 ```bash -dws devapp permission list --agent-id --format json -dws devapp permission add --agent-id --scope-values Contact.User.mobile --dry-run --format json -dws devapp permission remove --agent-id --scope-value Contact.User.mobile --dry-run --format json +dws devapp permission list --unified-app-id --format json +dws devapp permission add --unified-app-id --permissions Contact.User.mobile --dry-run --format json +dws devapp permission remove --unified-app-id --permissions Contact.User.mobile --dry-run --format json ``` 权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。 @@ -192,25 +194,25 @@ dws devapp permission remove --agent-id --scope-value Contact.User.mob ```bash dws devapp robot get --unified-app-id --format json dws devapp robot config --unified-app-id --name "机器人名称" --dry-run --format json -dws devapp robot update --unified-app-id --brief "新的简介" --dry-run --format json -dws devapp robot enable --unified-app-id --name "机器人名称" --dry-run --format json -dws devapp robot offline --unified-app-id --dry-run --format json +dws devapp robot enable --unified-app-id --dry-run --format json +dws devapp robot disable --unified-app-id --dry-run --format json ``` ### 成员与安全 ```bash -dws devapp member list --agent-id --format json -dws devapp member add --agent-id --user-ids --dry-run --format json -dws devapp member remove --agent-id --user-ids --dry-run --format json -dws devapp security config --unified-app-id --redirect-urls --dry-run --format json -dws devapp security config --unified-app-id --ip-white-list --dry-run --format json +dws devapp member list --unified-app-id --format json +dws devapp member add --unified-app-id --users --dry-run --format json +dws devapp member remove --unified-app-id --users --dry-run --format json +dws devapp security config --unified-app-id --redirect-url --dry-run --format json +dws devapp security config --unified-app-id --ip-whitelist --dry-run --format json ``` ### 版本发布 ```bash dws devapp version list --unified-app-id --format json +dws devapp version list --unified-app-id --cursor --format json dws devapp version create --unified-app-id --dry-run --format json dws devapp version publish --unified-app-id --version-id --dry-run --format json dws devapp version status --unified-app-id --version-id --format json @@ -272,7 +274,7 @@ dws devapp robot get --unified-app-id --format json ``` 如果机器人不存在,使用 `robot config` 创建或配置。 -如果机器人已存在,用 `robot update` 修改配置,或用 `robot enable` 重新启用。 +如果机器人已存在,继续用 `robot config` 修改配置,或用 `robot enable` 重新启用。 ## 页面文案建议 diff --git a/docs/devapp-yulan-command-routing.md b/docs/devapp-yulan-command-routing.md index c91dcf69..583f7739 100644 --- a/docs/devapp-yulan-command-routing.md +++ b/docs/devapp-yulan-command-routing.md @@ -85,33 +85,32 @@ dws devapp permission search Search permission candidates, alias over list semantics permission detail Show one permission's full API coverage permission add Apply permissions into current app version changes - permission remove Remove one authorized permission - event list List event subscription config - event config Configure callback URL, token, aesKey, event types + permission remove Remove authorized permissions + event list List subscribable events and current subscription state + event subscribe Subscribe events by eventCodes + event unsubscribe Unsubscribe events by eventCodes robot create Create a new agent app + robot synchronously robot submit Submit async robot-create task (supports retry by taskId) robot result Query async robot-create task result robot get Get robot config of an existing app - robot config Create robot config on an existing app - robot update Update robot config + robot config Create or update robot config on an existing app robot enable Enable / re-enable robot capability - robot offline Offline robot capability + robot disable Disable robot capability version create Save app version - version list List app versions (paged) + version list List app versions (cursor paged) version get Get one version detail version check-approval Precheck publish approval requirements (no publish) version publish Submit publish, possibly creating approval version status Poll publish and approval status ``` -Robot and version commands are hardcoded helper leaves over the `op-app` MCP -server (same pinned endpoint as the rest of `devapp`, no service discovery). -`event` is still spec-only and reports unsupported at runtime. +Robot, version, and event commands are hardcoded helper leaves over the `op-app` +MCP server (same pinned endpoint as the rest of `devapp`, no service discovery). Open-source examples should use `--format json` for agent consumption: ```bash -dws devapp list --name DemoApp --page 1 --page-size 20 --format json +dws devapp list --name DemoApp --page-size 20 --format json dws devapp get --unified-app-id UNIFIED_APP_ID --format json dws devapp permission list --unified-app-id UNIFIED_APP_ID --format json ``` @@ -129,24 +128,24 @@ Do not send confirmation fields such as `confirmCreate`, `confirmUpdate`, `confi | Area | CLI command | MCP tool | Backend HSF facade | Current backend status | Open-source DWS status | | --- | --- | --- | --- | --- | --- | -| App query | `devapp list` | `list_open_dev_apps_by_condition` | `OpenInnerAppQueryFacade.listByCondition` | Implemented | Hardcoded helper | -| App detail | `devapp get` | `get_open_dev_app_detail` | `OpenInnerAppQueryFacade.getDetail` | Implemented | Hardcoded helper | -| App create | `devapp create` | `create_inner_app` | `OpenInnerAppManageFacade.create` | Implemented | Hardcoded helper | -| App update | `devapp update` | `update_inner_app` | `OpenInnerAppManageFacade.update` | Implemented | Hardcoded helper | -| App delete | `devapp delete` | `delete_inner_app` | `OpenInnerAppManageFacade.delete` | Implemented | Hardcoded helper | -| Credentials | `devapp credentials get` | `get_open_dev_app_credentials` | `OpenInnerAppQueryFacade.getCredentials` | Implemented | Hardcoded helper | -| Web app | `devapp webapp get/config` | `get_webapp_config`/`set_webapp_config` | Webapp facade | Implemented | Hardcoded helper; verified `config` then `get` returns `agentId/h5PageType/homepageLink/pcHomepageLink/ompLink`; before config, `get` may only return `agentId`. | -| Permission list | `devapp permission list/search/detail` | `list_open_dev_app_permissions` | `OpenInnerAppPermissionFacade.list` | Implemented | Hardcoded helper | -| Permission apply | `devapp permission add` | `apply_open_dev_app_permissions` | `OpenInnerAppPermissionFacade.apply` | Implemented | Hardcoded helper | -| Permission remove | `devapp permission remove` | `remove_open_dev_app_permission` | `OpenInnerAppPermissionFacade.remove` | Implemented | Hardcoded helper | -| Events | `devapp event list/config` | `list/config_open_dev_app_events` | Event facade pending | Pending | Spec only | -| Robot create | `devapp robot create/submit/result` | `submit_robot_create_task`/`query_robot_create_result` (sync `create_dingtalk_robot` broken server-side, see issue #35) | Implemented | Hardcoded helper | -| Robot config | `devapp robot get/config/update/enable/offline` | `get/create/update_open_dev_app_robot_config`, `enable/offline_open_dev_app_robot` | Implemented | Hardcoded helper | -| Version | `devapp version create/list/get/check-approval/publish/status` | `create/list_open_dev_app_versions`, `get_open_dev_app_version_detail/status`, `publish_open_dev_app_version` | Implemented | Hardcoded helper | +| App query | `devapp list` | `list_dev_app` | `OpenInnerAppQueryFacade.listByCondition` | Implemented | Hardcoded helper | +| App detail | `devapp get` | `get_dev_app` | `OpenInnerAppQueryFacade.getDetail` | Implemented | Hardcoded helper | +| App create | `devapp create` | `create_dev_app` | `OpenInnerAppManageFacade.create` | Implemented | Hardcoded helper | +| App update | `devapp update` | `update_dev_app` | `OpenInnerAppManageFacade.update` | Implemented | Hardcoded helper | +| App delete | `devapp delete` | `delete_dev_app` | `OpenInnerAppManageFacade.delete` | Implemented | Hardcoded helper | +| Credentials | `devapp credentials get` | `get_dev_app_credentials` | `OpenInnerAppQueryFacade.getCredentials` | Implemented | Hardcoded helper | +| Web app | `devapp webapp get/config` | `get_extension_webapp_config`/`set_extension_webapp_config` | Webapp facade | Implemented | Hardcoded helper; locator field is `unifiedAppId`; config fields are `h5PageType/homepageUrl/pcHomepageUrl/ompUrl`. | +| Permission list | `devapp permission list/search/detail` | `list_dev_app_permissions` | `OpenInnerAppPermissionFacade.list` | Implemented | Hardcoded helper | +| Permission apply | `devapp permission add` | `apply_dev_app_permissions` | `OpenInnerAppPermissionFacade.apply` | Implemented | Hardcoded helper | +| Permission remove | `devapp permission remove` | `remove_dev_app_permissions` | `OpenInnerAppPermissionFacade.remove` | Implemented | Hardcoded helper | +| Events | `devapp event list/subscribe/unsubscribe` | `list_dev_app_events`, `subscribe_dev_app_events`, `unsubscribe_dev_app_events` | Implemented | Hardcoded helper | +| Robot create | `devapp robot create/submit/result` | `create_dingtalk_robot`/`submit_robot_create_task`/`query_robot_create_result` | Implemented | Hardcoded helper | +| Robot config | `devapp robot get/config/enable/disable` | `get_extension_robot_config`, `set_extension_robot_config`, `enable_dev_app_robot`, `disable_dev_app_robot` | Implemented | Hardcoded helper | +| Version | `devapp version create/list/get/check-approval/publish/status` | `create_dev_app_version`, `list_dev_app_versions`, `get_dev_app_version_detail`, `publish_dev_app_version`, `get_dev_app_version_status` | Implemented | Hardcoded helper | -P0 for yulan is app CRUD, permissions, credentials, and web app config. Robot -and version are now implemented as hardcoded helper leaves over the same -`op-app` MCP server; events remain a pending backend slice. +P0 for yulan is app CRUD, permissions, credentials, and web app config. Robot, +version, and event subscription are now all implemented as hardcoded helper +leaves over the same `op-app` MCP server. ### 3.1 P0 RPC Contract @@ -180,36 +179,32 @@ MCP output configuration should keep the wrapper fields and configure success as `ServiceResult.success == true`. DWS/Agent normalization can add `ok`, `reason`, `summary`, and `nextAction`, but raw `errorCode/errorMsg` must remain visible. +Naming convention: + +- Public CLI flags use user-facing names: `--name`, `--desc`, `--icon`, `--homepage-url`. +- MCP app create/update use the current backend request field names: `name`, `desc`, `icon`. +- MCP app list/detail use the simplified locator/search fields: `unifiedAppId`, `appKey`, `name`. +- The CLI helper keeps public flags stable, for example `--name` -> `name` for create/update/list/detail. + ### 3.2 P0 Tool Contracts -#### list_open_dev_apps_by_condition +#### list_dev_app | Item | Contract | | --- | --- | | CLI | `dws devapp list` | | HSF facade | `OpenInnerAppQueryFacade.listByCondition` | | Request shape | One object with system context and optional list filters. | -| Required public fields | None; default `currentPage=1`, `pageSize=20`. | +| Required public fields | None; default `pageSize=20`. | | Identity source | `corpId/userId` from MCP system context. | Input fields: | Field | Type | CLI flag | Required | Notes | | --- | --- | --- | --- | --- | -| `currentPage` | number | `--page` | no | 1-based page number. | | `pageSize` | number | `--page-size` | no | Default 20; cap by backend. | -| `appName` | string | `--name` | no | App name keyword. | -| `agentId` | number | `--agent-id` | no | Exact DingTalk app agentId. | -| `appId` | number | `--app-id` | no | Compatible app id. | -| `appKey` | string | `--app-key` | no | DingTalk appKey/clientId. | -| `customKey` | string | `--custom-key` | no | Custom key. | -| `appGroupId` | number | `--app-group-id` | no | App group id. | -| `creator` | string | `--creator` | no | Creator name keyword. | -| `robotName` | string | `--robot-name` | no | Bot name keyword. | -| `developType` | number/string | `--develop-type` | no | Use backend enum if schema exposes one. | -| `filterCoolApp` | number/boolean | `--filter-cool-app` | no | Only expose when backend semantics are documented. | -| `sortType` | string | `--sort` | no | Example: `gmt_modified`. | -| `sortOrder` | string | `--order` | no | `asc` or `desc`. | +| `cursor` | string | `--cursor` | no | Cursor from previous response; empty for first page. | +| `name` | string | `--name` | no | App name keyword. | Normalized response: @@ -217,47 +212,39 @@ Normalized response: { "ok": true, "operation": "app.list", - "page": {"currentPage": 1, "pageSize": 20, "total": 0}, + "page": {"pageSize": 20, "nextCursor": ""}, "apps": [ { "unifiedAppId": "UNIFIED_APP_ID", - "appName": "DemoApp", - "agentId": 123456, - "appId": 123, + "name": "DemoApp", "appKey": "dingxxx", - "customKey": "custom-key", "creator": "Creator", "robotName": "BotName", - "gmtModified": "2026-06-05T00:00:00+08:00" + "modifiedAt": "2026-06-05 00:00:00" } ], "raw": {} } ``` -#### get_open_dev_app_detail +#### get_dev_app | Item | Contract | | --- | --- | | CLI | `dws devapp get` | | HSF facade | `OpenInnerAppQueryFacade.getDetail` | -| Request shape | One object with system context and one or more app locator fields. | -| Required public fields | At least one locator. | +| Request shape | One object with system context and `unifiedAppId`. | +| Required public fields | `unifiedAppId`. | | Identity source | `corpId/userId` from MCP system context. | -Locator fields: +Locator field: | Field | Type | CLI flag | Notes | | --- | --- | --- | --- | -| `unifiedAppId` | string | `--unified-app-id` | Preferred. | -| `agentId` | number | `--agent-id` | Exact id. | -| `appId` | number | `--app-id` | Compatible id. | -| `appKey` | string | `--app-key` | appKey/clientId. | -| `customKey` | string | `--custom-key` | Custom key. | -| `appName` | string | `--name` | Fuzzy; must resolve uniquely before writes. | +| `unifiedAppId` | string | `--unified-app-id` | Required app identity. | -Detail response should include identifiers and configuration summary, but not -full `clientSecret/appSecret`: +Detail response may include identifiers, configuration summary, and credentials. +Treat `clientSecret/appSecret` as sensitive values in all user-visible output: ```json { @@ -265,11 +252,8 @@ full `clientSecret/appSecret`: "operation": "app.get", "app": { "unifiedAppId": "UNIFIED_APP_ID", - "appName": "DemoApp", - "agentId": 123456, - "appId": 123, + "name": "DemoApp", "appKey": "dingxxx", - "customKey": "custom-key", "developType": "internal", "creator": "Creator", "visibility": "partial_members", @@ -280,23 +264,23 @@ full `clientSecret/appSecret`: } ``` -#### create_inner_app +#### create_dev_app | Item | Contract | | --- | --- | | CLI | `dws devapp create` | | HSF facade | `OpenInnerAppManageFacade.create` | | Request shape | One object with system context and app base fields. | -| Required public fields | `appName`; `--type` is a CLI-only guard and currently must be `internal`. | +| Required public fields | `name`; `--type` is a CLI-only guard and currently must be `internal`. | | Write guard | CLI `--dry-run`, then `--yes`; no MCP confirm field. | Input fields: | Field | Type | CLI flag | Required | Notes | | --- | --- | --- | --- | --- | -| `appName` | string | `--name` | yes | Enterprise internal app name. | -| `appDesc` | string | `--desc` | no | App description. | -| `appIcon` | string | `--icon` | no | Existing media/resource id if backend accepts it. | +| `name` | string | `--name` | yes | Enterprise internal app name. | +| `desc` | string | `--desc` | no | App description. | +| `icon` | string | `--icon` | no | Existing media/resource id if backend accepts it. | Response: @@ -306,8 +290,7 @@ Response: "operation": "app.create", "app": { "unifiedAppId": "UNIFIED_APP_ID", - "appName": "DemoApp", - "agentId": 123456, + "name": "DemoApp", "appKey": "dingxxx" }, "nextAction": "run devapp get or configure permissions", @@ -315,7 +298,7 @@ Response: } ``` -#### update_inner_app +#### update_dev_app | Item | Contract | | --- | --- | @@ -325,23 +308,23 @@ Response: | Required public fields | One app locator and at least one changed field. | | Write guard | CLI `--dry-run`, then `--yes`; no MCP confirm field. | -Locator fields are the same as `get_open_dev_app_detail`, but the Agent should +Locator fields are the same as `get_dev_app`, but the Agent should prefer `unifiedAppId` after resolving a unique app. Update fields: | Field | Type | CLI flag | Notes | | --- | --- | --- | --- | -| `appName` | string | `--name` | New app name. | -| `appDesc` | string | `--desc` | New app description. | -| `appIcon` | string | `--icon` | New icon resource id if backend accepts it. | +| `name` | string | `--name` | New app name. | +| `desc` | string | `--desc` | New app description. | +| `icon` | string | `--icon` | New icon resource id if backend accepts it. | | `international` | object/string | `--international` | Only expose when schema describes the shape. | | `supportHarmony` | boolean | `--support-harmony` | Harmony support switch if backend exposes it. | Response should return the updated app summary plus a `changedFields` array when available. -#### delete_inner_app +#### delete_dev_app | Item | Contract | | --- | --- | @@ -355,28 +338,28 @@ The Agent must display the resolved app summary before executing delete with `--yes`. The response should include `deleted=true`, the app summary, and raw backend fields. -#### list_open_dev_app_permissions +#### list_dev_app_permissions | Item | Contract | | --- | --- | | CLI | `dws devapp permission list/search/detail` | | HSF facade | `OpenInnerAppPermissionFacade.list` | | Request shape | One object with system context, app locator, and optional filters. | -| Required public fields | One app locator. | +| Required public fields | `unifiedAppId`. | | Web semantics | Equivalent to grouped `scope/list` for APP and SNS scopes. | Input fields: | Field | Type | CLI flag | Required | Notes | | --- | --- | --- | --- | --- | -| `unifiedAppId` | string | `--unified-app-id` | one locator | Preferred. | -| `agentId` | number | `--agent-id` | one locator | Compatible. | +| `unifiedAppId` | string | `--unified-app-id` | yes | Unified app id. | | `keyword` | string | `--keyword` | no | Maps to Web `key`. | | `apiStatus` | string | `--api-status` | no | Web `apiStatus` filter. | | `authStatus` | string | `--status` | no | `ALL/AUTHED/UNAUTHED`, post-filter. | -| `firstLevelType` | string | `--scope-type` | no | `APP/SNS`; empty returns both. | +| `scopeType` | string | `--scope-type` | no | `APP/SNS`; empty returns both. | | `scopeValue` | string | `--scope` | no | Detail mode and exact post-filter. | -| `limit` | number | `--limit` | no | Agent display cap; no server pagination. | +| `pageSize` | number | `--page-size` | no | Return count cap; default 20. | +| `cursor` | string | `--cursor` | no | Cursor from previous response; empty for first page. | Normalized response should flatten `appScopes` and `snsScopes` into one `permissions[]` list while preserving group metadata: @@ -390,7 +373,7 @@ Normalized response should flatten `appScopes` and `snsScopes` into one { "scopeValue": "qyapi_robot_sendmsg", "scopeName": "企业内机器人发消息权限", - "firstLevelType": "APP", + "scopeType": "APP", "categoryTitle": "机器人", "authed": true, "canEdit": true, @@ -401,27 +384,26 @@ Normalized response should flatten `appScopes` and `snsScopes` into one } ], "summary": {"totalMatched": 1, "returnedCount": 1}, - "truncated": false, + "nextCursor": "", "raw": {} } ``` -#### apply_open_dev_app_permissions +#### apply_dev_app_permissions | Item | Contract | | --- | --- | | CLI | `dws devapp permission add` | | HSF facade | `OpenInnerAppPermissionFacade.apply` | | Request shape | One object with system context, app locator, and `scopeValues`. | -| Required public fields | One app locator and non-empty `scopeValues`. | +| Required public fields | `unifiedAppId` and non-empty `scopeValues`. | | Write guard | CLI `--dry-run`, then `--yes`; no MCP confirm field. | Input fields: | Field | Type | CLI flag | Required | Notes | | --- | --- | --- | --- | --- | -| `unifiedAppId` | string | `--unified-app-id` | one locator | Preferred. | -| `agentId` | number | `--agent-id` | one locator | Compatible. | +| `unifiedAppId` | string | `--unified-app-id` | yes | Unified app id. | | `scopeValues` | string[] | `--permissions` | yes | Comma-separated in CLI; must come from list response. | Response must distinguish direct apply, version-staged apply, rejected scopes, and @@ -444,23 +426,22 @@ skipped scopes: `requiredApproval=true` scopes must be accepted when editable and staged into the current app version. Approver selection is handled by the version flow. -#### remove_open_dev_app_permission +#### remove_dev_app_permissions | Item | Contract | | --- | --- | | CLI | `dws devapp permission remove` | | HSF facade | `OpenInnerAppPermissionFacade.remove` | -| Request shape | One object with system context, app locator, and one `scopeValue`. | -| Required public fields | One app locator and one `scopeValue`. | +| Request shape | One object with system context, app locator, and `scopeValues`. | +| Required public fields | `unifiedAppId` and non-empty `scopeValues`. | | Write guard | CLI `--dry-run`, then `--yes`; no MCP confirm field. | Input fields: | Field | Type | CLI flag | Required | Notes | | --- | --- | --- | --- | --- | -| `unifiedAppId` | string | `--unified-app-id` | one locator | Preferred. | -| `agentId` | number | `--agent-id` | one locator | Compatible. | -| `scopeValue` | string | `--permission` | yes | Remove exactly one permission. | +| `unifiedAppId` | string | `--unified-app-id` | yes | Unified app id. | +| `scopeValues` | string[] | `--permissions` | yes | Remove one or more permissions. | Expected structured failures: @@ -499,99 +480,73 @@ Tool override examples: ```json { "toolOverrides": { - "list_open_dev_apps_by_condition": { + "list_dev_app": { "cliName": "list", "description": "查询开放平台企业内部应用列表", "flags": { - "currentPage": {"alias": "page", "default": 1}, "pageSize": {"alias": "page-size", "default": 20}, - "appName": {"alias": "name"}, - "agentId": {"alias": "agent-id"}, - "appId": {"alias": "app-id"}, - "appKey": {"alias": "app-key"}, - "customKey": {"alias": "custom-key"}, - "creator": {"alias": "creator"}, - "robotName": {"alias": "robot-name"}, - "sortType": {"alias": "sort"}, - "sortOrder": {"alias": "order"} + "cursor": {"alias": "cursor"}, + "name": {"alias": "name"} } }, - "get_open_dev_app_detail": { + "get_dev_app": { "cliName": "get", "description": "查询开放平台企业内部应用详情", "flags": { - "unifiedAppId": {"alias": "unified-app-id"}, - "agentId": {"alias": "agent-id"}, - "appId": {"alias": "app-id"}, - "appKey": {"alias": "app-key"}, - "customKey": {"alias": "custom-key"}, - "appName": {"alias": "name"} + "unifiedAppId": {"alias": "unified-app-id", "required": true} } }, - "create_inner_app": { + "create_dev_app": { "cliName": "create", "description": "创建开放平台企业内部应用", "isSensitive": true, "flags": { - "appName": {"alias": "name", "required": true}, - "appDesc": {"alias": "desc"}, - "appIcon": {"alias": "icon"} + "name": {"alias": "name", "required": true}, + "desc": {"alias": "desc"}, + "icon": {"alias": "icon"} } }, - "update_inner_app": { + "update_dev_app": { "cliName": "update", "description": "修改开放平台企业内部应用基础信息", "isSensitive": true, "flags": { - "unifiedAppId": {"alias": "unified-app-id"}, - "agentId": {"alias": "agent-id"}, - "appId": {"alias": "app-id"}, - "appKey": {"alias": "app-key"}, - "customKey": {"alias": "custom-key"}, - "currentAppName": {"alias": "current-name"}, - "appName": {"alias": "name"}, - "appDesc": {"alias": "desc"}, - "appIcon": {"alias": "icon"}, - "international": {"alias": "international"}, - "supportHarmony": {"alias": "support-harmony", "type": "bool"} + "unifiedAppId": {"alias": "unified-app-id", "required": true}, + "name": {"alias": "name"}, + "desc": {"alias": "desc"}, + "icon": {"alias": "icon"} } }, - "delete_inner_app": { + "delete_dev_app": { "cliName": "delete", "description": "删除开放平台企业内部应用", "isSensitive": true, "flags": { - "unifiedAppId": {"alias": "unified-app-id"}, - "agentId": {"alias": "agent-id"}, - "appId": {"alias": "app-id"}, - "appName": {"alias": "name"}, - "appKey": {"alias": "app-key"}, - "customKey": {"alias": "custom-key"} + "unifiedAppId": {"alias": "unified-app-id", "required": true} } }, - "list_open_dev_app_permissions": { + "list_dev_app_permissions": { "cliName": "list", "group": "permission", "description": "查询开放平台应用权限列表,默认同时返回 APP 应用权限和 SNS 个人权限", "flags": { "unifiedAppId": {"alias": "unified-app-id"}, - "agentId": {"alias": "agent-id"}, "keyword": {"alias": "keyword"}, "apiStatus": {"alias": "api-status"}, "authStatus": {"alias": "status", "default": "ALL"}, - "firstLevelType": {"alias": "scope-type"}, + "scopeType": {"alias": "scope-type"}, "scopeValue": {"alias": "scope"}, - "limit": {"alias": "limit", "default": "20"} + "pageSize": {"alias": "page-size", "default": "20"}, + "cursor": {"alias": "cursor"} } }, - "apply_open_dev_app_permissions": { + "apply_dev_app_permissions": { "cliName": "add", "group": "permission", "description": "申请开放平台应用权限点;需审核权限写入当前应用版本变更", "isSensitive": true, "flags": { "unifiedAppId": {"alias": "unified-app-id"}, - "agentId": {"alias": "agent-id"}, "scopeValues": { "alias": "permissions", "type": "stringSlice", @@ -600,18 +555,18 @@ Tool override examples: } } }, - "remove_open_dev_app_permission": { + "remove_dev_app_permissions": { "cliName": "remove", "group": "permission", - "description": "取消一个已开通的开放平台应用权限点", + "description": "取消一个或多个已开通的开放平台应用权限点", "isSensitive": true, "flags": { "unifiedAppId": {"alias": "unified-app-id"}, - "agentId": {"alias": "agent-id"}, - "scopeValue": { - "alias": "permission", + "scopeValues": { + "alias": "permissions", + "type": "stringSlice", "required": true, - "description": "待取消权限点 scopeValue" + "description": "待取消权限点 scopeValue,多个用逗号分隔" } } } @@ -619,7 +574,7 @@ Tool override examples: } ``` -`permission search/detail` are UX aliases over `list_open_dev_app_permissions`. The helper exposes one canonical MCP call and keeps alias behavior at the CLI layer: +`permission search/detail` are UX aliases over `list_dev_app_permissions`. The helper exposes one canonical MCP call and keeps alias behavior at the CLI layer: ```bash dws devapp permission list --keyword "机器人发送消息" @@ -630,8 +585,8 @@ If a helper command is added, map aliases without creating new MCP tools: | Helper command | Underlying tool | Forced/derived args | | --- | --- | --- | -| `devapp permission search` | `list_open_dev_app_permissions` | Requires `keyword` or `scopeValue`; returns candidate summaries. | -| `devapp permission detail` | `list_open_dev_app_permissions` | Requires `scopeValue`; returns one scope with full `apiList`. | +| `devapp permission search` | `list_dev_app_permissions` | Requires `keyword` or `scopeValue`; returns candidate summaries. | +| `devapp permission detail` | `list_dev_app_permissions` | Requires `scopeValue`; returns one scope with full `apiList`. | ## 5. App Identity Resolution @@ -640,16 +595,14 @@ Application identity must be resolved before detail and write commands. | Priority | Identifier | Rule | | --- | --- | --- | | 1 | `unifiedAppId` | Preferred unique id for write operations. | -| 2 | `agentId/appId` | Compatible exact identifier. | -| 3 | `appKey/clientId` | Call list/detail and require one result. | -| 4 | `customKey` | Call list/detail and require one result. | -| 5 | `appName/name` | Fuzzy search; write operations require one result. | +| 2 | `appKey/clientId` | Call list/detail and require one result. | +| 3 | `name` | Fuzzy search; write operations require one result. | Agent rule: 1. Never choose the first row when multiple apps match. -2. Show candidates with `appName`, `unifiedAppId`, `agentId`, `appKey/clientId`, `creator`, and modified time. -3. Ask the user to pick `unifiedAppId` or `agentId` for write operations. +2. Show candidates with `name`, `unifiedAppId`, `appKey/clientId`, `creator`, and modified time. +3. Ask the user to pick `unifiedAppId` for write operations when multiple apps match. MCP calls must rely on system context for caller identity: @@ -665,34 +618,26 @@ Open-source DWS and Agent must not pass `orgId` or `uid` as user input. ### 6.1 List ```bash -dws devapp list --page 1 --page-size 20 --format json +dws devapp list --page-size 20 --format json dws devapp list --name DemoApp --format json -dws devapp list --agent-id AGENT_ID --format json -dws devapp list --app-key dingxxx --format json -dws devapp list --creator 张三 --format json -dws devapp list --robot-name 小钉 --format json +dws devapp list --page-size 20 --cursor NEXT_CURSOR --format json ``` MCP payload: ```json { - "currentPage": 1, "pageSize": 20, - "appName": "DemoApp", - "sortType": "gmt_modified", - "sortOrder": "desc" + "name": "DemoApp" } ``` -Pagination is supported for app list through `currentPage/pageSize`, both 1-based. Search filters are optional. A baseline call should pass only pagination and sort fields. +Pagination is cursor based. A baseline call should pass only `pageSize`; use the response `nextCursor` for the next page. ### 6.2 Get ```bash dws devapp get --unified-app-id UNIFIED_APP_ID --format json -dws devapp get --agent-id AGENT_ID --format json -dws devapp get --name DemoApp --format json ``` MCP payload: @@ -703,7 +648,7 @@ MCP payload: } ``` -`get` may show `clientId/appKey/agentId`, but it must not be used to read full `clientSecret/appSecret`. Secret read belongs to `credentials get`. +`get` may show `clientId/appKey/clientSecret/appSecret`; callers must treat secret fields as sensitive. Active secret reads still prefer `credentials get`. ### 6.3 Create @@ -726,8 +671,8 @@ MCP payload: ```json { - "appName": "DemoApp", - "appDesc": "internal app" + "name": "DemoApp", + "desc": "internal app" } ``` @@ -742,9 +687,9 @@ At least one update field is required. Supported P0 fields: | CLI flag | MCP field | Notes | | --- | --- | --- | -| `--name` | `appName` | New app name. | -| `--desc` | `appDesc` | New description. | -| `--icon` | `appIcon` | Existing media id or backend-supported resource. | +| `--name` | `name` | New app name. | +| `--desc` | `desc` | New description. | +| `--icon` | `icon` | Existing media id or backend-supported resource. | | `--international` | `international` | JSON string if exposed. | | `--support-harmony` | `supportHarmony` | Boolean if exposed. | @@ -765,7 +710,7 @@ MCP payload: } ``` -Valid locator fields are `unifiedAppId`, `agentId`, `appId`, `appName`, `appKey`, and `customKey`. Name/key locators must uniquely resolve. +Valid public locator field is `unifiedAppId`. ## 7. Credentials @@ -773,13 +718,12 @@ Target command: ```bash dws devapp credentials get --unified-app-id UNIFIED_APP_ID --format json -dws devapp credentials get --agent-id AGENT_ID --format json ``` Current status: 1. Backend facade is `OpenInnerAppQueryFacade.getCredentials`. -2. MCP tool is `get_open_dev_app_credentials`. +2. MCP tool is `get_dev_app_credentials`. 3. CLI sends only application locator fields and does not add `showSecret`, `confirmSecret`, or masking fields. 4. The MCP response follows the backend credential contract and may include `clientSecret/appSecret`; callers must treat command output as sensitive. @@ -787,7 +731,7 @@ Target fields: | Field | Notes | | --- | --- | -| `unifiedAppId/agentId/appName` | App summary. | +| `unifiedAppId/name` | App summary. | | `clientId/appKey` | Non-secret identifiers. | | `clientSecret/appSecret` | Returned by the dedicated credentials tool; treat as sensitive output. | | `currentSecretStatus` | Current secret state. | @@ -810,10 +754,10 @@ Returned groups: "success": true, "data": { "snsScopes": [ - {"title": "个人权限", "firstLevelType": "SNS", "scopeList": []} + {"title": "个人权限", "scopeType": "SNS", "scopeList": []} ], "appScopes": [ - {"title": "通讯录管理", "firstLevelType": "APP", "scopeList": []} + {"title": "通讯录管理", "scopeType": "APP", "scopeList": []} ] } } @@ -836,7 +780,7 @@ MCP payload for normal list: { "unifiedAppId": "UNIFIED_APP_ID", "authStatus": "ALL", - "limit": 20 + "pageSize": 20 } ``` @@ -847,7 +791,7 @@ MCP payload for search: "unifiedAppId": "UNIFIED_APP_ID", "keyword": "机器人发送消息", "authStatus": "UNAUTHED", - "limit": 10 + "pageSize": 10 } ``` @@ -857,7 +801,7 @@ MCP payload for detail: { "unifiedAppId": "UNIFIED_APP_ID", "scopeValue": "qyapi_robot_sendmsg", - "limit": 1 + "pageSize": 1 } ``` @@ -865,16 +809,16 @@ Input fields: | Field | Type | Required | Notes | | --- | --- | --- | --- | -| `unifiedAppId` | string | one of app id fields | Preferred app locator. | -| `agentId` | number | one of app id fields | Compatible app locator. | +| `unifiedAppId` | string | yes | Unified app id. | | `keyword` | string | no | Maps to Web `key`. | | `apiStatus` | string | no | Maps to Web `apiStatus`. | | `authStatus` | string | no | `ALL/AUTHED/UNAUTHED`, post-filter. | -| `firstLevelType` | string | no | `APP/SNS`; empty returns both. | +| `scopeType` | string | no | `APP/SNS`; empty returns both. | | `scopeValue` | string | no | Detail mode and exact post-filter. | -| `limit` | number | no | Default 20, hard cap 50. | +| `pageSize` | number | no | Default 20, hard cap 50. | +| `cursor` | string | no | Cursor from previous response; empty for first page. | -Permission list has no server pagination. Web also loads the full grouped list. Use `keyword` to reduce server-side results, then use `limit` to control Agent context. +Permission list uses cursor pagination. Use `keyword` to reduce server-side results and `pageSize/cursor` to control Agent context. Default response must flatten APP and SNS scopes into one stable `permissions[]` array: @@ -886,7 +830,7 @@ Default response must flatten APP and SNS scopes into one stable `permissions[]` "scopeValue": "qyapi_robot_sendmsg", "scopeName": "企业内机器人发消息权限", "scopeDesc": "企业内机器人发消息权限", - "firstLevelType": "APP", + "scopeType": "APP", "categoryTitle": "机器人", "authed": true, "canEdit": true, @@ -919,7 +863,7 @@ Default response must flatten APP and SNS scopes into one stable `permissions[]` { "scopeValue": "Contact.User.mobile", "scopeName": "个人手机号信息", - "firstLevelType": "SNS", + "scopeType": "SNS", "authed": false, "canEdit": true, "requiredApproval": true, @@ -934,7 +878,7 @@ Agent selection order: 1. Exact `scopeValue`. 2. Matched `apiList[].name`. 3. Scope name or description. -4. Category title and `firstLevelType`. +4. Category title and `scopeType`. Agent must pass final `scopeValue` to add/remove. Do not pass API name, API uuid, or category title into write tools. @@ -979,8 +923,8 @@ Expected response: ### 8.4 Remove ```bash -dws devapp permission remove --unified-app-id UNIFIED_APP_ID --permission qyapi_robot_sendmsg --dry-run --format json -dws devapp permission remove --unified-app-id UNIFIED_APP_ID --permission qyapi_robot_sendmsg --yes --format json +dws devapp permission remove --unified-app-id UNIFIED_APP_ID --permissions qyapi_robot_sendmsg --dry-run --format json +dws devapp permission remove --unified-app-id UNIFIED_APP_ID --permissions qyapi_robot_sendmsg --yes --format json ``` MCP payload: @@ -988,7 +932,7 @@ MCP payload: ```json { "unifiedAppId": "UNIFIED_APP_ID", - "scopeValue": "qyapi_robot_sendmsg" + "scopeValues": ["qyapi_robot_sendmsg"] } ``` @@ -1003,25 +947,33 @@ Rules: Event and version commands are needed for a full application lifecycle but should not be folded into permission add. -Target event tools: +Implemented event tools (verified against the `op-app` MCP `tools/list`). The +real model is "list subscribable events + subscribe/unsubscribe events by +`eventCodes`", not a single callbackUrl/token/aesKey config call: | CLI | MCP tool | Notes | | --- | --- | --- | -| `devapp event list` | `list_open_dev_app_events` | Query current callback and subscribed event types. | -| `devapp event config` | `configure_open_dev_app_event` | Configure callbackUrl, token, aesKey, eventTypes. | +| `dev app event list` | `list_dev_app_events` | Args `unifiedAppId/keyword/cursor/pageSize`; `keyword` searches `eventCode` or `eventName`; returns `pushType=STREAM` + `events[]` (`eventCode`/`eventName`/`subscribed`) + `hasMore/nextCursor/pageSize`. Read. | +| `dev app event subscribe` | `subscribe_dev_app_events` | Args `unifiedAppId` + required array `eventCodes`. Returns `success/operation/eventCodes/needsPublish/versionRequiredAction`; failures include `errorCode/errorMsg/reason/retryable/action` such as `STREAM_NOT_CONNECTED`. Write (`--dry-run`/`--yes`). | +| `dev app event unsubscribe` | `unsubscribe_dev_app_events` | Args `unifiedAppId` + required array `eventCodes`. Returns `success/operation/eventCodes/needsPublish/versionRequiredAction`; failures include `errorCode/errorMsg/reason/retryable/action`. Write. | + +Note: for gray unified apps, subscribe/unsubscribe are staged into version +metadata and only take effect after `version publish`. The event callback URL +itself is not part of this model; it lives in robot config (`eventCallbackUrl`, +exposed as `robot config --event-url`). Implemented version tools (verified against the `op-app` MCP `tools/list`): | CLI | MCP tool | Notes | | --- | --- | --- | -| `devapp version create` | `create_open_dev_app_version` | Save a new version from current config (`version`, `description`). | -| `devapp version list` | `list_open_dev_app_versions` | Paged list (`currentPage`, `pageSize`). | -| `devapp version get` | `get_open_dev_app_version_detail` | One version detail by `versionId`. | -| `devapp version check-approval` | `publish_open_dev_app_version` (`dryRun=true`) | Precheck approval requirement / approvers; does not publish. | -| `devapp version publish` | `publish_open_dev_app_version` (`dryRun=false`) | Publish; `confirmedSensitive` for high-sensitivity scopes, optional `approverUserId`. | -| `devapp version status` | `get_open_dev_app_version_status` | Poll publish and approval state. | +| `devapp version create` | `create_dev_app_version` | Save a new version from current config. Omit `version` by default so the server auto-increments from the latest released version; only pass `version` when the user explicitly requests a higher version. | +| `devapp version list` | `list_dev_app_versions` | Cursor-paged list (`cursor`, `pageSize`; response `items/nextCursor/hasMore`). | +| `devapp version get` | `get_dev_app_version_detail` | One version detail by `versionId`. | +| `devapp version check-approval` | `publish_dev_app_version` (`precheckOnly=true`) | Precheck approval requirement / approvers via `requiresApproval/publishable/approvalMode/approvalCandidates`; does not publish. | +| `devapp version publish` | `publish_dev_app_version` (`precheckOnly=false`) | Publish or submit approval; inspect `published/approvalSubmitted/processId/versionStatus`. | +| `devapp version status` | `get_dev_app_version_status` | Poll publish and approval state. | -Note: the MCP `dryRun` field is the server-side approval precheck and maps to +Note: the MCP `precheckOnly` field is the server-side approval precheck and maps to `version check-approval`. It is distinct from the CLI global `--dry-run` (preview without execution). `check-approval` is read-only (no write guard); `publish` uses the standard `--dry-run`/`--yes` write guard. @@ -1030,16 +982,15 @@ Implemented robot tools: | CLI | MCP tool | Notes | | --- | --- | --- | -| `devapp robot create` | `submit_robot_create_task` + `query_robot_create_result` | Create a new agent app + robot via the async submit/poll flow (the legacy sync `create_dingtalk_robot` action is broken server-side — see issue #35). | +| `devapp robot create` | `create_dingtalk_robot` | Synchronously create a new agent app + robot. | | `devapp robot submit` | `submit_robot_create_task` | Async submit; supports retry via original `taskId`. | | `devapp robot result` | `query_robot_create_result` | Poll async task by `taskId`. | -| `devapp robot get` | `get_open_dev_app_robot_config` | Read robot config of an existing app. | -| `devapp robot config` | `create_open_dev_app_robot_config` | Create robot config on an existing app. | -| `devapp robot update` | `update_open_dev_app_robot_config` | Update robot config. | -| `devapp robot enable` | `enable_open_dev_app_robot` | Enable / re-enable robot capability. | -| `devapp robot offline` | `offline_open_dev_app_robot` | Offline robot capability. | +| `devapp robot get` | `get_extension_robot_config` | Read robot config of an existing app. | +| `devapp robot config` | `set_extension_robot_config` | Create or update robot config on an existing app. | +| `devapp robot enable` | `enable_dev_app_robot` | Enable / re-enable robot capability. | +| `devapp robot disable` | `disable_dev_app_robot` | Disable robot capability. | -Robot create/submit and config/update/enable/offline are write operations and +Robot create/submit and config/enable/disable are write operations and use the `--dry-run`/`--yes` guard; `get` and `result` are reads. Stitching rule: @@ -1068,9 +1019,9 @@ Positive `devapp` signals: | Signal | Route | | --- | --- | | `开放平台应用`, `开发者后台应用`, `企业内部应用`, `内部应用` | `devapp` | -| `agentId`, `clientId`, `appKey`, `customKey`, `appSecret`, `clientSecret` | `devapp` | +| `unifiedAppId`, `clientId`, `appKey`, `appSecret`, `clientSecret` | `devapp` | | `应用权限`, `权限点`, `API 权限`, `APP/SNS 权限`, `scopeValue` | `devapp permission ...` | -| `事件订阅`, `callbackUrl`, `token`, `aesKey` in an app context | `devapp event ...` | +| `事件订阅`, `订阅事件`, `退订事件`, `eventCode`, `可订阅事件` in an app context | `devapp event ...` | | `应用版本`, `版本发布`, `选审批人`, `发布审核` in developer backend context | `devapp version ...` | | yulan's current `开放平台应用 CLI 化 / dws app` workstream | `devapp` | @@ -1114,18 +1065,18 @@ Intent table: | User intent | Command | Tool | | --- | --- | --- | -| "查应用/搜索应用" | `devapp list` | `list_open_dev_apps_by_condition` | -| "看应用详情/clientId/agentId" | `devapp get` | `get_open_dev_app_detail` | -| "创建应用" | `devapp create` | `create_inner_app` | -| "修改名称/描述/图标" | `devapp update` | `update_inner_app` | -| "删除应用" | `devapp delete` | `delete_inner_app` | -| "查权限/搜索 API 权限" | `permission list` | `list_open_dev_app_permissions` | -| "这个权限覆盖哪些 API" | `permission list --scope` | `list_open_dev_app_permissions` | -| "申请权限/开通权限" | `permission add` | `apply_open_dev_app_permissions` | -| "取消权限/移除权限" | `permission remove` | `remove_open_dev_app_permission` | -| "拿 appSecret/clientSecret" | `credentials get` | `get_open_dev_app_credentials` | -| "配置事件订阅" | `event config` | Pending | -| "发布/审核/选审批人" | `version ...` | Pending | +| "查应用/搜索应用" | `devapp list` | `list_dev_app` | +| "看应用详情/clientId/appKey" | `devapp get` | `get_dev_app` | +| "创建应用" | `devapp create` | `create_dev_app` | +| "修改名称/描述/图标" | `devapp update` | `update_dev_app` | +| "删除应用" | `devapp delete` | `delete_dev_app` | +| "查权限/搜索 API 权限" | `permission list` | `list_dev_app_permissions` | +| "这个权限覆盖哪些 API" | `permission list --scope` | `list_dev_app_permissions` | +| "申请权限/开通权限" | `permission add` | `apply_dev_app_permissions` | +| "取消权限/移除权限" | `permission remove` | `remove_dev_app_permissions` | +| "拿 appSecret/clientSecret" | `credentials get` | `get_dev_app_credentials` | +| "查可订阅事件/订阅/退订事件" | `event list/subscribe/unsubscribe` | `list_open_dev_app_events`, `subscribe/unsubscribe_open_dev_app_event` | +| "发布/审核/选审批人" | `version ...` | `publish_open_dev_app_version` etc. | Yulan invocation examples: @@ -1135,7 +1086,7 @@ Yulan invocation examples: | "把应用图标换成 ICON_RESOURCE" | `dws devapp update --unified-app-id ... --icon ICON_RESOURCE --dry-run --format json` | Icon change is app base info update and must be guarded. | | "已确认删除这个开放平台应用" | `dws devapp delete --unified-app-id ... --yes --format json` | Destructive write only after unique app resolution and confirmation. | | "手机号权限要申请,虽然需要审核" | `dws devapp permission add --permissions Contact.User.mobile --dry-run/--yes` | `requiredApproval=true` is still appliable; approval moves to version publish. | -| "配置事件订阅回调" | `dws devapp event config ... --dry-run --format json` | Event subscription is app configuration, not permission or docs search. | +| "订阅通讯录用户增加事件" | `dws dev app event list ...` then `dws dev app event subscribe --event-codes user_add_org --dry-run --format json` | Pick eventCode from list, then subscribe; gray apps need a version publish to take effect. | | "保存版本并看审核状态" | `version create`, `check-approval`, `publish`, `status` | Version flow owns approver selection and publish audit state. | | "创建 MCP 服务并配置 HSF tool" | OpenDev MCP platform workflow, not `dws devapp create` | MCP connector/tool is a platform artifact, not an enterprise internal app. | @@ -1211,7 +1162,7 @@ Do not add confirmation booleans to MCP payloads. These belong to the CLI layer: | User request | Agent recipe | | --- | --- | | "查 DemoApp 应用" | `devapp list --name DemoApp`; if one result, optionally `devapp get`; if multiple, show candidates. | -| "这个 agentId 是哪个应用" | `devapp get --agent-id AGENT_ID`; if backend requires list semantics, use `devapp list --agent-id AGENT_ID`. | +| "这个 appKey 是哪个应用" | 当前 CLI 不用 appKey 定位详情;先让用户提供 `unifiedAppId`,或用 `devapp list --name` 缩小候选后再 `devapp get --unified-app-id ...`。 | | "创建企业内部应用" | `devapp create --name ... --type internal --dry-run`; after confirmation, repeat with `--yes`. | | "修改应用描述/图标" | Resolve `unifiedAppId`; `devapp update --unified-app-id ... --desc/icon ... --dry-run`; after confirmation, `--yes`. | | "删除应用" | Resolve unique app; show app summary; `devapp delete ... --dry-run`; after confirmation, `--yes`. | @@ -1231,9 +1182,9 @@ writing: 2. If the user gives an API name, search with `keyword`, then match `apiList[].name` or `apiPreview.name`. 3. If the user gives a human permission name, match `scopeName/scopeDesc`. -4. If multiple scopes remain, show `scopeValue`, `scopeName`, `firstLevelType`, +4. If multiple scopes remain, show `scopeValue`, `scopeName`, `scopeType`, `authed`, `requiredApproval`, and `apiPreview`, then ask for one. -5. For list output, cap the displayed list with `limit`; for detail output, call +5. For list output, cap the displayed list with `pageSize`; for detail output, call with `scopeValue` and return the full API coverage for one scope only. `requiredApproval=true` is not a blocker for permission add. It means the add @@ -1258,7 +1209,7 @@ Agent should normalize behavior without hiding raw backend fields. | `PERMISSION_ALREADY_AUTHED` | Add receives an already authorized scope | Report skipped scope and continue only for remaining unauth scopes. | | `PERMISSION_NOT_AUTHED` | Remove receives an unauthorized scope | Report that there is nothing to remove. | | `PERMISSION_NOT_EDITABLE` | `canEdit=false` or backend no-edit error | Show the no-edit reason; do not retry as a write. | -| `VERSION_APPROVAL_REQUIRED` | `check-approval` says approver is required | Ask for approver or use returned candidate when the user gave an explicit approver. | +| `requiresApproval=true` | `check-approval` says approval is required | Use `approvalMode/approvalCandidates`; ask for approver only in `SELECT_APPROVER` mode. | Raw `errorCode/errorMsg` must remain in JSON output or the Agent response so the backend owner can diagnose actual HSF failures. @@ -1273,8 +1224,7 @@ details: "operation": "permission.add", "app": { "unifiedAppId": "UNIFIED_APP_ID", - "agentId": 123456, - "appName": "DemoApp" + "name": "DemoApp" }, "summary": { "appliedCount": 1, @@ -1377,7 +1327,7 @@ For each P0 tool: 6. Configure tool input JSON Schema with only Agent-facing fields. 7. Map `corpId/userId` from system context to the business request object. 8. Review every recommended mapping manually, especially fields named - `agentId`, `appId`, `appKey`, or `userId`. + `unifiedAppId`, `appKey`, `name`, or `userId`. 9. Keep `ServiceResult` output wrapper and success rule `ServiceResult.success == true`. 10. Remove blank error-text optimization rows; preserve raw @@ -1390,14 +1340,14 @@ P0 debug inputs: | Tool | Baseline debug input | | --- | --- | -| `list_open_dev_apps_by_condition` | `{"currentPage":1,"pageSize":10}` | -| `get_open_dev_app_detail` | `{"unifiedAppId":"UNIFIED_APP_ID"}` | -| `create_inner_app` | `{"appName":"DemoApp","appDesc":"internal app"}` | -| `update_inner_app` | `{"unifiedAppId":"UNIFIED_APP_ID","appDesc":"new desc"}` | -| `delete_inner_app` | `{"unifiedAppId":"UNIFIED_APP_ID"}` | -| `list_open_dev_app_permissions` | `{"unifiedAppId":"UNIFIED_APP_ID","authStatus":"ALL","limit":20}` | -| `apply_open_dev_app_permissions` | `{"unifiedAppId":"UNIFIED_APP_ID","scopeValues":["SCOPE_VALUE"]}` | -| `remove_open_dev_app_permission` | `{"unifiedAppId":"UNIFIED_APP_ID","scopeValue":"SCOPE_VALUE"}` | +| `list_dev_app` | `{"pageSize":10}` | +| `get_dev_app` | `{"unifiedAppId":"UNIFIED_APP_ID"}` | +| `create_dev_app` | `{"name":"DemoApp","desc":"internal app"}` | +| `update_dev_app` | `{"unifiedAppId":"UNIFIED_APP_ID","desc":"new desc"}` | +| `delete_dev_app` | `{"unifiedAppId":"UNIFIED_APP_ID"}` | +| `list_dev_app_permissions` | `{"unifiedAppId":"UNIFIED_APP_ID","authStatus":"ALL","pageSize":20}` | +| `apply_dev_app_permissions` | `{"unifiedAppId":"UNIFIED_APP_ID","scopeValues":["SCOPE_VALUE"]}` | +| `remove_dev_app_permissions` | `{"unifiedAppId":"UNIFIED_APP_ID","scopeValues":["SCOPE_VALUE"]}` | For destructive debug cases, use a disposable application and permission scope. Do not use production data in long-lived examples. @@ -1408,8 +1358,8 @@ After publish, verify the service through MCP before using DWS: 1. Run MCP `initialize`. 2. Run `tools/list` and confirm all expected P0 tool keys exist. -3. Call `list_open_dev_apps_by_condition` with only pagination. -4. Call one exact locator scenario, such as `get_open_dev_app_detail`. +3. Call `list_dev_app` with only pagination. +4. Call one exact locator scenario, such as `get_dev_app`. 5. Call permission list with `authStatus=ALL` and confirm APP and SNS scopes are both represented. 6. Confirm failures preserve `errorCode/errorMsg`. @@ -1473,9 +1423,10 @@ the following are true: 4. Permission list/add/remove smoke cases pass for APP and SNS scopes. 5. `requiredApproval=true` permission add stages into version changes instead of being rejected by the Agent. -6. Credential access is handled only by `credentials get`; missing event/version - tools are reported as unsupported/pending, not silently emulated by other - commands. +6. Credential access is handled only by `credentials get`; event subscription + (`event list/subscribe/unsubscribe`) and version flow are implemented over + `op-app`, and any still-missing tool is reported as unsupported, not silently + emulated by other commands. 7. No repository doc, skill, log, or fixture contains MCP gateway keys, cookies, access tokens, full app secrets, or production app data. 8. Existing skill setup and generator tests pass. @@ -1488,14 +1439,14 @@ the following are true: | `dws devapp get --name DemoApp --format json` with multiple matches | Stops and returns candidates. | | `dws devapp create --name T --type internal --dry-run --format json` | Prints payload only; no MCP call. | | `dws devapp create --name T --type internal --format json` | Refuses because write confirmation is missing. | -| `dws devapp create --name T --type internal --yes --format json` | Calls `create_inner_app`. | -| `dws devapp update --unified-app-id X --desc Y --yes --format json` | Calls `update_inner_app`. | +| `dws devapp create --name T --type internal --yes --format json` | Calls `create_dev_app`. | +| `dws devapp update --unified-app-id X --desc Y --yes --format json` | Calls `update_dev_app`. | | `dws devapp delete --unified-app-id X --format json` | Refuses because delete confirmation is missing. | | `dws devapp permission list --unified-app-id X --format json` | Returns flattened APP and SNS permissions. | | `dws devapp permission list --unified-app-id X --keyword "发送消息" --format json` | Returns candidate `permissions[]`; no automatic apply. | | `dws devapp permission add --unified-app-id X --permissions Contact.User.mobile --yes --format json` | Applies or stages permission; `requiredApproval=true` is allowed. | -| `dws devapp permission remove --unified-app-id X --permission qyapi_robot_sendmsg --yes --format json` | Removes one authorized scope or returns a structured reason. | -| `dws devapp credentials get --unified-app-id X --format json` | Calls `get_open_dev_app_credentials`; does not use `devapp get` as a secret fallback. | +| `dws devapp permission remove --unified-app-id X --permissions qyapi_robot_sendmsg --yes --format json` | Removes authorized scopes or returns a structured reason. | +| `dws devapp credentials get --unified-app-id X --format json` | Calls `get_dev_app_credentials`; does not use `devapp get` as a secret fallback. | | `查应用 appXYZ 的详情` without OpenDev context | Does not blindly route to `devapp`; uses context such as `workbench app get` or asks. | | `查询开放平台 API 错误码` | Routes to `devdoc`, not `devapp`. | | `创建 MCP 服务并配置 HSF tool` | Uses OpenDev MCP platform workflow, not `dws devapp create`. | diff --git a/docs/robot-quickstart.md b/docs/robot-quickstart.md index 94e5113a..b7e839f9 100644 --- a/docs/robot-quickstart.md +++ b/docs/robot-quickstart.md @@ -13,7 +13,7 @@ ```bash ARCH=$(uname -m | sed 's/x86_64/amd64/') mkdir -p ~/.local/bin -curl -fsSL -o /tmp/dws.tar.gz "https://github.com/PeterGuy326/dingtalk-workspace-cli/releases/latest/download/dws-darwin-${ARCH}.tar.gz" +curl -fsSL -o /tmp/dws.tar.gz "https://github.com/wxianfeng/dingtalk-workspace-cli/releases/latest/download/dws-darwin-${ARCH}.tar.gz" tar xzf /tmp/dws.tar.gz -C ~/.local/bin dws export PATH="$HOME/.local/bin:$PATH" echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc @@ -26,14 +26,14 @@ dws version ```powershell New-Item -ItemType Directory -Force "$env:USERPROFILE\dws" | Out-Null -Invoke-WebRequest -Uri "https://github.com/PeterGuy326/dingtalk-workspace-cli/releases/latest/download/dws-windows-amd64.zip" -OutFile "$env:TEMP\dws.zip" +Invoke-WebRequest -Uri "https://github.com/wxianfeng/dingtalk-workspace-cli/releases/latest/download/dws-windows-amd64.zip" -OutFile "$env:TEMP\dws.zip" Expand-Archive -Path "$env:TEMP\dws.zip" -DestinationPath "$env:USERPROFILE\dws" -Force [Environment]::SetEnvironmentVariable("Path", "$env:Path;$env:USERPROFILE\dws", "User") ``` 然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。 -> 两个平台装好后都应显示形如 `dws version v1.0.6x-dws-devapp` 的版本号(能打印版本即安装成功,具体数字以最新发布为准)。其他平台(Linux / Windows ARM)的安装包在 [最新 Release 页面](https://github.com/PeterGuy326/dingtalk-workspace-cli/releases/latest)下载。 +> 两个平台装好后都应显示形如 `dws version v1.0.6x-dws-devapp` 的版本号(能打印版本即安装成功,具体数字以最新发布为准)。其他平台(Linux / Windows ARM)的安装包在 [最新 Release 页面](https://github.com/wxianfeng/dingtalk-workspace-cli/releases/latest)下载。 ### 登录钉钉 @@ -45,18 +45,22 @@ dws auth login ## 第二步:创建机器人 -一条命令建号(名字、描述可以改成你自己的): +建号是异步的,两步(名字、描述可以改成你自己的): ```bash -dws devapp robot create --app-name 我的智能体 --robot-name 小助手 --desc "群内答疑" --yes --format json +# 1) 提交建号任务,记下返回的 taskId +dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "群内答疑" --yes --format json + +# 2) 用上一步的 taskId 查结果,直到 status 变成 SUCCESS(还是 WAITING 就过几秒再查一次) +dws dev app robot result --task-id 上一步返回的taskId --format json ``` -返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。 +`status` 变成 `SUCCESS` 后,返回结果里的 `clientId` 和 `clientSecret` **保存好**,下一步要用。 ## 第三步:把机器人接上你本地的 AI ```bash -dws devapp robot connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-client-secret yyyyyyyyyyyyyyyyyyyy +dws dev connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx --robot-client-secret yyyyyyyyyyyyyyyyyyyy ``` - 把 `dingxxxxxxxxxxxxxxxx` 和 `yyyyyyyyyyyyyyyyyyyy` 换成第二步返回的 `clientId` 和 `clientSecret` 的实际值 @@ -89,7 +93,7 @@ dws devapp robot connect --channel auto --robot-client-id dingxxxxxxxxxxxxxxxx - `--channel auto` 只认内置的几款工具(Claude Code / Codex / Qoder / Gemini 等)。如果你用的是自研的、或还没内置支持的 AI(比如网易有道龙虾 LobsterAI),用 `--agent-cmd` 把它接进来——只要它能在命令行「一次性」跑(给一段问题、把答案打到标准输出),就能接: ```bash -dws devapp robot connect \ +dws dev connect \ --agent-cmd "你的AI命令 一次性问答参数" \ --robot-client-id dingxxxx --robot-client-secret yyyy ``` @@ -104,7 +108,7 @@ dws devapp robot connect \ 命令里残留了 `<...>` 尖括号占位符(旧版文档的写法),shell 会把尖括号当成重定向符。把占位符整体替换成实际值、不要保留尖括号,再执行。 **群里 @机器人 没反应?** -确认第三步的 `robot connect` 窗口还开着——关掉窗口机器人就下线了。 +确认第三步的 `dev connect` 窗口还开着——关掉窗口机器人就下线了。 **第二步提示"当前用户没有开发者身份"?** 创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试第二步。 @@ -129,4 +133,4 @@ macOS 重开一个终端窗口;Windows 重开一个 PowerShell 窗口(安装 一句话:让机器人和终端"看到一样的东西、用一样的模型",差距基本就抹平了。 **第三步执行完,在蚂蚁钉/开放平台搜不到审批工单?** -这是正常的,不是出错。第三步 `robot connect`(把机器人接到本地 AI)只是用现成机器人的凭证起一条连接、本地转发,**不产生任何审批工单**。会产生审批工单的是第二步「建机器人」(`robot create`),由平台/管理员审批。所以第三步之后搜不到工单是预期内的。 +这是正常的,不是出错。第三步 `dev connect`(把机器人接到本地 AI)只是用现成机器人的凭证起一条连接、本地转发,**不产生任何审批工单**。会产生审批工单的是第二步「建机器人」(`dev app robot submit`),由平台/管理员审批。所以第三步之后搜不到工单是预期内的。 diff --git a/internal/app/direct_runtime_test.go b/internal/app/direct_runtime_test.go index dde44c38..5410a739 100644 --- a/internal/app/direct_runtime_test.go +++ b/internal/app/direct_runtime_test.go @@ -52,14 +52,14 @@ func TestDirectRuntimeEndpoint_DevappEnvOverrideWithoutRegistry(t *testing.T) { withCleanDynamicRegistry(t) t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp") - assertEndpoint(t, "devapp", "list_open_dev_apps_by_condition", "https://example.test/server/devapp") + assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp") } func TestDirectRuntimeEndpoint_DevappEnvOverridePreservesQuery(t *testing.T) { withCleanDynamicRegistry(t) t.Setenv("DINGTALK_DEVAPP_MCP_URL", "https://example.test/server/devapp?key=secret") - assertEndpoint(t, "devapp", "list_open_dev_apps_by_condition", "https://example.test/server/devapp?key=secret") + assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp?key=secret") } func TestDirectRuntimeEndpoint_DevappDynamicServerDoesNotOverrideHardcoded(t *testing.T) { @@ -74,7 +74,7 @@ func TestDirectRuntimeEndpoint_DevappDynamicServerDoesNotOverrideHardcoded(t *te }, }) - assertEndpoint(t, "devapp", "list_open_dev_apps_by_condition", devappMCPEndpoint()) + assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint()) } func TestDirectRuntimeEndpoint_DevappEditionSupplementDoesNotOverrideHardcoded(t *testing.T) { @@ -95,7 +95,7 @@ func TestDirectRuntimeEndpoint_DevappEditionSupplementDoesNotOverrideHardcoded(t }) t.Cleanup(func() { edition.Override(prev) }) - assertEndpoint(t, "devapp", "list_open_dev_apps_by_condition", devappMCPEndpoint()) + assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint()) } func TestDirectRuntimeEndpoint_DevappEditionStaticDoesNotOverrideHardcoded(t *testing.T) { @@ -116,7 +116,7 @@ func TestDirectRuntimeEndpoint_DevappEditionStaticDoesNotOverrideHardcoded(t *te }) t.Cleanup(func() { edition.Override(prev) }) - assertEndpoint(t, "devapp", "list_open_dev_apps_by_condition", devappMCPEndpoint()) + assertEndpoint(t, "devapp", "list_dev_app", devappMCPEndpoint()) } func TestDirectRuntimeEndpoint_DevappEnvOverrideWinsOverEditionSupplement(t *testing.T) { @@ -137,7 +137,7 @@ func TestDirectRuntimeEndpoint_DevappEnvOverrideWinsOverEditionSupplement(t *tes }) t.Cleanup(func() { edition.Override(prev) }) - assertEndpoint(t, "devapp", "list_open_dev_apps_by_condition", "https://example.test/server/devapp-env") + assertEndpoint(t, "devapp", "list_dev_app", "https://example.test/server/devapp-env") } func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) { diff --git a/internal/app/helper_tool_fetcher.go b/internal/app/helper_tool_fetcher.go new file mode 100644 index 00000000..528880ed --- /dev/null +++ b/internal/app/helper_tool_fetcher.go @@ -0,0 +1,136 @@ +// 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 app + +import ( + "context" + "fmt" + "sync" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" +) + +// newHelperToolFetcher returns a cli.HelperToolFetcher that loads a helper MCP +// server's tools/list LIVE (by source) and projects each tool into a +// cli.HelperToolSchema (name, description, inputSchema properties/required). It +// is injected into the schema command so the cli package can render +// `dws schema dev.*` from real server schema without importing app/transport. +// +// Sources: "op-app" backs the dev app commands (pinned endpoint); "devdoc" +// backs `dws dev doc search` (endpoint resolved dynamically, see +// helperSourceEndpoint). Results are memoized per source per process so +// repeated `dws schema dev.*` hit the network at most once per source. A failed +// fetch is not cached, allowing a later retry within the same process. +func newHelperToolFetcher() cli.HelperToolFetcher { + var ( + mu sync.Mutex + cached = map[string]map[string]cli.HelperToolSchema{} + ) + return func(ctx context.Context, source string) (map[string]cli.HelperToolSchema, error) { + mu.Lock() + if got, ok := cached[source]; ok { + mu.Unlock() + return got, nil + } + mu.Unlock() + + endpoint, err := helperSourceEndpoint(source) + if err != nil { + return nil, err + } + schemas, err := fetchHelperToolSchemas(ctx, endpoint) + if err != nil { + return nil, err + } + mu.Lock() + cached[source] = schemas + mu.Unlock() + return schemas, nil + } +} + +// helperSourceEndpoint maps a schema source to its MCP endpoint. op-app (dev +// app) is pinned in source (devappMCPEndpoint, derived from the active gateway +// base — production by default, pre when ~/.dws/mcp_url points at pre); other +// sources (e.g. devdoc) are resolved the same way the runner resolves a product +// endpoint — env override → discovery → edition StaticServers/SupplementServers. +func helperSourceEndpoint(source string) (string, error) { + switch source { + case "", "op-app", "devapp": + return devappMCPEndpoint(), nil + default: + if endpoint, ok := directRuntimeEndpoint(source, ""); ok { + return endpoint, nil + } + return "", fmt.Errorf("no MCP endpoint resolved for source %q (not injected by edition/discovery)", source) + } +} + +// fetchHelperToolSchemas performs the live tools/list call against endpoint and +// converts the descriptors. Auth and identity headers are resolved the same way +// the runner does for direct-runtime invocations. +func fetchHelperToolSchemas(ctx context.Context, endpoint string) (map[string]cli.HelperToolSchema, error) { + token := resolveRuntimeAuthToken(ctx, "") + headers := resolveIdentityHeaders() + client := transport.NewClient(nil).WithAuth(token, headers) + + result, err := client.ListTools(ctx, endpoint) + if err != nil { + return nil, err + } + + out := make(map[string]cli.HelperToolSchema, len(result.Tools)) + for _, td := range result.Tools { + out[td.Name] = cli.HelperToolSchema{ + Name: td.Name, + Description: td.Description, + Properties: inputSchemaProperties(td.InputSchema), + Required: inputSchemaRequired(td.InputSchema), + } + } + return out, nil +} + +// inputSchemaProperties pulls the "properties" object out of a deserialized +// MCP inputSchema map. Returns an empty (non-nil) map when absent. +func inputSchemaProperties(schema map[string]any) map[string]any { + if schema == nil { + return map[string]any{} + } + props, _ := schema["properties"].(map[string]any) + if props == nil { + return map[string]any{} + } + return props +} + +// inputSchemaRequired pulls the "required" string list out of a deserialized +// MCP inputSchema map. +func inputSchemaRequired(schema map[string]any) []string { + if schema == nil { + return nil + } + raw, ok := schema["required"].([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok && s != "" { + out = append(out, s) + } + } + return out +} diff --git a/internal/app/legacy.go b/internal/app/legacy.go index acd6e753..6202de17 100644 --- a/internal/app/legacy.go +++ b/internal/app/legacy.go @@ -241,7 +241,7 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C // no-op: fall through to FallbackServers check below } } else { - servers = market.NormalizeServers(resp, "market") + servers = market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL()) if discoveryTraceEnabled() { slog.Info("loadDynamicCommands: sync discovery fetch ok", "partition", partition, @@ -499,6 +499,13 @@ func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market. return client.FetchServers(ctx, config.DefaultFetchServersLimit) } +func registryDiscoveryBaseURL() string { + if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" { + return editionURL + } + return DiscoveryBaseURL() +} + // asyncRevalidateRegistry refreshes the registry cache in the background. // Uses a short timeout derived from the parent context and silently ignores // errors — the next CLI invocation will pick up the refreshed cache or retry. @@ -511,7 +518,7 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err) return } - servers := market.NormalizeServers(resp, "market") + servers := market.NormalizeServersForBaseURL(resp, "market", registryDiscoveryBaseURL()) if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil { slog.Debug("asyncRevalidateRegistry: save failed", "error", saveErr) } diff --git a/internal/app/root.go b/internal/app/root.go index 86726148..18024a50 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -474,7 +474,7 @@ func newCacheCommand() *cobra.Command { if err != nil { return apperrors.NewDiscovery(fmt.Sprintf("cache refresh: fetch server list failed: %v", err)) } - servers := market.NormalizeServers(resp, "live_market") + servers := market.NormalizeServersForBaseURL(resp, "live_market", registryDiscoveryBaseURL()) _ = store.SaveRegistry(service.CachePartition(), cache.RegistrySnapshot{Servers: servers}) selected := selectServersForProduct(servers, product) @@ -596,7 +596,7 @@ func newVersionCommand() *cobra.Command { } func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command { - return cli.NewSchemaCommand(loader) + return cli.NewSchemaCommand(loader, newHelperToolFetcher()) } func newGenerateSkillsCommand() *cobra.Command { diff --git a/internal/app/runner.go b/internal/app/runner.go index a15901c6..e13894d9 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -111,7 +111,7 @@ func logHostOwnedPATDecisionOnce() { hostOwnedPATDecisionOnce.Do(func() { slog.Debug("runtime.host_owned_pat", "hostOwned", authpkg.HostOwnsPATFlow(), - "agentCodeEnvPresent", os.Getenv(authpkg.AgentCodeEnv) != "", + "agentCodeEnvPresent", authpkg.AgentCodeEnvPresent(), ) }) } @@ -262,7 +262,7 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut hint := "产品 envelope 可能未下发到 discovery,或已经被 serverDeps fail-fast 丢弃;可执行 'dws cache refresh' 强制重新 discovery,仍失败请向 Portal 确认 envelope 状态。" actions := []string{"dws cache refresh"} if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID { - hint = "devapp 是 helper-only 产品,命令树不依赖 discovery;真实调用需要内部版通过 SupplementServers/StaticServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。" + hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖 discovery;真实调用需要内部版通过 SupplementServers/StaticServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。" actions = []string{ "检查内部版 SupplementServers/StaticServers 是否包含 devapp endpoint", "本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试", @@ -696,9 +696,10 @@ func resolveIdentityHeaders() map[string]string { if sessionID == "" { sessionID = os.Getenv(envRewindSessionID) } + agentCode, _ := authpkg.AgentCodeFromEnv() envHeaders := map[string]string{ "x-dingtalk-agent": os.Getenv(envDingtalkAgent), - "x-dingtalk-dws-agent-code": strings.TrimSpace(os.Getenv(authpkg.AgentCodeEnv)), + "x-dingtalk-dws-agent-code": agentCode, "x-dingtalk-trace-id": os.Getenv(envDingtalkTraceID), "x-dingtalk-session-id": sessionID, "x-dingtalk-message-id": os.Getenv(envDingtalkMessageID), @@ -725,13 +726,23 @@ func resolveIdentityHeaders() map[string]string { // errors (success=false + errorCode/errorMsg) that are not flagged at the MCP // protocol level. Returns the error message, or "" if the response is OK. func detectBusinessError(content map[string]any) string { + return detectBusinessErrorAtDepth(content, 0) +} + +func detectBusinessErrorAtDepth(content map[string]any, depth int) string { + if content == nil || depth > 8 { + return "" + } success, ok := content["success"] if !ok { - return "" + return detectNestedBusinessError(content, depth) } b, ok := success.(bool) if !ok || b { - return "" + return detectNestedBusinessError(content, depth) + } + if nested := detectNestedBusinessError(content, depth); nested != "" { + return nested } if msg, ok := content["errorMsg"].(string); ok && strings.TrimSpace(msg) != "" { return strings.TrimSpace(msg) @@ -742,6 +753,28 @@ func detectBusinessError(content map[string]any) string { return "business error: success=false" } +func detectNestedBusinessError(content map[string]any, depth int) string { + for _, key := range []string{"content", "result", "data"} { + switch child := content[key].(type) { + case map[string]any: + if msg := detectBusinessErrorAtDepth(child, depth+1); msg != "" { + return msg + } + case []any: + for _, item := range child { + childMap, ok := item.(map[string]any) + if !ok { + continue + } + if msg := detectBusinessErrorAtDepth(childMap, depth+1); msg != "" { + return msg + } + } + } + } + return "" +} + // extractMCPErrorMessage builds an error message from a ToolCallResult with // isError=true. It extracts text from content blocks when available. func extractMCPErrorMessage(result transport.ToolCallResult) string { diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index fd3fd673..3aeafb4b 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -321,6 +321,7 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) { func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) { setupRuntimeCommandTest(t) t.Setenv(authpkg.AgentCodeEnv, " cursor ") + t.Setenv(authpkg.AgentCodeEnvCompat, "") headers := resolveIdentityHeaders() if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" { @@ -328,6 +329,17 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) { } } +func TestResolveIdentityHeadersForwardsCompatAgentCode(t *testing.T) { + setupRuntimeCommandTest(t) + t.Setenv(authpkg.AgentCodeEnv, "") + t.Setenv(authpkg.AgentCodeEnvCompat, " compat ") + + headers := resolveIdentityHeaders() + if got := headers["x-dingtalk-dws-agent-code"]; got != "compat" { + t.Fatalf("x-dingtalk-dws-agent-code = %q, want compat", got) + } +} + func TestResolveIdentityHeadersSessionEnvPriority(t *testing.T) { setupRuntimeCommandTest(t) t.Setenv(envDingtalkSessionID, "ding-session") @@ -884,6 +896,20 @@ func jsonRPCToolName(req map[string]any) string { return name } +func TestDetectBusinessErrorNestedServiceResult(t *testing.T) { + content := map[string]any{ + "success": false, + "result": map[string]any{ + "success": false, + "errorCode": "ROBOT_NOT_FOUND", + "errorMsg": "robot info is not exist", + }, + } + if got := detectBusinessError(content); got != "robot info is not exist" { + t.Fatalf("detectBusinessError() = %q, want nested errorMsg", got) + } +} + func writeJSONRPCToolResult(t *testing.T, w http.ResponseWriter, req map[string]any, content map[string]any, isError bool) { t.Helper() _ = json.NewEncoder(w).Encode(map[string]any{ diff --git a/internal/auth/channel.go b/internal/auth/channel.go index e9c1f49d..38c3753c 100644 --- a/internal/auth/channel.go +++ b/internal/auth/channel.go @@ -19,19 +19,40 @@ import ( ) const ( - // AgentCodeEnv is the sole per-spawn environment variable the host injects - // to declare "this process is driven by a third-party Agent host, render - // authorization UI yourselves". + // AgentCodeEnv is the primary per-spawn environment variable the host + // injects to declare "this process is driven by a third-party Agent host, + // render authorization UI yourselves". AgentCodeEnv = "DINGTALK_DWS_AGENTCODE" + + // AgentCodeEnvCompat is a compatibility alias for hosts that shipped the + // reversed prefix before AgentCodeEnv became the public spelling. + AgentCodeEnvCompat = "DWS_DINGTALK_AGENTCODE" ) +// AgentCodeFromEnv returns the effective host agent code and the env name that +// supplied it. The primary public spelling wins over the compatibility alias. +func AgentCodeFromEnv() (string, string) { + if value := strings.TrimSpace(os.Getenv(AgentCodeEnv)); value != "" { + return value, AgentCodeEnv + } + if value := strings.TrimSpace(os.Getenv(AgentCodeEnvCompat)); value != "" { + return value, AgentCodeEnvCompat + } + return "", "" +} + +func AgentCodeEnvPresent() bool { + value, _ := AgentCodeFromEnv() + return value != "" +} + // HostOwnsPATFlow reports whether the current process is running under a // third-party Agent host that will render the PAT authorization card -// itself. The sole trigger is AgentCodeEnv (DINGTALK_DWS_AGENTCODE) being -// non-empty. The CLI deliberately does not consult any other signal -// (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type header) for this -// decision so that server-side routing tags and the host-owned UI contract -// remain independent concerns. +// itself. The trigger is the effective agent-code env (DINGTALK_DWS_AGENTCODE +// or DWS_DINGTALK_AGENTCODE) being non-empty. The CLI deliberately does not +// consult any other signal (DINGTALK_AGENT / DWS_CHANNEL / the wire claw-type +// header) for this decision so that server-side routing tags and the host-owned +// UI contract remain independent concerns. func HostOwnsPATFlow() bool { - return strings.TrimSpace(os.Getenv(AgentCodeEnv)) != "" + return AgentCodeEnvPresent() } diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 1ddbf4f0..46a1caae 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -103,7 +103,7 @@ func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Ru return cmd } -func NewSchemaCommand(loader CatalogLoader) *cobra.Command { +func NewSchemaCommand(loader CatalogLoader, helperTools HelperToolFetcher) *cobra.Command { cmd := &cobra.Command{ Use: "schema [path]", Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)", @@ -125,7 +125,13 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command { dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好) dws schema calendar.create_event --jq '.tool.auth' dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示 - dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay`, + dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay + +helper-only 命令组(如 dev,不走服务发现)也支持查询,schema 从 op-app +MCP 服务端实时拉取,输出对齐 gws 的扁平格式(parameters 内联 required, +键为 CLI flag): + dws schema "dev app robot config" # 实时 MCP 参数 schema(gws-flat) + dws schema "dev app" # 列出该分组下的子命令`, Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -137,6 +143,29 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command { } args = []string{cliPath} } + + // Helper-only subtrees (e.g. `dws dev ...`) aren't in the discovery + // catalog; their schema CONTENT is fetched LIVE from the helper's + // pinned MCP server (op-app) and rendered in the gws-flat shape, so + // `dws schema "dev app robot config"` answers without touching + // discovery. Only the `dev` root claims this path; everything else + // falls through to the catalog below. + if len(args) > 0 { + payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools) + if err != nil { + return err + } + if ok { + return output.WriteFiltered( + cmd.OutOrStdout(), + output.ResolveFormat(cmd, output.FormatJSON), + payload, + output.ResolveFields(cmd), + output.ResolveJQ(cmd), + ) + } + } + catalog, err := loader.Load(cmd.Context()) if err != nil { var degraded *CatalogDegraded @@ -166,6 +195,17 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command { return err } + // Append helper-only subtrees (e.g. `dev`) to the no-arg product + // listing so browsing all products also surfaces helper commands. + if len(args) == 0 { + if helpers := helperProductSummaries(cmd.Root()); len(helpers) > 0 { + if products, ok := payload["products"].([]map[string]any); ok { + payload["products"] = append(products, helpers...) + payload["count"] = len(payload["products"].([]map[string]any)) + } + } + } + return output.WriteFiltered( cmd.OutOrStdout(), output.ResolveFormat(cmd, output.FormatJSON), diff --git a/internal/cli/canonical_test.go b/internal/cli/canonical_test.go index 2bd5e0b6..a52ef5b0 100644 --- a/internal/cli/canonical_test.go +++ b/internal/cli/canonical_test.go @@ -288,7 +288,7 @@ func TestSchemaCommandCLIPathFlag(t *testing.T) { }} t.Run("resolves via --cli-path", func(t *testing.T) { - cmd := NewSchemaCommand(loader) + cmd := NewSchemaCommand(loader, nil) var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&bytes.Buffer{}) @@ -310,7 +310,7 @@ func TestSchemaCommandCLIPathFlag(t *testing.T) { }) t.Run("rejects positional + flag collision", func(t *testing.T) { - cmd := NewSchemaCommand(loader) + cmd := NewSchemaCommand(loader, nil) var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&bytes.Buffer{}) @@ -1252,7 +1252,7 @@ func TestSchemaCommandOutputsDegradedOnUnauthenticated(t *testing.T) { Reason: DegradedUnauthenticated, Hint: "未登录,无法发现 MCP 服务。请先执行: dws auth login", } - cmd := NewSchemaCommand(errorLoader{err: degradedErr}) + cmd := NewSchemaCommand(errorLoader{err: degradedErr}, nil) var out, errOut bytes.Buffer cmd.SetOut(&out) @@ -1287,7 +1287,7 @@ func TestSchemaCommandOutputsDegradedOnMarketUnreachable(t *testing.T) { Reason: DegradedMarketUnreachable, Hint: "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络", } - cmd := NewSchemaCommand(errorLoader{err: degradedErr}) + cmd := NewSchemaCommand(errorLoader{err: degradedErr}, nil) var out, errOut bytes.Buffer cmd.SetOut(&out) @@ -1310,7 +1310,7 @@ func TestSchemaCommandPropagatesNonDegradedError(t *testing.T) { t.Parallel() wantErr := errors.New("unexpected failure") - cmd := NewSchemaCommand(errorLoader{err: wantErr}) + cmd := NewSchemaCommand(errorLoader{err: wantErr}, nil) var out bytes.Buffer cmd.SetOut(&out) diff --git a/internal/cli/dev_schema.go b/internal/cli/dev_schema.go new file mode 100644 index 00000000..487cf8d7 --- /dev/null +++ b/internal/cli/dev_schema.go @@ -0,0 +1,345 @@ +// 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 cli + +import ( + "context" + "fmt" + "sort" + "strings" + "unicode" + + "github.com/spf13/cobra" +) + +// helperSchemaRoots are top-level command names whose subtrees are helper-only +// (hard-coded cobra commands, not in the discovery catalog). `dws schema` still +// answers for them, but unlike discovery products the schema CONTENT is fetched +// LIVE from the helper's pinned MCP server (op-app) and rendered in the flat +// gws-aligned shape — never synthesized from local cobra flags, never +// hardcoded. The mapping from a leaf command to its MCP tool comes from the +// `mcp-tool` cobra annotation set in internal/helpers/devapp.go. +var helperSchemaRoots = map[string]bool{"dev": true} + +// HelperToolSchema is the live op-app tool schema the renderer needs: the raw +// MCP description plus the inputSchema's properties/required, exactly as the +// server returned them (no local transformation of CONTENT). +type HelperToolSchema struct { + Name string + Description string + Properties map[string]any // MCP param name → property object {type,description,default?,...} + Required []string // MCP param names that are required +} + +// HelperToolFetcher loads a helper MCP server's tools/list LIVE and returns +// toolName→schema for the given source (e.g. "op-app" for dev app commands, +// "devdoc" for dev doc commands). The schema command injects this so +// dev_schema.go can resolve a command's MCP tool and render its real schema +// without the cli package importing app/transport. Implementations should +// cache per-source per-process so repeated `dws schema dev.*` only hit the +// network once per source. +type HelperToolFetcher func(ctx context.Context, source string) (map[string]HelperToolSchema, error) + +// renderHelperSchema builds the `dws schema` payload for helper-only command +// subtrees. Returns (payload, true) when the path targets a helper subtree (so +// the caller skips catalog resolution); (nil, false) otherwise so the caller +// falls back to the discovery catalog. +// +// Leaf commands render the gws-flat object {description, path, source, +// parameters{:{type,description,default?,required}}} with all CONTENT +// pulled live from the MCP tool named by the command's `mcp-tool` annotation. +// Group/root paths render a browse listing {path, commands:[...]} from the +// cobra tree (no MCP needed). +func renderHelperSchema(ctx context.Context, root *cobra.Command, rawPath string, fetch HelperToolFetcher) (map[string]any, bool, error) { + if root == nil { + return nil, false, nil + } + tokens := splitSchemaPathTokens(rawPath) + if len(tokens) == 0 || !helperSchemaRoots[tokens[0]] { + return nil, false, nil + } + + target, rest, err := root.Find(tokens) + if err != nil || target == nil { + target = root + rest = tokens[1:] + } + // Find resolves to the deepest matching command and returns trailing tokens + // it couldn't match as (sub)commands. Any non-flag leftover means a typo'd + // or unknown subcommand — surface it with the closest group's children. + if unknown := firstNonFlag(rest); unknown != "" { + return map[string]any{ + "path": rawPath, + "error": "unknown subcommand \"" + unknown + "\" under \"" + helperCommandPath(target) + "\"", + "available": helperSubcommands(target), + }, true, nil + } + + // A runnable leaf → emit its live MCP schema in gws-flat shape. + // A group → browse its subcommands. + if target.Runnable() && !target.HasAvailableSubCommands() { + payload, err := helperLeafSchema(ctx, target, fetch) + return payload, true, err + } + + return map[string]any{ + "path": helperCommandPath(target), + "commands": helperSubcommands(target), + }, true, nil +} + +// helperLeafSchema renders a single leaf command as the gws-flat object, +// fetching its MCP tool schema live. The command must carry an `mcp-tool` +// annotation; commands without one (e.g. `dev connect`, `dev doc search`) are +// not devapp tools and get a clear, non-fatal explanation instead. +func helperLeafSchema(ctx context.Context, cmd *cobra.Command, fetch HelperToolFetcher) (map[string]any, error) { + toolName, source := "", "" + if cmd.Annotations != nil { + toolName = strings.TrimSpace(cmd.Annotations["mcp-tool"]) + source = strings.TrimSpace(cmd.Annotations["mcp-source"]) + } + // Default source is op-app (dev app commands); dev doc commands annotate + // mcp-source=devdoc to pull from the devdoc MCP server instead. + if source == "" { + source = "op-app" + } + path := helperCommandPath(cmd) + if toolName == "" { + return map[string]any{ + "path": path, + "error": "no MCP tool bound to this command; schema is unavailable", + }, nil + } + if fetch == nil { + return map[string]any{ + "path": path, + "error": "live MCP schema fetcher not configured", + }, nil + } + + tools, err := fetch(ctx, source) + if err != nil { + return nil, fmt.Errorf("fetch %s tool schemas: %w", source, err) + } + tool, ok := tools[toolName] + if !ok { + return map[string]any{ + "path": path, + "error": fmt.Sprintf("MCP tool %q not found in %s tools/list", toolName, source), + }, nil + } + + return map[string]any{ + "description": tool.Description, + "path": path, + "source": "mcp:" + source, + "parameters": helperFlatParameters(tool), + }, nil +} + +// helperFlatParameters projects an MCP tool's inputSchema into the gws-flat +// per-parameter object. Keys are kebab-case of the MCP param name (== the CLI +// flag); each value is {type, description, default?, required} with type mapped +// to a JSON-type string, description verbatim from MCP, default only when MCP +// provides one (stringified), and required inline (true iff the param is in the +// tool's required[]). +func helperFlatParameters(tool HelperToolSchema) map[string]any { + required := make(map[string]bool, len(tool.Required)) + for _, r := range tool.Required { + required[r] = true + } + + params := make(map[string]any, len(tool.Properties)) + for name, raw := range tool.Properties { + prop, _ := raw.(map[string]any) + entry := map[string]any{ + "type": mcpJSONType(prop), + "description": mcpString(prop, "description"), + "required": required[name], + } + if def, ok := mcpDefault(prop); ok { + entry["default"] = def + } + params[kebabCase(name)] = entry + } + return params +} + +// mcpJSONType normalizes the MCP property "type" to a JSON-type string. MCP +// reports standard JSON Schema types; pass them through, defaulting to "string" +// when absent/unknown so the contract is always populated. +func mcpJSONType(prop map[string]any) string { + t, _ := prop["type"].(string) + switch t { + case "string", "integer", "number", "boolean", "array", "object": + return t + default: + return "string" + } +} + +// mcpString reads a string field from an MCP property object. +func mcpString(prop map[string]any, key string) string { + if prop == nil { + return "" + } + v, _ := prop[key].(string) + return v +} + +// mcpDefault returns the MCP-provided default, stringified, only when present. +// gws renders default as a string; mirror that. Non-string JSON defaults +// (numbers/bools) are formatted with %v so e.g. 0 → "0", true → "true". +func mcpDefault(prop map[string]any) (string, bool) { + if prop == nil { + return "", false + } + v, ok := prop["default"] + if !ok || v == nil { + return "", false + } + switch tv := v.(type) { + case string: + return tv, true + case float64: + // JSON numbers decode to float64; render integers without a fraction. + if tv == float64(int64(tv)) { + return fmt.Sprintf("%d", int64(tv)), true + } + return fmt.Sprintf("%v", tv), true + default: + return fmt.Sprintf("%v", tv), true + } +} + +// kebabCase converts an MCP camelCase param name to the CLI flag's kebab form, +// matching how flags are registered in internal/helpers/devapp.go: +// +// eventCallbackUrl → event-callback-url +// unifiedAppId → unified-app-id +// disableSSLVerify → disable-ssl-verify +// +// A boundary is inserted before an uppercase letter that follows a lowercase +// letter or digit, and before the final uppercase of a run that starts a new +// lowercase word (so SSLVerify → ssl-verify, not s-s-l-verify). +func kebabCase(name string) string { + runes := []rune(name) + var b strings.Builder + for i, r := range runes { + if unicode.IsUpper(r) { + prevLowerOrDigit := i > 0 && (unicode.IsLower(runes[i-1]) || unicode.IsDigit(runes[i-1])) + nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1]) + if i > 0 && (prevLowerOrDigit || nextLower) { + b.WriteByte('-') + } + b.WriteRune(unicode.ToLower(r)) + continue + } + b.WriteRune(r) + } + // Collapse any accidental double dashes and trim, just in case the source + // already contained separators. + out := strings.ReplaceAll(b.String(), "_", "-") + for strings.Contains(out, "--") { + out = strings.ReplaceAll(out, "--", "-") + } + return strings.Trim(out, "-") +} + +// helperProductSummaries returns light product entries for every helper-only +// subtree, appended to the no-arg `dws schema` product listing so agents +// browsing all products also see helper commands. Tools are listed by path + +// summary only; drill in with `dws schema ""` for full parameter schema. +func helperProductSummaries(root *cobra.Command) []map[string]any { + if root == nil { + return nil + } + out := []map[string]any{} + for name := range helperSchemaRoots { + top, _, err := root.Find([]string{name}) + if err != nil || top == nil || !top.HasParent() { + continue + } + leaves := []map[string]any{} + walkLeafCommands(top, func(leaf *cobra.Command) { + leaves = append(leaves, map[string]any{ + "cli_name": leaf.Name(), + "cli_path": helperCommandPath(leaf), + "description": strings.TrimSpace(leaf.Short), + }) + }) + out = append(out, map[string]any{ + "id": name, + "name": strings.TrimSpace(top.Short), + "description": "helper-only 命令组(不走服务发现);schema 从 op-app MCP 实时拉取,用 `dws schema \"" + helperCommandPath(top) + " ...\"` 查具体参数", + "helper": true, + "tools": leaves, + }) + } + return out +} + +// walkLeafCommands invokes fn for every runnable leaf under cmd (depth-first). +func walkLeafCommands(cmd *cobra.Command, fn func(*cobra.Command)) { + if cmd.Runnable() && !cmd.HasAvailableSubCommands() { + fn(cmd) + return + } + for _, sub := range cmd.Commands() { + if !sub.IsAvailableCommand() || sub.Name() == "help" { + continue + } + walkLeafCommands(sub, fn) + } +} + +// helperSubcommands lists a group's runnable children for browse mode, sorted +// by name for deterministic output. +func helperSubcommands(cmd *cobra.Command) []map[string]any { + out := []map[string]any{} + for _, sub := range cmd.Commands() { + if !sub.IsAvailableCommand() || sub.Name() == "help" { + continue + } + out = append(out, map[string]any{ + "cli_path": helperCommandPath(sub), + "description": strings.TrimSpace(sub.Short), + }) + } + sort.Slice(out, func(i, j int) bool { + return out[i]["cli_path"].(string) < out[j]["cli_path"].(string) + }) + return out +} + +// helperCommandPath returns the space-joined path from root to cmd, e.g. +// "dev app robot config". +func helperCommandPath(cmd *cobra.Command) string { + parts := []string{} + for c := cmd; c != nil && c.HasParent(); c = c.Parent() { + parts = append([]string{c.Name()}, parts...) + } + return strings.Join(parts, " ") +} + +// firstNonFlag returns the first token that is not a flag (does not start with +// "-"), or "" if there is none. +func firstNonFlag(tokens []string) string { + for _, t := range tokens { + if t != "" && !strings.HasPrefix(t, "-") { + return t + } + } + return "" +} diff --git a/internal/cli/dev_schema_test.go b/internal/cli/dev_schema_test.go new file mode 100644 index 00000000..4a745e87 --- /dev/null +++ b/internal/cli/dev_schema_test.go @@ -0,0 +1,254 @@ +// 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 cli + +import ( + "context" + "errors" + "testing" + + "github.com/spf13/cobra" +) + +// buildHelperTestTree mirrors the shape of the real `dws dev` subtree closely +// enough to exercise the live-schema renderer: a group and leaves carrying the +// `mcp-tool` annotation that names the op-app tool to fetch. +func buildHelperTestTree() *cobra.Command { + root := &cobra.Command{Use: "dws"} + + create := &cobra.Command{ + Use: "create", + Short: "创建应用", + Annotations: map[string]string{"mcp-tool": "create_dev_app"}, + Run: func(*cobra.Command, []string) {}, + } + + config := &cobra.Command{ + Use: "config", + Short: "配置机器人", + Annotations: map[string]string{"mcp-tool": "set_extension_robot_config"}, + Run: func(*cobra.Command, []string) {}, + } + + // A leaf without an mcp-tool annotation (e.g. dev connect / dev doc search). + noTool := &cobra.Command{Use: "connect", Short: "无 MCP 工具", Run: func(*cobra.Command, []string) {}} + + robot := &cobra.Command{Use: "robot", Short: "机器人能力"} + robot.AddCommand(config) + + app := &cobra.Command{Use: "app", Short: "应用"} + app.AddCommand(create, robot) + + dev := &cobra.Command{Use: "dev", Short: "开放平台开发者命令"} + dev.AddCommand(app, noTool) + + root.AddCommand(dev) + return root +} + +// fakeFetcher returns a canned op-app tools/list so the renderer is exercised +// without network. It mirrors the MCP shape: properties keyed by camelCase param +// name, required[] listing the camelCase names. +func fakeFetcher(tools map[string]HelperToolSchema) HelperToolFetcher { + return func(context.Context, string) (map[string]HelperToolSchema, error) { + return tools, nil + } +} + +func robotConfigToolSchema() HelperToolSchema { + return HelperToolSchema{ + Name: "set_extension_robot_config", + Description: "创建或更新现有应用的机器人配置", + Properties: map[string]any{ + "unifiedAppId": map[string]any{"type": "string", "description": "统一应用 ID"}, + "eventCallbackUrl": map[string]any{"type": "string", "description": "事件回调地址"}, + "skills": map[string]any{"type": "array", "description": "技能列表"}, + "mode": map[string]any{"type": "string", "description": "机器人模式", "enum": []any{"HTTPS", "STREAM", "AISKILL"}}, + }, + Required: []string{"unifiedAppId"}, + } +} + +func TestRenderHelperSchema_LeafGwsFlat(t *testing.T) { + root := buildHelperTestTree() + fetch := fakeFetcher(map[string]HelperToolSchema{ + "set_extension_robot_config": robotConfigToolSchema(), + }) + + payload, ok, err := renderHelperSchema(context.Background(), root, "dev app robot config", fetch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected helper renderer to claim the path") + } + + // Flat top-level: description / path / source / parameters; no wrapper. + if payload["description"] != "创建或更新现有应用的机器人配置" { + t.Fatalf("description = %v", payload["description"]) + } + if payload["path"] != "dev app robot config" { + t.Fatalf("path = %v", payload["path"]) + } + if payload["source"] != "mcp:op-app" { + t.Fatalf("source = %v", payload["source"]) + } + for _, leaked := range []string{"kind", "tool", "product", "helper"} { + if _, present := payload[leaked]; present { + t.Fatalf("gws-flat output must not carry %q wrapper key", leaked) + } + } + + params, _ := payload["parameters"].(map[string]any) + if params == nil { + t.Fatalf("no parameters: %#v", payload) + } + + // Keys are kebab-case of the MCP param name == the CLI flag. + uid, _ := params["unified-app-id"].(map[string]any) + if uid == nil { + t.Fatalf("missing unified-app-id param: %#v", params) + } + if uid["type"] != "string" || uid["required"] != true { + t.Fatalf("unified-app-id = %#v, want string+required", uid) + } + if _, hasDefault := uid["default"]; hasDefault { + t.Fatal("unified-app-id must not carry a default (MCP provides none)") + } + + cb, _ := params["event-callback-url"].(map[string]any) + if cb == nil || cb["required"] != false { + t.Fatalf("event-callback-url = %#v, want required=false", cb) + } + + skills, _ := params["skills"].(map[string]any) + if skills == nil || skills["type"] != "array" { + t.Fatalf("skills = %#v, want array", skills) + } + + mode, _ := params["mode"].(map[string]any) + if mode == nil || mode["type"] != "string" { + t.Fatalf("mode = %#v, want string", mode) + } + if _, hasDefault := mode["default"]; hasDefault { + t.Fatalf("mode default = %v, want none", mode["default"]) + } + if mode["required"] != false { + t.Fatalf("mode required = %v, want false", mode["required"]) + } +} + +func TestRenderHelperSchema_Group(t *testing.T) { + root := buildHelperTestTree() + payload, ok, err := renderHelperSchema(context.Background(), root, "dev app", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected claim") + } + if payload["path"] != "dev app" { + t.Fatalf("path = %v", payload["path"]) + } + cmds, _ := payload["commands"].([]map[string]any) + if len(cmds) != 2 { // create + robot + t.Fatalf("commands count = %d, want 2", len(cmds)) + } +} + +func TestRenderHelperSchema_NoAnnotation(t *testing.T) { + root := buildHelperTestTree() + payload, ok, err := renderHelperSchema(context.Background(), root, "dev connect", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected claim") + } + if payload["error"] == nil { + t.Fatalf("expected a clear no-MCP-tool error, got %#v", payload) + } + if _, present := payload["parameters"]; present { + t.Fatal("no-tool command must not emit parameters") + } +} + +func TestRenderHelperSchema_UnknownSubcommand(t *testing.T) { + root := buildHelperTestTree() + payload, ok, err := renderHelperSchema(context.Background(), root, "dev app nope", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected claim") + } + if payload["error"] == nil { + t.Fatalf("expected error for unknown subcommand, got %#v", payload) + } + if avail, _ := payload["available"].([]map[string]any); len(avail) == 0 { + t.Fatal("expected available subcommands listed") + } +} + +func TestRenderHelperSchema_ToolMissingInList(t *testing.T) { + root := buildHelperTestTree() + // Fetcher returns an empty list — the annotated tool isn't present. + payload, ok, err := renderHelperSchema(context.Background(), root, "dev app create", fakeFetcher(map[string]HelperToolSchema{})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected claim") + } + if payload["error"] == nil { + t.Fatalf("expected not-found error, got %#v", payload) + } +} + +func TestRenderHelperSchema_FetchError(t *testing.T) { + root := buildHelperTestTree() + failing := func(context.Context, string) (map[string]HelperToolSchema, error) { + return nil, errors.New("network down") + } + _, ok, err := renderHelperSchema(context.Background(), root, "dev app create", failing) + if !ok { + t.Fatal("expected claim even on fetch error") + } + if err == nil { + t.Fatal("expected the fetch error to surface") + } +} + +func TestRenderHelperSchema_NonHelperPathDeclined(t *testing.T) { + root := buildHelperTestTree() + if _, ok, _ := renderHelperSchema(context.Background(), root, "ding.message.send", nil); ok { + t.Fatal("non-helper path must not be claimed by the helper renderer") + } +} + +func TestKebabCase(t *testing.T) { + cases := map[string]string{ + "eventCallbackUrl": "event-callback-url", + "unifiedAppId": "unified-app-id", + "disableSSLVerify": "disable-ssl-verify", + "mode": "mode", + "skills": "skills", + "i18nName": "i18n-name", + } + for in, want := range cases { + if got := kebabCase(in); got != want { + t.Errorf("kebabCase(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/cli/loader.go b/internal/cli/loader.go index b67edce9..19dd85bd 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -279,7 +279,7 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) { // accepted subset. See plan fix-wukong-discovery-missing-servers Phase 4.3. logDiscoveryWarnings(response.Metadata.Warnings) - servers := market.NormalizeServers(response, "live_market") + servers := market.NormalizeServersForBaseURL(response, "live_market", baseURL) _ = store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}) changedKeys := cache.ChangedServerKeysByUpdatedAt(cached.Registry.Servers, servers) diff --git a/internal/discovery/service.go b/internal/discovery/service.go index 39f722c2..ab5c7887 100644 --- a/internal/discovery/service.go +++ b/internal/discovery/service.go @@ -94,7 +94,7 @@ func (s *Service) DiscoverServers(ctx context.Context) ([]market.ServerDescripto response, err := s.MarketClient.FetchServers(ctx, 200) if err == nil { - servers := market.NormalizeServers(response, "live_market") + servers := market.NormalizeServersForBaseURL(response, "live_market", s.MarketClient.BaseURL) _ = s.Cache.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}) return servers, nil } diff --git a/internal/helpers/connect_agent_options_test.go b/internal/helpers/connect_agent_options_test.go index 009dac71..992d745f 100644 --- a/internal/helpers/connect_agent_options_test.go +++ b/internal/helpers/connect_agent_options_test.go @@ -132,7 +132,7 @@ func TestRobotConnectAgentFlagsInDryRun(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"devapp", "robot", "connect", + root.SetArgs([]string{"dev", "connect", "--channel", "claudecode", "--robot-client-id", "id1", "--robot-client-secret", "sec1", "--agent-model", "claude-sonnet-4-6", "--agent-workdir", "/tmp/kb", @@ -151,7 +151,7 @@ func TestRobotConnectAgentFlagsInDryRun(t *testing.T) { root = newDevAppTestRoot(&captureRunner{}) root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"devapp", "robot", "connect", + root.SetArgs([]string{"dev", "connect", "--channel", "claudecode", "--robot-client-id", "id1", "--robot-client-secret", "sec1", "--agent-memory=false", "--dry-run"}) @@ -179,7 +179,7 @@ func TestRobotConnectDryRunShowsCliStatus(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"devapp", "robot", "connect", "--channel", channel, + root.SetArgs([]string{"dev", "connect", "--channel", channel, "--robot-client-id", "a", "--robot-client-secret", "b", "--dry-run"}) if err := root.Execute(); err != nil { t.Fatalf("Execute(%s): %v\n%s", channel, err, out.String()) diff --git a/internal/helpers/connect_card_test.go b/internal/helpers/connect_card_test.go index 03f1f96b..c8671034 100644 --- a/internal/helpers/connect_card_test.go +++ b/internal/helpers/connect_card_test.go @@ -230,7 +230,7 @@ func TestRobotConnectReplyCardFlag(t *testing.T) { var out strings.Builder root.SetOut(&out) root.SetErr(&out) - args := append([]string{"devapp", "robot", "connect", + args := append([]string{"dev", "connect", "--channel", "claudecode", "--robot-client-id", "a", "--robot-client-secret", "b", "--dry-run"}, tc.args...) root.SetArgs(args) diff --git a/internal/helpers/connect_stream.go b/internal/helpers/connect_stream.go index 16d26d0f..2920bd55 100644 --- a/internal/helpers/connect_stream.go +++ b/internal/helpers/connect_stream.go @@ -74,7 +74,7 @@ type streamingForwarder interface { } // connectAgentOptions carries the user-facing agent tuning exposed on -// `devapp robot connect` (and mirrored env vars). Zero value = defaults: +// `dev connect` (and mirrored env vars). Zero value = defaults: // channel's built-in model, per-conversation memory on (where the CLI supports // it), empty scratch workdir. type connectAgentOptions struct { diff --git a/internal/helpers/dev.go b/internal/helpers/dev.go new file mode 100644 index 00000000..10356a66 --- /dev/null +++ b/internal/helpers/dev.go @@ -0,0 +1,77 @@ +// 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 ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/spf13/cobra" +) + +func init() { + RegisterPublic(func() Handler { + return devHandler{} + }) +} + +// devHandler mounts the developer umbrella command `dws dev`, grouping the +// three developer-facing pillars under one entry: +// +// dev app application lifecycle (CRUD / credentials / permission / +// member / security / webapp / robot / version) +// dev connect link an existing robot to the local agent for debugging +// (promoted out of the robot subtree: it is a local-dev entry, +// not a robot configuration) +// dev doc open-platform developer doc search (bridges the devdoc +// product; `dws devdoc` keeps working independently) +// +// Future developer capabilities (event subscription, MCP tooling, ...) join +// as new subtrees here instead of new top-level commands. +type devHandler struct{} + +func (devHandler) Name() string { + return "dev" +} + +func (devHandler) Command(runner executor.Runner) *cobra.Command { + root := &cobra.Command{ + Use: "dev", + Short: "开放平台开发者能力", + Long: "钉钉开放平台开发者命令组:应用生命周期管理(app)、机器人本地调试建联(connect)、开发文档搜索(doc)。", + Args: cobra.NoArgs, + TraverseChildren: true, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + doc := &cobra.Command{ + Use: "doc", + Short: "开放平台文档搜索", + Args: cobra.NoArgs, + TraverseChildren: true, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + doc.AddCommand(newDevdocArticleSearchCommand(runner)) + + root.AddCommand( + newDevAppCommand(runner), + newDevAppRobotConnectCommand(runner), + doc, + ) + return root +} diff --git a/internal/helpers/devapp.go b/internal/helpers/devapp.go index 3212410d..28e94892 100644 --- a/internal/helpers/devapp.go +++ b/internal/helpers/devapp.go @@ -17,7 +17,6 @@ import ( "encoding/json" "fmt" "strings" - "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -29,57 +28,65 @@ import ( const ( devAppProduct = "devapp" - devAppMemberListTool = "list_open_dev_app_members" - devAppMemberAddTool = "add_open_dev_app_members" - devAppMemberRemoveTool = "remove_open_dev_app_members" - devAppSecurityConfigTool = "update_app_security_config" + // 工具名 = 服务端 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 工具,硬编码不走服务发现)。 - // - // devAppRobotCreateTool(同步 create_dingtalk_robot)的服务端实现引用了一个 - // connect-engine 动作模板(detailId G-ACT-...),该模板当前在引擎里已找不到, - // 对所有调用方都返回 PARAM_ERROR / "找不到执行动作"(见 issue #35)。因此 - // `dws devapp robot create` 不再调用它,改用 submit + 轮询 result 的异步路径, - // 二者返回的最终结果一致(agentId/clientId/robotCode/clientSecret)。 - devAppRobotCreateTool = "create_dingtalk_robot" - devAppRobotSubmitTool = "submit_robot_create_task" - devAppRobotResultTool = "query_robot_create_result" - devAppRobotConfigGetTool = "get_open_dev_app_robot_config" - devAppRobotConfigCreateTool = "create_open_dev_app_robot_config" - devAppRobotConfigUpdateTool = "update_open_dev_app_robot_config" - devAppRobotEnableTool = "enable_open_dev_app_robot" - devAppRobotOfflineTool = "offline_open_dev_app_robot" + 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_open_dev_app_version" - devAppVersionListTool = "list_open_dev_app_versions" - devAppVersionDetailTool = "get_open_dev_app_version_detail" - devAppVersionPublishTool = "publish_open_dev_app_version" - devAppVersionStatusTool = "get_open_dev_app_version_status" + 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 init() { - RegisterPublic(func() Handler { - return devAppHandler{} - }) -} - -type devAppHandler struct{} - -func (devAppHandler) Name() string { - return "devapp" -} - -func (devAppHandler) Command(runner executor.Runner) *cobra.Command { - return newDevAppCommand(runner) -} - +// 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 { root := &cobra.Command{ - Use: "devapp", - Aliases: []string{"app"}, + Use: "app", Short: "开放平台应用", - Long: "管理开放平台开发者应用:查询、详情、创建、更新、启停、删除、权限、网页应用、成员、安全配置、机器人和版本发布。", + Long: "管理开放平台开发者应用:查询、详情、创建、更新、启停、删除、权限、网页应用、成员、安全配置、机器人、版本发布和事件订阅。", Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, @@ -170,15 +177,12 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { }, } robot.AddCommand( - newDevAppRobotCreateCommand(runner), newDevAppRobotSubmitCommand(runner), newDevAppRobotResultCommand(runner), newDevAppRobotConfigGetCommand(runner), - newDevAppRobotConfigCommand(runner, "config", "为现有应用创建机器人配置", devAppRobotConfigCreateTool), - newDevAppRobotConfigCommand(runner, "update", "更新现有应用机器人配置", devAppRobotConfigUpdateTool), - newDevAppRobotConfigCommand(runner, "enable", "启用现有应用机器人能力", devAppRobotEnableTool), + newDevAppRobotConfigCommand(runner), + newDevAppRobotEnableCommand(runner), newDevAppRobotOfflineCommand(runner), - newDevAppRobotConnectCommand(runner), ) version := &cobra.Command{ @@ -200,14 +204,30 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { 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() + }, + } + event.AddCommand( + newDevAppEventListCommand(runner), + newDevAppEventSubscribeCommand(runner), + newDevAppEventUnsubscribeCommand(runner), + ) + root.AddCommand( newDevAppListCommand(runner), newDevAppGetCommand(runner), newDevAppCreateCommand(runner), newDevAppUpdateCommand(runner), - newDevAppLifecycleCommand(runner, "delete", "删除开放平台企业内部应用", "delete_inner_app"), - newDevAppLifecycleCommand(runner, "inactive", "停用开放平台企业内部应用", "inactive_inner_app"), - newDevAppLifecycleCommand(runner, "active", "启用开放平台企业内部应用", "active_inner_app"), + newDevAppDeleteCommand(runner), + newDevAppLifecycleCommand(runner, "disable", "停用开放平台企业内部应用", devAppDisableTool), + newDevAppLifecycleCommand(runner, "enable", "启用开放平台企业内部应用", devAppEnableTool), credentials, webapp, permission, @@ -215,51 +235,140 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { security, robot, version, + event, ) return root } +// --------------------------------------------------------------------------- +// 事件订阅能力(服务端新增 list/subscribe/unsubscribe) +// --------------------------------------------------------------------------- + +func newDevAppEventListCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "查询应用已订阅的事件列表", + Example: " dws dev app event list --unified-app-id UNIFIED_APP_ID --page-size 20 --format json", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err + } + params := map[string]any{"unifiedAppId": appID} + devAppPutString(params, "keyword", devAppStringFlag(cmd, "keyword")) + devAppApplyCursorParams(cmd, params) + return runDevAppTool(runner, cmd, devAppEventListTool, params) + }, + } + addDevAppUnifiedIDFlag(cmd) + cmd.Flags().String("keyword", "", "事件搜索关键词,支持按事件码或事件名称模糊匹配") + registerDevAppCursorFlags(cmd) + preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppEventListTool) + return cmd +} + +func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "subscribe", + Short: "订阅应用事件回调", + Example: " dws dev app event subscribe --unified-app-id UNIFIED_APP_ID --event-codes bpms_task_change --dry-run --format json", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := devAppRequireWriteGuard(cmd, "event subscribe"); err != nil { + return err + } + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err + } + params := map[string]any{"unifiedAppId": appID} + // 服务端 subscribe/unsubscribe 工具收 eventCodes 数组(事件码列表)。 + eventCodes, err := requiredDevAppEventCodes(cmd) + if err != nil { + return err + } + params["eventCodes"] = eventCodes + return runDevAppTool(runner, cmd, devAppEventSubscribeTool, params) + }, + } + addDevAppUnifiedIDFlag(cmd) + cmd.Flags().String("event-codes", "", "事件码,多个用逗号或分号分隔") + preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppEventSubscribeTool) + return cmd +} + +func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "unsubscribe", + Short: "取消订阅应用事件", + Example: " dws dev app event unsubscribe --unified-app-id UNIFIED_APP_ID --event-codes bpms_task_change --dry-run --format json", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := devAppRequireWriteGuard(cmd, "event unsubscribe"); err != nil { + return err + } + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err + } + params := map[string]any{"unifiedAppId": appID} + eventCodes, err := requiredDevAppEventCodes(cmd) + if err != nil { + return err + } + params["eventCodes"] = eventCodes + return runDevAppTool(runner, cmd, devAppEventUnsubscribeTool, params) + }, + } + addDevAppUnifiedIDFlag(cmd) + cmd.Flags().String("event-codes", "", "事件码,多个用逗号或分号分隔") + preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppEventUnsubscribeTool) + return cmd +} + func newDevAppListCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "查询开放平台企业内部应用列表", - Example: " dws devapp list --name DemoApp --page 1 --page-size 20 --format json", + Example: " dws dev app list --name DemoApp --page-size 20 --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - params := map[string]any{ - "currentPage": devAppIntFlag(cmd, "page"), - "pageSize": devAppIntFlag(cmd, "page-size"), - } - devAppPutString(params, "appName", devAppFlagOrFallback(cmd, "name", "keyword")) - devAppPutInt(params, "agentId", devAppIntFlag(cmd, "agent-id")) - devAppPutInt(params, "appId", devAppIntFlag(cmd, "app-id")) + params := map[string]any{} + devAppApplyCursorParams(cmd, params) + devAppPutString(params, "name", devAppFlagOrFallback(cmd, "name", "keyword")) devAppPutString(params, "appKey", devAppStringFlag(cmd, "app-key")) - devAppPutString(params, "customKey", devAppStringFlag(cmd, "custom-key")) devAppPutInt(params, "appGroupId", devAppIntFlag(cmd, "app-group-id")) devAppPutString(params, "creator", devAppStringFlag(cmd, "creator")) devAppPutString(params, "robotName", devAppStringFlag(cmd, "robot-name")) devAppPutInt(params, "developType", devAppIntFlag(cmd, "develop-type")) devAppPutInt(params, "filterCoolApp", devAppIntFlag(cmd, "filter-cool-app")) - devAppPutString(params, "sortType", devAppStringFlag(cmd, "sort")) - devAppPutString(params, "sortOrder", devAppStringFlag(cmd, "order")) - return runDevAppTool(runner, cmd, "list_open_dev_apps_by_condition", params) + devAppPutString(params, "sortType", devAppStringFlag(cmd, "sort-type")) + devAppPutString(params, "sortOrder", devAppStringFlag(cmd, "sort-order")) + return runDevAppTool(runner, cmd, devAppListTool, params) }, } - cmd.Flags().Int("page", 1, "分页页码,从 1 开始") - cmd.Flags().Int("page-size", 20, "分页大小") + registerDevAppCursorFlags(cmd) cmd.Flags().String("name", "", "应用名称关键词") cmd.Flags().String("keyword", "", "--name 的兼容别名") _ = cmd.Flags().MarkHidden("keyword") - addDevAppLocatorFlagsWithoutName(cmd) + cmd.Flags().String("app-key", "", "按 appKey/clientId 过滤") cmd.Flags().Int("app-group-id", 0, "应用分组 ID") cmd.Flags().String("creator", "", "创建人名称关键词") cmd.Flags().String("robot-name", "", "机器人名称关键词") cmd.Flags().Int("develop-type", 0, "开发类型枚举;不确定时不要传") cmd.Flags().Int("filter-cool-app", 0, "酷应用过滤枚举;不确定时不要传") - cmd.Flags().String("sort", "", "排序字段,如 gmt_modified") - cmd.Flags().String("order", "", "排序方向 asc 或 desc") + cmd.Flags().String("sort-type", "", "排序字段,如 gmt_modified") + cmd.Flags().String("sort-order", "", "排序方向 asc 或 desc") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppListTool) return cmd } @@ -267,19 +376,21 @@ func newDevAppGetCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "get", Short: "查询开放平台企业内部应用详情", - Example: " dws devapp get --agent-id 123456 --format json", + Example: " dws dev app get --unified-app-id UNIFIED_APP_ID --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - params := devAppLocatorParams(cmd, true) - if len(params) == 0 { - return devAppLocatorRequired(true) + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err } - return runDevAppTool(runner, cmd, "get_open_dev_app_detail", params) + params := map[string]any{"unifiedAppId": appID} + return runDevAppTool(runner, cmd, devAppGetTool, params) }, } - addDevAppLocatorFlags(cmd) + addDevAppUnifiedIDFlag(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppGetTool) return cmd } @@ -287,32 +398,28 @@ func newDevAppCreateCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "创建开放平台企业内部应用", - Example: " dws devapp create --name DemoApp --desc 内部应用 --dry-run --format json", + Example: " dws dev app create --name DemoApp --desc 内部应用 --dry-run --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "create"); err != nil { return err } - appType := devAppStringFlag(cmd, "type") - if appType != "" && appType != "internal" { - return apperrors.NewValidation("--type currently only supports internal") - } name := devAppStringFlag(cmd, "name") if name == "" { - return apperrors.NewValidation("--name is required") + return apperrors.NewValidation("--name 为必填") } - params := map[string]any{"appName": name} - devAppPutString(params, "appDesc", devAppStringFlag(cmd, "desc")) - devAppPutString(params, "appIcon", devAppStringFlag(cmd, "icon")) - return runDevAppTool(runner, cmd, "create_inner_app", params) + params := map[string]any{"name": name} + devAppPutString(params, "desc", devAppStringFlag(cmd, "desc")) + devAppPutString(params, "iconMediaId", devAppStringFlag(cmd, "icon-media-id")) + return runDevAppTool(runner, cmd, devAppCreateTool, params) }, } cmd.Flags().String("name", "", "应用名称 (必填)") cmd.Flags().String("desc", "", "应用描述") - cmd.Flags().String("icon", "", "应用图标 mediaId") - cmd.Flags().String("type", "internal", "应用类型;当前仅支持 internal") + cmd.Flags().String("icon-media-id", "", "应用图标 mediaId") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppCreateTool) return cmd } @@ -320,41 +427,43 @@ func newDevAppUpdateCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "update", Short: "修改开放平台企业内部应用基础信息", - Example: " dws devapp update --agent-id 123456 --name DemoApp2 --dry-run --format json", + Example: " dws dev app update --unified-app-id UNIFIED_APP_ID --name DemoApp2 --dry-run --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "update"); err != nil { return err } - params := devAppLocatorParams(cmd, false) - if len(params) == 0 { - return devAppLocatorRequired(false) + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err } + params := map[string]any{"unifiedAppId": appID} updates := 0 if v := devAppStringFlag(cmd, "name"); v != "" { - params["appName"] = v + params["name"] = v updates++ } if v := devAppStringFlag(cmd, "desc"); v != "" { - params["appDesc"] = v + params["desc"] = v updates++ } - if v := devAppStringFlag(cmd, "icon"); v != "" { - params["appIcon"] = v + if v := devAppStringFlag(cmd, "icon-media-id"); v != "" { + params["iconMediaId"] = v updates++ } if updates == 0 { - return apperrors.NewValidation("at least one update field is required: --name, --desc, or --icon") + return apperrors.NewValidation("至少提供一项待更新字段:--name、--desc 或 --icon-media-id") } - return runDevAppTool(runner, cmd, "update_inner_app", params) + return runDevAppTool(runner, cmd, devAppUpdateTool, params) }, } - addDevAppLocatorFlagsWithoutName(cmd) + addDevAppUnifiedIDFlag(cmd) cmd.Flags().String("name", "", "新的应用名称") cmd.Flags().String("desc", "", "新的应用描述") - cmd.Flags().String("icon", "", "新的应用图标 mediaId") + cmd.Flags().String("icon-media-id", "", "新的应用图标 mediaId") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppUpdateTool) return cmd } @@ -362,19 +471,21 @@ func newDevAppCredentialsGetCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "get", Short: "读取开放平台应用凭证", - Example: " dws devapp credentials get --agent-id 123456 --format json", + Example: " dws dev app credentials get --unified-app-id UNIFIED_APP_ID --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - params := devAppLocatorParams(cmd, false) - if len(params) == 0 { - return devAppLocatorRequired(false) + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err } - return runDevAppTool(runner, cmd, "get_open_dev_app_credentials", params) + params := map[string]any{"unifiedAppId": appID} + return runDevAppTool(runner, cmd, devAppCredentialsGetTool, params) }, } - addDevAppLocatorFlagsWithoutName(cmd) + addDevAppUnifiedIDFlag(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppCredentialsGetTool) return cmd } @@ -388,35 +499,132 @@ func newDevAppLifecycleCommand(runner executor.Runner, use, short, tool string) if err := devAppRequireWriteGuard(cmd, use); err != nil { return err } - params := devAppLocatorParams(cmd, false) - if len(params) == 0 { - return devAppLocatorRequired(false) + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err } + params := map[string]any{"unifiedAppId": appID} return runDevAppTool(runner, cmd, tool, params) }, } - addDevAppLocatorFlagsWithoutName(cmd) + addDevAppUnifiedIDFlag(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, tool) return cmd } +// newDevAppDeleteCommand is delete with a danger tier: deleting an app is +// irreversible, so beyond the write guard 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). +func newDevAppDeleteCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "删除开放平台企业内部应用(不可逆,需 --confirm-name 二次确认)", + Example: " dws dev app delete --unified-app-id UNIFIED_APP_ID --confirm-name 应用名 --yes --format json", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := devAppRequireWriteGuard(cmd, "delete"); err != nil { + return err + } + 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) + }, + } + addDevAppUnifiedIDFlag(cmd) + cmd.Flags().String("confirm-name", "", "二次确认:必须与被删应用的名称一致(不可逆操作的防误删)") + preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppDeleteTool) + return cmd +} + +// 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 { cmd := &cobra.Command{ Use: "get", Short: "查询网页应用配置", - Example: " dws devapp webapp get --agent-id 123456 --format json", + Example: " dws dev app webapp get --unified-app-id UNIFIED_APP_ID --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - params := devAppAppLocatorParams(cmd) - if len(params) == 0 { - return devAppAppLocatorRequired() + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err } - return runDevAppTool(runner, cmd, "get_webapp_config", params) + params := map[string]any{"unifiedAppId": appID} + return runDevAppTool(runner, cmd, devAppWebappGetTool, params) }, } - addDevAppAppLocatorFlags(cmd) + addDevAppUnifiedIDFlag(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppWebappGetTool) return cmd } @@ -424,83 +632,83 @@ func newDevAppWebappConfigCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "配置网页应用能力", - Example: " dws devapp webapp config --agent-id 123456 --homepage-link https://example.com --dry-run --format json", + Example: " dws dev app webapp config --unified-app-id UNIFIED_APP_ID --homepage-url https://example.com --dry-run --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "webapp config"); err != nil { return err } - params := devAppAppLocatorParams(cmd) - if len(params) == 0 { - return devAppAppLocatorRequired() + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err } + params := map[string]any{"unifiedAppId": appID} updates := 0 if v := devAppStringFlag(cmd, "h5-page-type"); v != "" { params["h5PageType"] = v updates++ } - if v := devAppStringFlag(cmd, "homepage-link"); v != "" { - params["homepageLink"] = v + if v := devAppStringFlag(cmd, "homepage-url"); v != "" { + params["homepageUrl"] = v updates++ } - if v := devAppStringFlag(cmd, "pc-homepage-link"); v != "" { - params["pcHomepageLink"] = v + if v := devAppStringFlag(cmd, "pc-homepage-url"); v != "" { + params["pcHomepageUrl"] = v updates++ } - if v := devAppStringFlag(cmd, "omp-link"); v != "" { - params["ompLink"] = v + if v := devAppStringFlag(cmd, "omp-url"); v != "" { + params["ompUrl"] = v updates++ } if updates == 0 { - return apperrors.NewValidation("at least one webapp field is required: --h5-page-type, --homepage-link, --pc-homepage-link, or --omp-link") + return apperrors.NewValidation("至少提供一项网页应用配置:--h5-page-type、--homepage-url、--pc-homepage-url 或 --omp-url") } - return runDevAppTool(runner, cmd, "set_webapp_config", params) + return runDevAppTool(runner, cmd, devAppWebappSetTool, params) }, } - addDevAppAppLocatorFlags(cmd) + addDevAppUnifiedIDFlag(cmd) cmd.Flags().String("h5-page-type", "", "网页应用生效端/页面类型") - cmd.Flags().String("homepage-link", "", "移动端首页地址") - cmd.Flags().String("pc-homepage-link", "", "PC 端首页地址") - cmd.Flags().String("omp-link", "", "管理后台地址") + cmd.Flags().String("homepage-url", "", "移动端首页地址") + cmd.Flags().String("pc-homepage-url", "", "PC 端首页地址") + cmd.Flags().String("omp-url", "", "管理后台地址") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppWebappSetTool) return cmd } func newDevAppPermissionListCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Aliases: []string{"search", "detail"}, + Aliases: []string{"search"}, Short: "查询开放平台应用权限列表", - Example: " dws devapp permission list --agent-id 123456 --keyword 通讯录 --limit 20 --format json", + Example: " dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword 通讯录 --page-size 20 --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - params := devAppAppLocatorParams(cmd) - if len(params) == 0 { - return devAppAppLocatorRequired() + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err } + params := map[string]any{"unifiedAppId": appID} devAppPutString(params, "keyword", devAppStringFlag(cmd, "keyword")) - devAppPutString(params, "scopeValue", devAppFlagOrFallback(cmd, "scope", "permission")) - devAppPutString(params, "authStatus", strings.ToUpper(devAppStringFlag(cmd, "status"))) - devAppPutString(params, "firstLevelType", strings.ToUpper(devAppStringFlag(cmd, "scope-type"))) + devAppPutString(params, "scopeValue", devAppStringFlag(cmd, "scope-value")) + devAppPutString(params, "authStatus", strings.ToUpper(devAppStringFlag(cmd, "auth-status"))) + devAppPutString(params, "scopeType", strings.ToUpper(devAppStringFlag(cmd, "scope-type"))) devAppPutString(params, "apiStatus", devAppStringFlag(cmd, "api-status")) - devAppPutInt(params, "limit", devAppIntFlag(cmd, "limit")) - devAppPutInt(params, "offset", devAppIntFlag(cmd, "offset")) - return runDevAppTool(runner, cmd, "list_open_dev_app_permissions", params) + devAppApplyCursorParams(cmd, params) + return runDevAppTool(runner, cmd, devAppPermissionListTool, params) }, } - addDevAppAppLocatorFlags(cmd) + addDevAppUnifiedIDFlag(cmd) cmd.Flags().String("keyword", "", "权限名、权限点、接口名关键词") - cmd.Flags().String("scope", "", "精确权限点 scopeValue") - cmd.Flags().String("permission", "", "--scope 的兼容别名") - _ = cmd.Flags().MarkHidden("permission") - cmd.Flags().String("status", "ALL", "权限状态:ALL、AUTHED、UNAUTHED") + cmd.Flags().String("scope-value", "", "精确权限点 scopeValue") + cmd.Flags().String("auth-status", "ALL", "权限状态:ALL、AUTHED、UNAUTHED") cmd.Flags().String("scope-type", "", "权限一级类型:APP 或 SNS") cmd.Flags().String("api-status", "", "开发者后台 apiStatus 过滤") - cmd.Flags().Int("limit", 20, "返回数量上限") - cmd.Flags().Int("offset", 0, "返回偏移量") + registerDevAppCursorFlags(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppPermissionListTool) return cmd } @@ -508,32 +716,30 @@ func newDevAppPermissionAddCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "add", Short: "申请开放平台应用权限点", - Example: " dws devapp permission add --agent-id 123456 --permissions Contact.User.mobile --dry-run --format json", + Example: " dws dev app permission add --unified-app-id UNIFIED_APP_ID --scope-values Contact.User.mobile --dry-run --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "permission add"); err != nil { return err } - params := devAppAppLocatorParams(cmd) - if len(params) == 0 { - return devAppAppLocatorRequired() + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err } + params := map[string]any{"unifiedAppId": appID} scopes := devAppPermissionScopes(cmd) if len(scopes) == 0 { - return apperrors.NewValidation("--permissions is required") + return apperrors.NewValidation("--scope-values 为必填") } params["scopeValues"] = scopes - return runDevAppTool(runner, cmd, "apply_open_dev_app_permissions", params) + return runDevAppTool(runner, cmd, devAppPermissionAddTool, params) }, } - addDevAppAppLocatorFlags(cmd) - cmd.Flags().StringSlice("permissions", nil, "权限点 scopeValue,多个用逗号分隔") - cmd.Flags().String("scope", "", "--permissions 的兼容别名,单个权限点 scopeValue") - cmd.Flags().String("permission", "", "--permissions 的兼容别名,单个权限点 scopeValue") - _ = cmd.Flags().MarkHidden("scope") - _ = cmd.Flags().MarkHidden("permission") + addDevAppUnifiedIDFlag(cmd) + cmd.Flags().String("scope-values", "", "权限点 scopeValue,多个用逗号或分号分隔") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppPermissionAddTool) return cmd } @@ -541,30 +747,30 @@ func newDevAppPermissionRemoveCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "remove", Short: "取消开放平台应用权限点", - Example: " dws devapp permission remove --agent-id 123456 --permission Contact.User.mobile --dry-run --format json", + Example: " dws dev app permission remove --unified-app-id UNIFIED_APP_ID --scope-values Contact.User.mobile,qyapi_robot_sendmsg --dry-run --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "permission remove"); err != nil { return err } - params := devAppAppLocatorParams(cmd) - if len(params) == 0 { - return devAppAppLocatorRequired() + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err } - scope := devAppFlagOrFallback(cmd, "permission", "scope") - if scope == "" { - return apperrors.NewValidation("--permission is required") + params := map[string]any{"unifiedAppId": appID} + scopes := devAppPermissionScopes(cmd) + if len(scopes) == 0 { + return apperrors.NewValidation("--scope-values 为必填") } - params["scopeValue"] = scope - return runDevAppTool(runner, cmd, "remove_open_dev_app_permission", params) + params["scopeValues"] = scopes + return runDevAppTool(runner, cmd, devAppPermissionRmTool, params) }, } - addDevAppAppLocatorFlags(cmd) - cmd.Flags().String("permission", "", "待取消权限点 scopeValue") - cmd.Flags().String("scope", "", "--permission 的兼容别名") - _ = cmd.Flags().MarkHidden("scope") + addDevAppUnifiedIDFlag(cmd) + cmd.Flags().String("scope-values", "", "待取消权限点 scopeValue,多个用逗号或分号分隔") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppPermissionRmTool) return cmd } @@ -572,11 +778,11 @@ func newDevAppMemberListCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "查询开放平台应用成员", - Example: " dws devapp member list --app-id ", + Example: " dws dev app member list --unified-app-id ", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - appID, err := requiredDevAppID(cmd) + appID, err := requiredDevAppUnifiedID(cmd) if err != nil { return err } @@ -585,8 +791,9 @@ func newDevAppMemberListCommand(runner executor.Runner) *cobra.Command { }) }, } - cmd.Flags().String("app-id", "", "开放平台统一应用 ID (必填)") + addDevAppUnifiedIDFlag(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppMemberListTool) return cmd } @@ -594,7 +801,7 @@ func newDevAppMemberAddCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "add", Short: "添加开放平台应用成员", - Example: " dws devapp member add --app-id --users userId1,userId2 --member-type DEVELOPER --dry-run", + Example: " dws dev app member add --unified-app-id --user-ids userId1,userId2 --member-type DEVELOPER --dry-run", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -603,6 +810,7 @@ func newDevAppMemberAddCommand(runner executor.Runner) *cobra.Command { } registerDevAppMemberMutationFlags(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppMemberAddTool) return cmd } @@ -610,7 +818,7 @@ func newDevAppMemberRemoveCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "remove", Short: "移除开放平台应用成员", - Example: " dws devapp member remove --app-id --users userId1,userId2 --member-type DEVELOPER --dry-run", + Example: " dws dev app member remove --unified-app-id --user-ids userId1,userId2 --member-type DEVELOPER --dry-run", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -619,6 +827,7 @@ func newDevAppMemberRemoveCommand(runner executor.Runner) *cobra.Command { } registerDevAppMemberMutationFlags(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppMemberRemoveTool) return cmd } @@ -626,40 +835,45 @@ func newDevAppSecurityConfigCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "更新开放平台应用安全配置", - Example: " dws devapp security config --app-id " + - "--ip-whitelist 192.0.2.10 --redirect-url https://callback.example.invalid/callback --sso-url https://sso.example.invalid/sso --dry-run", + 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", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { if err := devAppRequireWriteGuard(cmd, "security config"); err != nil { return err } - appID, err := requiredDevAppID(cmd) + appID, err := requiredDevAppUnifiedID(cmd) if err != nil { return err } params := map[string]any{"unifiedAppId": appID} + updates := 0 if values := parseDevAppListFlag(cmd, "ip-whitelist"); len(values) > 0 { - params["ipWhiteList"] = values + params["ipWhitelist"] = values + updates++ } - if values := parseDevAppListFlag(cmd, "redirect-url"); len(values) > 0 { + if values := parseDevAppListFlag(cmd, "redirect-urls"); len(values) > 0 { params["redirectUrls"] = values + updates++ } - if values := parseDevAppListFlag(cmd, "sso-url"); len(values) > 0 { - params["otherAuthUrls"] = values + if values := parseDevAppListFlag(cmd, "sso-urls"); len(values) > 0 { + params["ssoUrls"] = values + updates++ } - if len(params) == 1 { - return apperrors.NewValidation("one of --ip-whitelist, --redirect-url, or --sso-url is required") + if updates == 0 { + return apperrors.NewValidation("至少提供一项安全配置:--ip-whitelist、--redirect-urls 或 --sso-urls") } return runDevAppTool(runner, cmd, devAppSecurityConfigTool, params) }, } - cmd.Flags().String("app-id", "", "开放平台统一应用 ID (必填)") - cmd.Flags().String("ip-whitelist", "", "出口 IP 白名单,多个用逗号或分号分隔") - cmd.Flags().String("redirect-url", "", "登录重定向 URL,多个用逗号或分号分隔") - cmd.Flags().String("sso-url", "", "端内免登地址,多个用逗号或分号分隔") + addDevAppUnifiedIDFlag(cmd) + cmd.Flags().String("ip-whitelist", "", "出口 IP 白名单,多个用逗号或分号分隔(整组覆盖,非追加)") + cmd.Flags().String("redirect-urls", "", "登录重定向 URL,多个用逗号或分号分隔(整组覆盖,非追加)") + cmd.Flags().String("sso-urls", "", "端内免登地址,多个用逗号或分号分隔(整组覆盖,非追加)") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppSecurityConfigTool) return cmd } @@ -667,34 +881,11 @@ func newDevAppSecurityConfigCommand(runner executor.Runner) *cobra.Command { // 机器人能力 // --------------------------------------------------------------------------- -func newDevAppRobotCreateCommand(runner executor.Runner) *cobra.Command { - cmd := &cobra.Command{ - Use: "create", - Short: "新建钉钉智能体机器人(提交创建任务并等待完成)", - Example: " dws devapp robot create --app-name 我的智能体 --robot-name 小助手 --desc \"处理审批问答\" --dry-run --format json", - Args: cobra.NoArgs, - DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { - if err := devAppRequireWriteGuard(cmd, "robot create"); err != nil { - return err - } - params, err := devAppRobotCreateParams(cmd) - if err != nil { - return err - } - return runDevAppRobotCreate(runner, cmd, params) - }, - } - registerDevAppRobotCreateFlags(cmd) - preferLegacyLeaf(cmd) - return cmd -} - func newDevAppRobotSubmitCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "submit", Short: "异步提交钉钉智能体机器人创建任务(支持失败重试)", - Example: " dws devapp robot submit --app-name 我的智能体 --robot-name 小助手 --desc \"处理审批问答\" --dry-run --format json", + Example: " dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc \"处理审批问答\" --dry-run --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -705,10 +896,10 @@ func newDevAppRobotSubmitCommand(runner executor.Runner) *cobra.Command { if err != nil { return err } - // submit_robot_create_task 的 schema 把图标字段标为必填(空值时服务端用默认图标), + // submit_robot_create 的 schema 把图标字段标为必填(空值时服务端用默认图标), // 因此即使用户未提供也补空串占位。 - if _, ok := params["robotMediaId"]; !ok { - params["robotMediaId"] = "" + if _, ok := params["iconMediaId"]; !ok { + params["iconMediaId"] = "" } if _, ok := params["previewMediaId"]; !ok { params["previewMediaId"] = "" @@ -720,6 +911,7 @@ func newDevAppRobotSubmitCommand(runner executor.Runner) *cobra.Command { registerDevAppRobotCreateFlags(cmd) cmd.Flags().String("task-id", "", "失败重试时传入原 taskId;为空时服务端自动生成") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppRobotSubmitTool) return cmd } @@ -727,19 +919,20 @@ func newDevAppRobotResultCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "result", Short: "查询机器人异步创建任务结果", - Example: " dws devapp robot result --task-id TASK_ID --format json", + Example: " dws dev app robot result --task-id TASK_ID --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { taskID := devAppStringFlag(cmd, "task-id") if taskID == "" { - return apperrors.NewValidation("--task-id is required") + return apperrors.NewValidation("--task-id 为必填") } return runDevAppTool(runner, cmd, devAppRobotResultTool, map[string]any{"taskId": taskID}) }, } cmd.Flags().String("task-id", "", "提交创建任务时返回的 taskId (必填)") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppRobotResultTool) return cmd } @@ -747,7 +940,7 @@ func newDevAppRobotConfigGetCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "get", Short: "查询现有应用的机器人配置", - Example: " dws devapp robot get --unified-app-id UNIFIED_APP_ID --format json", + Example: " dws dev app robot get --unified-app-id UNIFIED_APP_ID --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -760,18 +953,24 @@ func newDevAppRobotConfigGetCommand(runner executor.Runner) *cobra.Command { } addDevAppUnifiedIDFlag(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppRobotConfigGetTool) return cmd } -func newDevAppRobotConfigCommand(runner executor.Runner, use, short, tool string) *cobra.Command { +// 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. +func newDevAppRobotConfigCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ - Use: use, - Short: short, - Example: " dws devapp robot " + use + " --unified-app-id UNIFIED_APP_ID --name 小助手 --brief 审批助手 --dry-run --format json", + Use: "config", + Short: "创建或更新现有应用的机器人配置(upsert)", + Example: " dws dev app robot config --unified-app-id UNIFIED_APP_ID --name 小助手 --brief 审批助手 --dry-run --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - if err := devAppRequireWriteGuard(cmd, "robot "+use); err != nil { + if err := devAppRequireWriteGuard(cmd, "robot config"); err != nil { return err } appID, err := requiredDevAppUnifiedID(cmd) @@ -783,26 +982,53 @@ func newDevAppRobotConfigCommand(runner executor.Runner, use, short, tool string return err } if updates == 0 { - return apperrors.NewValidation("at least one robot config field is required, e.g. --name, --brief, --description, --icon, --outgoing-url, --event-url, --mode, --skills") + return apperrors.NewValidation("至少提供一项机器人配置字段,如 --name、--brief、--desc、--icon-media-id、--outgoing-url、--event-callback-url、--mode、--skills") } - return runDevAppTool(runner, cmd, tool, params) + return runDevAppTool(runner, cmd, devAppRobotConfigUpsertTool, params) }, } addDevAppUnifiedIDFlag(cmd) registerDevAppRobotConfigFlags(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppRobotConfigUpsertTool) + return cmd +} + +// 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 { + cmd := &cobra.Command{ + Use: "enable", + Short: "启用现有应用机器人能力(纯启用,无需配置字段)", + Example: " dws dev app robot enable --unified-app-id UNIFIED_APP_ID --dry-run --format json", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := devAppRequireWriteGuard(cmd, "robot enable"); err != nil { + return err + } + appID, err := requiredDevAppUnifiedID(cmd) + if err != nil { + return err + } + return runDevAppTool(runner, cmd, devAppRobotEnableTool, map[string]any{"unifiedAppId": appID}) + }, + } + addDevAppUnifiedIDFlag(cmd) + preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppRobotEnableTool) return cmd } func newDevAppRobotOfflineCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ - Use: "offline", + Use: "disable", Short: "停用现有应用的机器人能力", - Example: " dws devapp robot offline --unified-app-id UNIFIED_APP_ID --dry-run --format json", + Example: " dws dev app robot disable --unified-app-id UNIFIED_APP_ID --dry-run --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - if err := devAppRequireWriteGuard(cmd, "robot offline"); err != nil { + if err := devAppRequireWriteGuard(cmd, "robot disable"); err != nil { return err } appID, err := requiredDevAppUnifiedID(cmd) @@ -814,150 +1040,50 @@ func newDevAppRobotOfflineCommand(runner executor.Runner) *cobra.Command { } addDevAppUnifiedIDFlag(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppRobotOfflineTool) return cmd } func registerDevAppRobotCreateFlags(cmd *cobra.Command) { - cmd.Flags().String("app-name", "", "智能体应用名称,长度 2-20,企业内唯一 (必填)") + cmd.Flags().String("name", "", "智能体应用名称,长度 2-20,企业内唯一 (必填)") cmd.Flags().String("robot-name", "", "承载机器人名称,用于客户端展示 (必填)") cmd.Flags().String("desc", "", "机器人功能描述,不超过 200 字 (必填)") - cmd.Flags().String("icon", "", "机器人图标 mediaId;为空时使用默认图标") - cmd.Flags().String("preview", "", "机器人预览图 mediaId;为空时复用图标") + cmd.Flags().String("icon-media-id", "", "机器人图标 mediaId;为空时使用默认图标") + cmd.Flags().String("preview-media-id", "", "机器人预览图 mediaId;为空时复用图标") } func devAppRobotCreateParams(cmd *cobra.Command) (map[string]any, error) { - appName := devAppStringFlag(cmd, "app-name") - if appName == "" { - return nil, apperrors.NewValidation("--app-name is required") + name := devAppStringFlag(cmd, "name") + if name == "" { + return nil, apperrors.NewValidation("--name 为必填") } robotName := devAppStringFlag(cmd, "robot-name") if robotName == "" { - return nil, apperrors.NewValidation("--robot-name is required") + return nil, apperrors.NewValidation("--robot-name 为必填") } desc := devAppStringFlag(cmd, "desc") if desc == "" { - return nil, apperrors.NewValidation("--desc is required") + return nil, apperrors.NewValidation("--desc 为必填") } params := map[string]any{ - "appName": appName, + "name": name, "robotName": robotName, "desc": desc, } - devAppPutString(params, "robotMediaId", devAppStringFlag(cmd, "icon")) - devAppPutString(params, "previewMediaId", devAppStringFlag(cmd, "preview")) + devAppPutString(params, "iconMediaId", devAppStringFlag(cmd, "icon-media-id")) + devAppPutString(params, "previewMediaId", devAppStringFlag(cmd, "preview-media-id")) return params, nil } -// runDevAppRobotCreate 实现 `dws devapp robot create`。 -// -// 同步工具 create_dingtalk_robot 的服务端动作模板已失效(issue #35),因此这里 -// 改走异步路径:submit_robot_create_task 拿 taskId,再按服务端给的 interval 轮询 -// query_robot_create_result 直到任务进入终态,最后输出与同步接口一致的结果 -// (agentId/clientId/robotCode/clientSecret)。用户感知仍是“一条命令建出机器人”。 -func runDevAppRobotCreate(runner executor.Runner, cmd *cobra.Command, params map[string]any) error { - // submit_robot_create_task 的 schema 把图标字段标为必填(空值时服务端用默认图标), - // 即使用户未提供也补空串占位。 - if _, ok := params["robotMediaId"]; !ok { - params["robotMediaId"] = "" - } - if _, ok := params["previewMediaId"]; !ok { - params["previewMediaId"] = "" - } - - // dry-run 直接预览将要提交的 submit 调用(这正是真实会发生的事)。 - if commandDryRun(cmd) { - return runDevAppTool(runner, cmd, devAppRobotSubmitTool, params) - } - - legacyPath := cobracmd.LegacyCommandPath(cmd) - submitInv := executor.NewHelperInvocation(legacyPath, devAppProduct, devAppRobotSubmitTool, params) - submitRes, err := runner.Run(cmd.Context(), submitInv) - if err != nil { - return err - } - - taskID := devAppRobotResultString(submitRes, "taskId") - if taskID == "" { - // 拿不到 taskId 说明服务端返回结构异常,原样回吐 submit 结果由用户判断。 - return writeCommandPayload(cmd, submitRes) - } - - last := submitRes - interval := devAppRobotResultInterval(submitRes) - const maxAttempts = 24 // 配合 interval(默认 5s)约 2 分钟内收敛 - for attempt := 0; attempt < maxAttempts; attempt++ { - if !devAppRobotStatusPending(devAppRobotResultString(last, "status")) { - break - } - select { - case <-cmd.Context().Done(): - return cmd.Context().Err() - case <-time.After(interval): - } - pollInv := executor.NewHelperInvocation(legacyPath, devAppProduct, devAppRobotResultTool, map[string]any{"taskId": taskID}) - pollRes, pollErr := runner.Run(cmd.Context(), pollInv) - if pollErr != nil { - return pollErr - } - last = pollRes - if next := devAppRobotResultInterval(pollRes); next > 0 { - interval = next - } - } - return writeCommandPayload(cmd, last) -} - -// devAppRobotResultBody 取出工具返回里的业务 result 对象 -// (形如 {"errorCode":null,"result":{...},"success":true} 中的 result)。 -func devAppRobotResultBody(res executor.Result) map[string]any { - content, ok := res.Response["content"].(map[string]any) - if !ok { - return nil - } - body, _ := content["result"].(map[string]any) - return body -} - -func devAppRobotResultString(res executor.Result, key string) string { - body := devAppRobotResultBody(res) - if body == nil { - return "" - } - value, _ := body[key].(string) - return strings.TrimSpace(value) -} - -// devAppRobotResultInterval 读取服务端建议的轮询间隔(秒),缺省 5 秒。 -func devAppRobotResultInterval(res executor.Result) time.Duration { - body := devAppRobotResultBody(res) - if body != nil { - if raw, ok := body["interval"].(float64); ok && raw > 0 { - return time.Duration(raw * float64(time.Second)) - } - } - return 5 * time.Second -} - -// devAppRobotStatusPending 判断创建任务是否仍在进行中(需要继续轮询)。 -// 已知终态:SUCCESS / APPROVAL_REQUIRED / FAILED / ERROR 等;未知或空状态保守按终态处理,避免死等。 -func devAppRobotStatusPending(status string) bool { - switch strings.ToUpper(strings.TrimSpace(status)) { - case "WAITING", "PROCESSING", "RUNNING", "PENDING", "INIT", "DOING": - return true - default: - return false - } -} - func registerDevAppRobotConfigFlags(cmd *cobra.Command) { cmd.Flags().String("name", "", "机器人名称") cmd.Flags().String("brief", "", "机器人简介") - cmd.Flags().String("description", "", "机器人描述") - cmd.Flags().String("icon", "", "机器人图标 mediaId") - cmd.Flags().String("outgoing-url", "", "消息回调地址 (outgoingUrl)") - cmd.Flags().String("event-url", "", "事件回调地址 (chatBotEventUrl)") - cmd.Flags().Int("mode", 0, "机器人模式枚举") - cmd.Flags().StringSlice("skills", nil, "技能列表,多个用逗号分隔") + cmd.Flags().String("desc", "", "机器人描述") + cmd.Flags().String("icon-media-id", "", "机器人图标 mediaId") + cmd.Flags().String("outgoing-url", "", "消息回调地址") + cmd.Flags().String("event-callback-url", "", "事件回调地址") + cmd.Flags().String("mode", "", "机器人模式:HTTPS / STREAM / AISKILL") + cmd.Flags().String("skills", "", "技能列表,多个用逗号或分号分隔") cmd.Flags().Bool("add-scope", false, "是否自动添加机器人相关权限") cmd.Flags().Bool("disable-ssl-verify", false, "回调地址是否关闭 SSL 校验") cmd.Flags().String("i18n-name", "", "机器人名称国际化 JSON,如 '{\"en_US\":\"Bot\"}'") @@ -976,17 +1102,23 @@ func devAppRobotConfigParams(cmd *cobra.Command, appID string) (map[string]any, } setString("name", "name") setString("brief", "brief") - setString("description", "description") - setString("iconMediaId", "icon") + setString("desc", "desc") + setString("iconMediaId", "icon-media-id") setString("outgoingUrl", "outgoing-url") - setString("chatBotEventUrl", "event-url") + setString("eventCallbackUrl", "event-callback-url") if cmd.Flags().Changed("mode") { - params["mode"] = devAppIntFlag(cmd, "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["isAddScope"] = value + params["addScope"] = value updates++ } if cmd.Flags().Changed("disable-ssl-verify") { @@ -994,8 +1126,8 @@ func devAppRobotConfigParams(cmd *cobra.Command, appID string) (map[string]any, params["disableSSLVerify"] = value updates++ } - if values, _ := cmd.Flags().GetStringSlice("skills"); len(values) > 0 { - params["skillList"] = values + if values := parseDevAppListFlag(cmd, "skills"); len(values) > 0 { + params["skills"] = values updates++ } for _, item := range []struct{ key, flag string }{ @@ -1009,7 +1141,7 @@ func devAppRobotConfigParams(cmd *cobra.Command, appID string) (map[string]any, } parsed := map[string]any{} if err := json.Unmarshal([]byte(raw), &parsed); err != nil { - return nil, 0, apperrors.NewValidation(fmt.Sprintf("--%s must be valid JSON object: %v", item.flag, err)) + return nil, 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是合法 JSON 对象:%v", item.flag, err)) } params[item.key] = parsed updates++ @@ -1025,7 +1157,7 @@ func newDevAppVersionCreateCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "基于当前配置创建应用新版本", - Example: " dws devapp version create --unified-app-id UNIFIED_APP_ID --version 1.0.1 --desc \"新增机器人能力\" --dry-run --format json", + Example: " dws dev app version create --unified-app-id UNIFIED_APP_ID --desc \"新增机器人能力\" --dry-run --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -1038,14 +1170,15 @@ func newDevAppVersionCreateCommand(runner executor.Runner) *cobra.Command { } params := map[string]any{"unifiedAppId": appID} devAppPutString(params, "version", devAppStringFlag(cmd, "version")) - devAppPutString(params, "description", devAppStringFlag(cmd, "desc")) + devAppPutString(params, "desc", devAppStringFlag(cmd, "desc")) return runDevAppTool(runner, cmd, devAppVersionCreateTool, params) }, } addDevAppUnifiedIDFlag(cmd) - cmd.Flags().String("version", "", "版本号,如 1.0.1") + cmd.Flags().String("version", "", "高级可选:显式版本号,如 1.0.1;默认不传,由服务端基于最新已发布版本自动递增") cmd.Flags().String("desc", "", "版本描述") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppVersionCreateTool) return cmd } @@ -1053,7 +1186,7 @@ func newDevAppVersionListCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "分页查询应用版本列表", - Example: " dws devapp version list --unified-app-id UNIFIED_APP_ID --page 1 --page-size 20 --format json", + Example: " dws dev app version list --unified-app-id UNIFIED_APP_ID --page-size 20 --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -1061,18 +1194,15 @@ func newDevAppVersionListCommand(runner executor.Runner) *cobra.Command { if err != nil { return err } - params := map[string]any{ - "unifiedAppId": appID, - "currentPage": devAppIntFlag(cmd, "page"), - "pageSize": devAppIntFlag(cmd, "page-size"), - } + params := map[string]any{"unifiedAppId": appID} + devAppApplyCursorParams(cmd, params) return runDevAppTool(runner, cmd, devAppVersionListTool, params) }, } addDevAppUnifiedIDFlag(cmd) - cmd.Flags().Int("page", 1, "分页页码,从 1 开始") - cmd.Flags().Int("page-size", 20, "分页大小") + registerDevAppCursorFlags(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppVersionListTool) return cmd } @@ -1080,7 +1210,7 @@ func newDevAppVersionGetCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "get", Short: "查询指定版本详情", - Example: " dws devapp version get --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", + Example: " dws dev app version get --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -1093,6 +1223,7 @@ func newDevAppVersionGetCommand(runner executor.Runner) *cobra.Command { } addDevAppVersionLocatorFlags(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppVersionDetailTool) return cmd } @@ -1100,7 +1231,7 @@ func newDevAppVersionCheckApprovalCommand(runner executor.Runner) *cobra.Command cmd := &cobra.Command{ Use: "check-approval", Short: "预检版本发布是否需要审批(不实际发布)", - Example: " dws devapp version check-approval --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", + Example: " dws dev app version check-approval --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -1108,21 +1239,24 @@ func newDevAppVersionCheckApprovalCommand(runner executor.Runner) *cobra.Command if err != nil { return err } - // 复用 publish 工具的服务端预检模式:dryRun=true 只返回审批要求,不发布。 - params["dryRun"] = true + // 复用 publish 工具的服务端预检模式:precheckOnly=true 只返回审批 + // 要求,不发布。原上游参数名 dryRun 与 CLI --dry-run 撞名,已改名 + // precheckOnly(见 docs/upstream-todo.md,需上游同步改)。 + params["precheckOnly"] = true return runDevAppTool(runner, cmd, devAppVersionPublishTool, params) }, } addDevAppVersionLocatorFlags(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppVersionPublishTool) return cmd } func newDevAppVersionPublishCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "publish", - Short: "发布指定版本(含高敏权限需 --confirm-sensitive)", - Example: " dws devapp version publish --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --dry-run --format json", + Short: "发布指定版本(含高敏权限需 --confirmed-sensitive)", + Example: " dws dev app version publish --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --dry-run --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -1133,19 +1267,24 @@ func newDevAppVersionPublishCommand(runner executor.Runner) *cobra.Command { if err != nil { return err } - params["dryRun"] = false - if cmd.Flags().Changed("confirm-sensitive") { - value, _ := cmd.Flags().GetBool("confirm-sensitive") + // precheckOnly=false 表示真发布。命名故意避开 CLI 的 --dry-run: + // 上游接口原参数名是 dryRun,与 CLI 全局 --dry-run 撞名,会让读 + // params 的 agent 误判(publish --dry-run 时回显 dryRun:false)。 + // 见 docs/upstream-todo.md:上游需把入参 dryRun 改名 precheckOnly。 + params["precheckOnly"] = false + if cmd.Flags().Changed("confirmed-sensitive") { + value, _ := cmd.Flags().GetBool("confirmed-sensitive") params["confirmedSensitive"] = value } - devAppPutString(params, "approverUserId", devAppStringFlag(cmd, "approver")) + devAppPutString(params, "approverUserId", devAppStringFlag(cmd, "approver-user-id")) return runDevAppTool(runner, cmd, devAppVersionPublishTool, params) }, } addDevAppVersionLocatorFlags(cmd) - cmd.Flags().Bool("confirm-sensitive", false, "确认发布包含高敏权限的版本") - cmd.Flags().String("approver", "", "灰度选人模式下指定审批人 userId") + cmd.Flags().Bool("confirmed-sensitive", false, "确认发布包含高敏权限的版本") + cmd.Flags().String("approver-user-id", "", "灰度选人模式下指定审批人 userId") preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppVersionPublishTool) return cmd } @@ -1153,7 +1292,7 @@ func newDevAppVersionStatusCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "status", Short: "查询版本发布/审批状态", - Example: " dws devapp version status --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", + Example: " dws dev app version status --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --format json", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -1166,6 +1305,7 @@ func newDevAppVersionStatusCommand(runner executor.Runner) *cobra.Command { } addDevAppVersionLocatorFlags(cmd) preferLegacyLeaf(cmd) + annotateDevAppTool(cmd, devAppVersionStatusTool) return cmd } @@ -1181,28 +1321,31 @@ func devAppVersionLocator(cmd *cobra.Command) (map[string]any, error) { } versionID := devAppStringFlag(cmd, "version-id") if versionID == "" { - return nil, apperrors.NewValidation("--version-id is required") + return nil, apperrors.NewValidation("--version-id 为必填") } return map[string]any{"unifiedAppId": appID, "versionId": versionID}, nil } +// addDevAppUnifiedIDFlag registers the canonical app locator. --unified-app-id +// is the single app identifier across the whole dev app tree (agent-id/app-id/ +// custom-key locators were intentionally removed). func addDevAppUnifiedIDFlag(cmd *cobra.Command) { - cmd.Flags().String("unified-app-id", "", "统一应用 ID (必填)") - cmd.Flags().String("app-id", "", "--unified-app-id 的兼容别名") - _ = cmd.Flags().MarkHidden("app-id") + cmd.Flags().String("unified-app-id", "", "开放平台统一应用 ID(必填)") } func requiredDevAppUnifiedID(cmd *cobra.Command) (string, error) { - appID := devAppFlagOrFallback(cmd, "unified-app-id", "app-id") + appID := devAppStringFlag(cmd, "unified-app-id") if appID == "" { - return "", apperrors.NewValidation("--unified-app-id is required") + return "", apperrors.NewValidation("--unified-app-id 为必填") } return appID, nil } func registerDevAppMemberMutationFlags(cmd *cobra.Command) { - cmd.Flags().String("app-id", "", "开放平台统一应用 ID (必填)") - cmd.Flags().String("users", "", "成员 userId 列表,多个用逗号分隔 (必填)") + addDevAppUnifiedIDFlag(cmd) + cmd.Flags().String("user-ids", "", "成员 userId 列表,多个用逗号分隔 (必填)") + cmd.Flags().String("member-user-ids", "", "成员 userId 列表,多个用逗号分隔 (兼容旧参数)") + _ = cmd.Flags().MarkHidden("member-user-ids") cmd.Flags().String("member-type", "", "成员类型,如 DEVELOPER (必填)") } @@ -1210,7 +1353,7 @@ func runDevAppMemberMutation(runner executor.Runner, cmd *cobra.Command, tool, o if err := devAppRequireWriteGuard(cmd, operation); err != nil { return err } - appID, err := requiredDevAppID(cmd) + appID, err := requiredDevAppUnifiedID(cmd) if err != nil { return err } @@ -1224,13 +1367,26 @@ func runDevAppMemberMutation(runner executor.Runner, cmd *cobra.Command, tool, o } params := map[string]any{ - "unifiedAppId": appID, - "memberUserIds": users, - "memberType": memberType, + "unifiedAppId": appID, + "userIds": users, + "memberType": memberType, } return runDevAppTool(runner, cmd, tool, params) } +// 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 +} + func runDevAppTool(runner executor.Runner, cmd *cobra.Command, tool string, params map[string]any) error { invocation := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), @@ -1243,26 +1399,95 @@ func runDevAppTool(runner executor.Runner, cmd *cobra.Command, tool string, para if err != nil { return err } + // 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 writeCommandPayload(cmd, result) } -func requiredDevAppID(cmd *cobra.Command) (string, error) { - appID, _ := cmd.Flags().GetString("app-id") - appID = strings.TrimSpace(appID) - if appID == "" { - return "", apperrors.NewValidation("--app-id is required") +// 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, and stamp an +// explicit disabled/enabled boolean on the lifecycle tools whose servers omit it. +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 + } + } + return result +} + +// 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 } - return appID, nil } func requiredDevAppUsers(cmd *cobra.Command) ([]string, error) { - usersRaw, _ := cmd.Flags().GetString("users") + usersRaw, _ := cmd.Flags().GetString("user-ids") if strings.TrimSpace(usersRaw) == "" { - return nil, apperrors.NewValidation("--users is required") + usersRaw, _ = cmd.Flags().GetString("member-user-ids") + } + if strings.TrimSpace(usersRaw) == "" { + return nil, apperrors.NewValidation("--user-ids 为必填") } users := splitDevAppList(usersRaw) if len(users) == 0 { - return nil, apperrors.NewValidation("--users must contain at least one userId") + return nil, apperrors.NewValidation("--user-ids 至少包含一个 userId") } return users, nil } @@ -1271,7 +1496,7 @@ func requiredDevAppMemberType(cmd *cobra.Command) (string, error) { memberType, _ := cmd.Flags().GetString("member-type") memberType = strings.TrimSpace(memberType) if memberType == "" { - return "", apperrors.NewValidation("--member-type is required") + return "", apperrors.NewValidation("--member-type 为必填") } return memberType, nil } @@ -1281,6 +1506,14 @@ func parseDevAppListFlag(cmd *cobra.Command, name string) []string { return splitDevAppList(raw) } +func requiredDevAppEventCodes(cmd *cobra.Command) ([]string, error) { + eventCodes := parseDevAppListFlag(cmd, "event-codes") + if len(eventCodes) == 0 { + return nil, apperrors.NewValidation("--event-codes 为必填") + } + return eventCodes, nil +} + func splitDevAppList(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { @@ -1297,57 +1530,15 @@ func splitDevAppList(raw string) []string { return values } -func addDevAppLocatorFlags(cmd *cobra.Command) { - addDevAppLocatorFlagsWithoutName(cmd) - cmd.Flags().String("name", "", "应用名称关键词;写操作前必须唯一命中") -} - -func addDevAppLocatorFlagsWithoutName(cmd *cobra.Command) { - addDevAppAppLocatorFlags(cmd) - cmd.Flags().Int("app-id", 0, "兼容 appId") - cmd.Flags().String("app-key", "", "appKey/clientId") - cmd.Flags().String("custom-key", "", "customKey") -} - -func addDevAppAppLocatorFlags(cmd *cobra.Command) { - cmd.Flags().String("unified-app-id", "", "统一应用 ID") - cmd.Flags().Int("agent-id", 0, "应用 agentId") -} - -func devAppLocatorParams(cmd *cobra.Command, includeName bool) map[string]any { - params := devAppAppLocatorParams(cmd) - devAppPutInt(params, "appId", devAppIntFlag(cmd, "app-id")) - devAppPutString(params, "appKey", devAppStringFlag(cmd, "app-key")) - devAppPutString(params, "customKey", devAppStringFlag(cmd, "custom-key")) - if includeName { - devAppPutString(params, "appName", devAppStringFlag(cmd, "name")) - } - return params -} - -func devAppAppLocatorParams(cmd *cobra.Command) map[string]any { - params := map[string]any{} - devAppPutString(params, "unifiedAppId", devAppStringFlag(cmd, "unified-app-id")) - devAppPutInt(params, "agentId", devAppIntFlag(cmd, "agent-id")) - return params -} - -func devAppLocatorRequired(includeName bool) error { - if includeName { - return apperrors.NewValidation("one app locator is required: --agent-id, --unified-app-id, --app-key, --custom-key, --app-id, or --name") - } - return apperrors.NewValidation("one app locator is required: --agent-id, --unified-app-id, --app-key, --custom-key, or --app-id") -} - -func devAppAppLocatorRequired() error { - return apperrors.NewValidation("one app locator is required: --agent-id or --unified-app-id") -} +// 应用定位统一只用 --unified-app-id(--app-key / --name 定位已下线,列表搜索的 +// --name/--app-key 是过滤参数、不在此列)。所有 app 作用域命令共用 addDevAppUnifiedIDFlag +// + requiredDevAppUnifiedID。 func devAppRequireWriteGuard(cmd *cobra.Command, operation string) error { if commandDryRun(cmd) || devAppYes(cmd) { return nil } - return apperrors.NewValidation(fmt.Sprintf("%s is a write operation; rerun with --dry-run to preview or --yes after confirmation", operation)) + return apperrors.NewValidation(fmt.Sprintf("%s 是写操作;加 --dry-run 预览,或确认后加 --yes 执行", operation)) } func devAppYes(cmd *cobra.Command) bool { @@ -1362,9 +1553,16 @@ func devAppYes(cmd *cobra.Command) bool { return false } +func devAppCloneParams(src map[string]any) map[string]any { + out := make(map[string]any, len(src)+1) + for k, v := range src { + out[k] = v + } + return out +} + func devAppPermissionScopes(cmd *cobra.Command) []string { - values, _ := cmd.Flags().GetStringSlice("permissions") - values = append(values, devAppFlagOrFallback(cmd, "scope", "permission")) + values := parseDevAppListFlag(cmd, "scope-values") out := make([]string, 0, len(values)) for _, value := range values { for _, part := range splitDevAppList(value) { diff --git a/internal/helpers/devapp_connect.go b/internal/helpers/devapp_connect.go index ad493635..e54e7755 100644 --- a/internal/helpers/devapp_connect.go +++ b/internal/helpers/devapp_connect.go @@ -28,8 +28,8 @@ import ( ) // devapp_connect wires the channel-aware "建联" (linking) capability into the -// devapp command tree as `dws devapp robot connect`. Provisioning (建号) is NOT -// duplicated here — it reuses devapp's existing `robot create/submit/result`. +// dev command tree as `dws dev connect`. Provisioning (建号) is NOT +// duplicated here — it reuses the existing `dev app robot create/submit/result`. // This file holds only the linking half: detect the current agent channel, then // start a Go-native in-process Stream that forwards @-bot messages to the local // agent CLI. The Stream forwarder internals live in connect_stream.go. @@ -376,24 +376,35 @@ func officialChannelGuidance(channel string) string { return "" } -// newDevAppRobotConnectCommand implements `dws devapp robot connect`: the linking +// connectPreviewEnvelope wraps a connect dry-run preview in an envelope that +// mirrors the app-tree helper_invocation shape (kind + dry_run at a known top +// level), so an agent can parse "is this a dry-run preview" the same way across +// all dev commands. The connect-specific fields (channel/cli/connect/...) sit +// inside, since connect is a linking pre-check, not an MCP tool call. +func connectPreviewEnvelope(fields map[string]any) map[string]any { + fields["kind"] = "connect_preview" + fields["dry_run"] = true + return map[string]any{"invocation": fields} +} + +// newDevAppRobotConnectCommand implements `dws dev connect`: the linking // half of provisioning. It takes an existing robot's credentials — either // directly via --client-id/--client-secret, or by --unified-app-id (reusing -// devapp's `get_open_dev_app_credentials` to fetch them) — and starts the +// devapp 的 `get_dev_app_credentials` to fetch them) — and starts the // channel-aware Stream connector in the foreground. It never provisions a robot; -// for 建号 run `dws devapp robot create` (or submit/result) first. +// for 建号 run `dws dev app robot create` (or submit/result) first. func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ Use: "connect", Short: "建联:把现成机器人接到当前本地 agent(起 Stream,不建号)", Long: "用已建好的机器人凭证把它接到当前本地 agent,不做建号。\n" + - "凭证两种来源:① 直接传 --robot-client-id/--robot-client-secret;② 传 --unified-app-id(复用 devapp credentials get 自动取凭证)。\n" + + "凭证两种来源:① 直接传 --robot-client-id/--robot-client-secret;② 传 --unified-app-id(复用 dev app credentials get 自动取凭证)。\n" + "渠道由 --channel 显式指定,或运行时信号自动探测。\n" + - "缺凭证请先用 `dws devapp robot create` 建号拿 clientId/clientSecret。", - Example: " dws devapp robot connect --channel workbuddy --robot-client-id --robot-client-secret \n" + - " dws devapp robot connect --unified-app-id --channel qoderwork\n" + - " dws devapp robot connect --channel claudecode --robot-client-id --robot-client-secret \n" + - " dws devapp robot connect --agent-cmd \"lobster -p\" --robot-client-id --robot-client-secret # 自研/未支持的 AI 工具", + "缺凭证请先用 `dws dev app robot submit` 建号(随后 `robot result` 轮询)拿 clientId/clientSecret。", + Example: " dws dev connect --channel workbuddy --robot-client-id --robot-client-secret \n" + + " dws dev connect --unified-app-id --channel qoderwork\n" + + " dws dev connect --channel claudecode --robot-client-id --robot-client-secret \n" + + " dws dev connect --agent-cmd \"lobster -p\" --robot-client-id --robot-client-secret # 自研/未支持的 AI 工具", Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -430,25 +441,24 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { unifiedAppID := devAppStringFlag(cmd, "unified-app-id") opts := connectAgentOptionsFromCommand(cmd) - // Credential resolution: explicit pair wins; otherwise reuse devapp's + // Credential resolution: explicit pair wins; otherwise reuse dev app's // credentials get against --unified-app-id. resolvedBy := "flag:--robot-client-id/--robot-client-secret" if clientID == "" || clientSecret == "" { if unifiedAppID == "" { - return apperrors.NewValidation("需要 --robot-client-id/--robot-client-secret(用现成机器人凭证),或 --unified-app-id(复用 devapp credentials get 自动取凭证)") + return apperrors.NewValidation("需要 --robot-client-id/--robot-client-secret(用现成机器人凭证),或 --unified-app-id(复用 dev app credentials get 自动取凭证)") } if commandDryRun(cmd) { // Dry-run must not call the credentials tool; just preview routing. - return writeCommandPayload(cmd, map[string]any{ + return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{ "channel": channel, "detectedBy": detectedBy, - "dryRun": true, "credentialSource": "unified-app-id (credentials get, skipped in dry-run)", "unifiedAppId": unifiedAppID, "agent": connectAgentOptionsPayload(channel, opts), "cli": connectCliStatus(channel), "connect": buildConnectPlan(channel, "", ""), - }) + })) } id, secret, err := devAppFetchCredentials(runner, cmd, unifiedAppID) if err != nil { @@ -462,16 +472,15 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { } if commandDryRun(cmd) { - return writeCommandPayload(cmd, map[string]any{ + return writeCommandPayload(cmd, connectPreviewEnvelope(map[string]any{ "channel": channel, "detectedBy": detectedBy, - "dryRun": true, "credentialSource": resolvedBy, "clientId": clientID, "agent": connectAgentOptionsPayload(channel, opts), "cli": connectCliStatus(channel), "connect": buildConnectPlan(channel, clientID, ""), - }) + })) } // --daemon: detach into a background supervisor that keeps the @@ -505,7 +514,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { // 目标机器人凭证,与 OAuth 客户端是两码事,故独立命名避免撞名。 cmd.Flags().String("robot-client-id", "", "现成机器人 clientId(AppKey)") cmd.Flags().String("robot-client-secret", "", "现成机器人 clientSecret(AppSecret)") - cmd.Flags().String("unified-app-id", "", "统一应用 ID:复用 devapp credentials get 自动取凭证(替代手填 robot-client-id/secret)") + cmd.Flags().String("unified-app-id", "", "统一应用 ID:复用 dev app credentials get 自动取凭证(替代手填 robot-client-id/secret)") cmd.Flags().String("agent-model", "", "覆盖本地 agent 模型(如 claude 的 sonnet/opus;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL") cmd.Flags().String("agent-workdir", "", "本地 agent 的运行目录(放知识文件可给机器人上下文;默认空白临时目录,求快);env: DWS_AGENT_WORKDIR") cmd.Flags().Bool("agent-memory", true, "按会话续聊:同一群/单聊共享 agent 会话上下文(claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)") @@ -651,14 +660,14 @@ func connectAgentOptionsPayload(channel string, opts connectAgentOptions) map[st return map[string]any{"model": model, "workdir": workDir, "memory": memory, "replyStyle": replyStyle} } -// devAppFetchCredentials reuses devapp's get_open_dev_app_credentials tool to +// devAppFetchCredentials reuses dev app 的 get_dev_app_credentials tool to // resolve a unified app's clientId/clientSecret, so `robot connect // --unified-app-id` need not have the caller paste raw credentials. Note the // open platform only returns clientSecret once (at provisioning); if the tool // omits it the caller is told to fall back to explicit flags. // // TODO(verify): the clientId/clientSecret (and appKey/appSecret fallback) field -// names below are NOT yet confirmed against the real get_open_dev_app_credentials +// names below are NOT yet confirmed against the real get_dev_app_credentials // response — no fixture exists in-repo. Verify against the pre-prod gateway and // pin the exact field names before relying on --unified-app-id auto-fetch. The // path degrades safely today: an unrecognised shape yields empty strings and the @@ -667,7 +676,7 @@ func devAppFetchCredentials(runner executor.Runner, cmd *cobra.Command, unifiedA invocation := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), devAppProduct, - "get_open_dev_app_credentials", + devAppCredentialsGetTool, map[string]any{"unifiedAppId": unifiedAppID}, ) res, err := runner.Run(cmd.Context(), invocation) diff --git a/internal/helpers/devapp_cursor.go b/internal/helpers/devapp_cursor.go new file mode 100644 index 00000000..6117067a --- /dev/null +++ b/internal/helpers/devapp_cursor.go @@ -0,0 +1,45 @@ +// 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 "github.com/spf13/cobra" + +// devapp_cursor wires cursor pagination as a PASS-THROUGH: the CLI forwards +// --cursor and --page-size straight into the upstream params, and the upstream +// returns nextCursor/hasMore in its response. No synthesis, no offset/page +// translation, no encode/decode in the CLI — the upstream tools own the cursor +// contract (see docs/cursor-pagination-design.md and docs/upstream-todo.md). +// Until the upstream tools accept `cursor`/`pageSize` and return `nextCursor`, +// paging is in the "pending integration" state, same as the precheckOnly +// rename — no anti-corruption layer bridges the gap. + +// registerDevAppCursorFlags adds the two cursor flags every list/search command +// exposes. pageSize defaults to 20. +func registerDevAppCursorFlags(cmd *cobra.Command) { + cmd.Flags().String("cursor", "", "游标令牌:首次查询留空,续翻传上次出参的 nextCursor") + cmd.Flags().Int("page-size", 20, "单页条数,默认 20") +} + +// devAppApplyCursorParams forwards cursor/pageSize into params as-is. cursor is +// only set when non-empty (first page omits it). +func devAppApplyCursorParams(cmd *cobra.Command, params map[string]any) { + if cur := devAppStringFlag(cmd, "cursor"); cur != "" { + params["cursor"] = cur + } + size := devAppIntFlag(cmd, "page-size") + if size < 1 { + size = 20 + } + params["pageSize"] = size +} diff --git a/internal/helpers/devapp_pretty.go b/internal/helpers/devapp_pretty.go new file mode 100644 index 00000000..37854818 --- /dev/null +++ b/internal/helpers/devapp_pretty.go @@ -0,0 +1,122 @@ +// 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" + + "github.com/spf13/cobra" +) + +// devapp_pretty annotates --format pretty output with human-readable status +// labels. Upstream payloads use a numeric enum for app status and the bare +// INIT/AUDIT/... enum for versionStatus. JSON output is NEVER touched +// (transparency contract); only the pretty view gets `*Text` companions. + +var devAppStatusLabels = map[int]string{ + 0: "IN_ACTIVE 已停用", + 1: "ACTIVE 已激活", + 2: "WAIT_ACTIVE 待激活", + 3: "EXPIRED 已过期", +} + +var devAppVersionStatusLabels = map[string]string{ + "INIT": "未发布(有待发布变更)", + "AUDIT": "发布审核中", + "RELEASE": "已发布生效", + "GRAY": "灰度中", +} + +// devAppToolStatusKind classifies which enum family a tool's `status`-like +// fields belong to, so app status 1 (ACTIVE) is never labeled with version +// semantics and vice versa. +var devAppToolStatusKind = map[string]string{ + "get_dev_app": "app", + "list_dev_app": "app", + "create_dev_app": "version", // create/update 返回 versionStatus + "update_dev_app": "version", + "create_dev_app_version": "version", + "list_dev_app_versions": "version", + "get_dev_app_version_detail": "version", + "get_dev_app_version_status": "version", + "publish_dev_app_version": "version", +} + +func devAppPrettyWanted(cmd *cobra.Command) bool { + format, err := cmd.Flags().GetString("format") + return err == nil && format == "pretty" +} + +// devAppPrettyAnnotate walks the response and adds appStatusText / +// versionStatusText next to recognized enum fields, in place. +func devAppPrettyAnnotate(tool string, payload any) { + kind, ok := devAppToolStatusKind[tool] + if !ok { + return + } + devAppAnnotateNode(payload, kind) +} + +func devAppAnnotateNode(node any, kind string) { + switch v := node.(type) { + case map[string]any: + devAppAnnotateMap(v, kind) + for _, child := range v { + devAppAnnotateNode(child, kind) + } + case []any: + for _, child := range v { + devAppAnnotateNode(child, kind) + } + } +} + +func devAppAnnotateMap(m map[string]any, kind string) { + if kind == "app" { + for _, key := range []string{"status", "appStatus"} { + if n, ok := devAppAsInt(m[key]); ok { + if label, ok := devAppStatusLabels[n]; ok { + m[key+"Text"] = label + } + } + } + return + } + if s, ok := m["versionStatus"].(string); ok { + if label, ok := devAppVersionStatusLabels[s]; ok { + m["versionStatusText"] = label + } + } +} + +func devAppAsInt(v any) (int, bool) { + switch n := v.(type) { + case int: + return n, true + case int64: + return int(n), true + case float64: + return int(n), true + case json.Number: + i, err := n.Int64() + return int(i), err == nil + case string: + var i int + if _, err := fmt.Sscanf(n, "%d", &i); err == nil { + return i, true + } + } + return 0, false +} diff --git a/internal/helpers/devapp_pretty_test.go b/internal/helpers/devapp_pretty_test.go new file mode 100644 index 00000000..85be0606 --- /dev/null +++ b/internal/helpers/devapp_pretty_test.go @@ -0,0 +1,54 @@ +// 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 "testing" + +func TestDevAppPrettyAnnotateAppStatus(t *testing.T) { + payload := map[string]any{ + "result": map[string]any{ + "items": []any{ + map[string]any{"status": float64(1)}, + map[string]any{"status": float64(0)}, + }, + }, + } + devAppPrettyAnnotate("list_dev_app", payload) + items := payload["result"].(map[string]any)["items"].([]any) + if got := items[0].(map[string]any)["statusText"]; got != "ACTIVE 已激活" { + t.Fatalf("statusText = %v, want ACTIVE 已激活", got) + } + if got := items[1].(map[string]any)["statusText"]; got != "IN_ACTIVE 已停用" { + t.Fatalf("statusText = %v, want IN_ACTIVE 已停用", got) + } +} + +func TestDevAppPrettyAnnotateVersionStatusKindSeparation(t *testing.T) { + // 版本工具只注解 versionStatus;旧 status 字段不再参与版本契约。 + version := map[string]any{"status": "INIT", "versionStatus": "RELEASE"} + devAppPrettyAnnotate("get_dev_app_version_detail", version) + if version["versionStatusText"] != "已发布生效" { + t.Fatalf("version annotations wrong: %#v", version) + } + if _, exists := version["statusText"]; exists { + t.Fatalf("version status should not be annotated: %#v", version) + } + + // 应用工具的数字枚举不能套用版本语义;未登记的工具不动 + unknown := map[string]any{"status": float64(1)} + devAppPrettyAnnotate("some_unknown_tool", unknown) + if _, exists := unknown["statusText"]; exists { + t.Fatalf("unknown tool should not be annotated: %#v", unknown) + } +} diff --git a/internal/helpers/devapp_robot_connect_test.go b/internal/helpers/devapp_robot_connect_test.go index 90b2b752..d0508625 100644 --- a/internal/helpers/devapp_robot_connect_test.go +++ b/internal/helpers/devapp_robot_connect_test.go @@ -20,12 +20,12 @@ import ( "testing" ) -// TestDevAppRobotConnectRegistered confirms `devapp robot connect` is wired into +// TestDevAppRobotConnectRegistered confirms `dev connect` is wired into // the robot subtree alongside the (reused) provisioning commands. func TestDevAppRobotConnectRegistered(t *testing.T) { root := newDevAppCommand(&captureRunner{}) if _, _, err := root.Find([]string{"robot", "connect"}); err != nil { - t.Fatalf("devapp robot connect not registered: %v", err) + t.Fatalf("dev connect not registered: %v", err) } // Provisioning stays under robot (reused, not duplicated by connect). for _, name := range []string{"create", "submit", "result"} { @@ -72,7 +72,7 @@ func TestDevAppRobotConnectValidation(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs(append([]string{"devapp", "robot", "connect"}, tc.args...)) + root.SetArgs(append([]string{"dev", "connect"}, tc.args...)) err := root.Execute() if tc.wantErr != "" { @@ -94,7 +94,7 @@ func TestDevAppRobotConnectValidation(t *testing.T) { } // TestDevAppFetchCredentials checks that --unified-app-id credential resolution -// reuses get_open_dev_app_credentials and extracts clientId/clientSecret from the +// reuses get_dev_app_credentials and extracts clientId/clientSecret from the // MCP envelope. func TestDevAppFetchCredentials(t *testing.T) { runner := &devAppResponseRunner{response: map[string]any{ @@ -106,7 +106,7 @@ func TestDevAppFetchCredentials(t *testing.T) { }, }} root := newDevAppTestRoot(runner) - connectCmd, _, err := root.Find([]string{"devapp", "robot", "connect"}) + connectCmd, _, err := root.Find([]string{"dev", "connect"}) if err != nil { t.Fatalf("find connect: %v", err) } @@ -119,8 +119,8 @@ func TestDevAppFetchCredentials(t *testing.T) { if id != "ck-123" || secret != "cs-456" { t.Fatalf("got id=%q secret=%q, want ck-123/cs-456", id, secret) } - if runner.last.Tool != "get_open_dev_app_credentials" { - t.Fatalf("Tool = %q, want get_open_dev_app_credentials", runner.last.Tool) + if runner.last.Tool != "get_dev_app_credentials" { + t.Fatalf("Tool = %q, want get_dev_app_credentials", runner.last.Tool) } if runner.last.CanonicalProduct != devAppProduct { t.Fatalf("CanonicalProduct = %q, want %q", runner.last.CanonicalProduct, devAppProduct) diff --git a/internal/helpers/devapp_test.go b/internal/helpers/devapp_test.go index 8bd77b10..ce1e3b61 100644 --- a/internal/helpers/devapp_test.go +++ b/internal/helpers/devapp_test.go @@ -3,6 +3,7 @@ package helpers import ( "bytes" "context" + "encoding/json" "reflect" "strings" "testing" @@ -18,7 +19,7 @@ func newDevAppTestRoot(runner executor.Runner) *cobra.Command { } root.PersistentFlags().Bool("dry-run", false, "dry run") root.PersistentFlags().Bool("yes", false, "yes") - root.AddCommand(newDevAppCommand(runner)) + root.AddCommand(devHandler{}.Command(runner)) return root } @@ -44,8 +45,8 @@ func TestDevAppMemberCommandsBuildToolParams(t *testing.T) { { name: "list", cmd: "list", - args: []string{"--app-id", "app-001"}, - wantTool: "list_open_dev_app_members", + args: []string{"--unified-app-id", "app-001"}, + wantTool: "list_dev_app_members", wantParams: map[string]any{ "unifiedAppId": "app-001", }, @@ -53,23 +54,34 @@ func TestDevAppMemberCommandsBuildToolParams(t *testing.T) { { name: "add multiple users", cmd: "add", - args: []string{"--app-id", "app-001", "--users", "userId1,userId2,userId3,userId4", "--member-type", "DEVELOPER", "--yes"}, - wantTool: "add_open_dev_app_members", + args: []string{"--unified-app-id", "app-001", "--user-ids", "userId1,userId2,userId3,userId4", "--member-type", "DEVELOPER", "--yes"}, + wantTool: "add_dev_app_members", wantParams: map[string]any{ - "unifiedAppId": "app-001", - "memberUserIds": []string{"userId1", "userId2", "userId3", "userId4"}, - "memberType": "DEVELOPER", + "unifiedAppId": "app-001", + "userIds": []string{"userId1", "userId2", "userId3", "userId4"}, + "memberType": "DEVELOPER", }, }, { name: "remove trims users", cmd: "remove", - args: []string{"--app-id", "app-001", "--users", " userId1 , userId2 ", "--member-type", "DEVELOPER", "--yes"}, - wantTool: "remove_open_dev_app_members", + args: []string{"--unified-app-id", "app-001", "--user-ids", " userId1 , userId2 ", "--member-type", "DEVELOPER", "--yes"}, + wantTool: "remove_dev_app_members", wantParams: map[string]any{ - "unifiedAppId": "app-001", - "memberUserIds": []string{"userId1", "userId2"}, - "memberType": "DEVELOPER", + "unifiedAppId": "app-001", + "userIds": []string{"userId1", "userId2"}, + "memberType": "DEVELOPER", + }, + }, + { + name: "add accepts legacy member user ids flag", + cmd: "add", + args: []string{"--unified-app-id", "app-001", "--member-user-ids", "userId1,userId2", "--member-type", "DEVELOPER", "--yes"}, + wantTool: "add_dev_app_members", + wantParams: map[string]any{ + "unifiedAppId": "app-001", + "userIds": []string{"userId1", "userId2"}, + "memberType": "DEVELOPER", }, }, } @@ -81,7 +93,7 @@ func TestDevAppMemberCommandsBuildToolParams(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs(append([]string{"devapp", "member", tc.cmd}, tc.args...)) + root.SetArgs(append([]string{"dev", "app", "member", tc.cmd}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) @@ -100,23 +112,37 @@ func TestDevAppMemberCommandsBuildToolParams(t *testing.T) { } } -func TestDevAppCommandHasAppAliasAndCoreCommands(t *testing.T) { - root := newDevAppCommand(&captureRunner{}) - if root.Name() != "devapp" { - t.Fatalf("Name() = %q, want devapp", root.Name()) +func TestDevCommandTree(t *testing.T) { + dev := devHandler{}.Command(&captureRunner{}) + if dev.Name() != "dev" { + t.Fatalf("Name() = %q, want dev", dev.Name()) } - hasAlias := false - for _, alias := range root.Aliases { - if alias == "app" { - hasAlias = true + if len(dev.Aliases) != 0 { + t.Fatalf("Aliases = %v, want none (clean switch from devapp)", dev.Aliases) + } + // 三支柱:app / connect / doc + for _, name := range []string{"app", "connect", "doc"} { + if _, _, err := dev.Find([]string{name}); err != nil { + t.Fatalf("missing subtree %q: %v", name, err) } } - if !hasAlias { - t.Fatalf("Aliases = %v, want app", root.Aliases) + app, _, err := dev.Find([]string{"app"}) + if err != nil { + t.Fatalf("find app: %v", err) } - for _, name := range []string{"list", "get", "create", "update", "delete", "inactive", "active", "credentials", "webapp", "permission", "member", "security", "robot", "version"} { - if _, _, err := root.Find([]string{name}); err != nil { - t.Fatalf("missing command %q: %v", name, err) + for _, name := range []string{"list", "get", "create", "update", "delete", "disable", "enable", "credentials", "webapp", "permission", "member", "security", "robot", "version", "event"} { + if _, _, err := app.Find([]string{name}); err != nil { + t.Fatalf("missing app command %q: %v", name, err) + } + } + // connect 已从 robot 子树提升为 dev connect,robot 下不应再有 + robot, _, err := app.Find([]string{"robot"}) + if err != nil { + t.Fatalf("find robot: %v", err) + } + for _, sub := range robot.Commands() { + if sub.Name() == "connect" { + t.Fatalf("robot subtree still has connect; it moved to dev connect") } } } @@ -128,29 +154,15 @@ func TestDevAppRobotCommandsBuildToolParams(t *testing.T) { wantTool string wantParams map[string]any }{ - { - // create 不再调用失效的同步 create_dingtalk_robot(issue #35), - // 改为提交异步 submit_robot_create_task 任务(并补图标占位),随后轮询结果。 - name: "create routes to async submit", - args: []string{"robot", "create", "--app-name", "智能体", "--robot-name", "小助手", "--desc", "审批问答", "--yes"}, - wantTool: "submit_robot_create_task", - wantParams: map[string]any{ - "appName": "智能体", - "robotName": "小助手", - "desc": "审批问答", - "robotMediaId": "", - "previewMediaId": "", - }, - }, { name: "submit async fills media placeholders", - args: []string{"robot", "submit", "--app-name", "智能体", "--robot-name", "小助手", "--desc", "审批问答", "--task-id", "t-1", "--yes"}, + args: []string{"robot", "submit", "--name", "智能体", "--robot-name", "小助手", "--desc", "审批问答", "--task-id", "t-1", "--yes"}, wantTool: "submit_robot_create_task", wantParams: map[string]any{ - "appName": "智能体", + "name": "智能体", "robotName": "小助手", "desc": "审批问答", - "robotMediaId": "", + "iconMediaId": "", "previewMediaId": "", "taskId": "t-1", }, @@ -164,26 +176,26 @@ func TestDevAppRobotCommandsBuildToolParams(t *testing.T) { { name: "get config", args: []string{"robot", "get", "--unified-app-id", "u-1"}, - wantTool: "get_open_dev_app_robot_config", + wantTool: "get_extension_robot_config", wantParams: map[string]any{"unifiedAppId": "u-1"}, }, { name: "config create with skills and mode", - args: []string{"robot", "config", "--unified-app-id", "u-1", "--name", "小助手", "--brief", "审批助手", "--mode", "2", "--skills", "qa,approval", "--add-scope", "--yes"}, - wantTool: "create_open_dev_app_robot_config", + args: []string{"robot", "config", "--unified-app-id", "u-1", "--name", "小助手", "--brief", "审批助手", "--mode", "STREAM", "--skills", "qa,approval", "--add-scope", "--yes"}, + wantTool: "set_extension_robot_config", wantParams: map[string]any{ "unifiedAppId": "u-1", "name": "小助手", "brief": "审批助手", - "mode": 2, - "skillList": []string{"qa", "approval"}, - "isAddScope": true, + "mode": "STREAM", + "skills": []string{"qa", "approval"}, + "addScope": true, }, }, { - name: "offline", - args: []string{"robot", "offline", "--unified-app-id", "u-1", "--yes"}, - wantTool: "offline_open_dev_app_robot", + name: "robot disable", + args: []string{"robot", "disable", "--unified-app-id", "u-1", "--yes"}, + wantTool: "disable_dev_app_robot", wantParams: map[string]any{"unifiedAppId": "u-1"}, }, } @@ -195,7 +207,7 @@ func TestDevAppRobotCommandsBuildToolParams(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs(append([]string{"devapp"}, tc.args...)) + root.SetArgs(append([]string{"dev", "app"}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) @@ -213,85 +225,6 @@ func TestDevAppRobotCommandsBuildToolParams(t *testing.T) { } } -// sequencedRobotRunner returns a scripted response per call so we can exercise -// the create → submit → poll-until-terminal loop without real network calls. -type sequencedRobotRunner struct { - responses []map[string]any - calls []executor.Invocation -} - -func (r *sequencedRobotRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) { - idx := len(r.calls) - r.calls = append(r.calls, invocation) - resp := map[string]any{} - if idx < len(r.responses) { - resp = r.responses[idx] - } else if len(r.responses) > 0 { - resp = r.responses[len(r.responses)-1] - } - invocation.Implemented = true - return executor.Result{Invocation: invocation, Response: resp}, nil -} - -func robotToolResponse(body map[string]any) map[string]any { - return map[string]any{"content": map[string]any{"result": body}} -} - -func TestDevAppRobotCreatePollsUntilTerminal(t *testing.T) { - runner := &sequencedRobotRunner{ - responses: []map[string]any{ - // submit_robot_create_task → task accepted, still pending. - robotToolResponse(map[string]any{"taskId": "t-1", "status": "WAITING", "interval": 0.01}), - // first poll → still pending. - robotToolResponse(map[string]any{"taskId": "t-1", "status": "PROCESSING", "interval": 0.01}), - // second poll → terminal, carries credentials. - robotToolResponse(map[string]any{"taskId": "t-1", "status": "APPROVAL_REQUIRED", "agentId": "4680653193", "robotCode": "ding-test"}), - }, - } - root := newDevAppTestRoot(runner) - var out bytes.Buffer - root.SetOut(&out) - root.SetErr(&out) - root.SetArgs([]string{"devapp", "robot", "create", "--app-name", "智能体", "--robot-name", "小助手", "--desc", "审批问答", "--yes"}) - - if err := root.Execute(); err != nil { - t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) - } - - if len(runner.calls) != 3 { - t.Fatalf("expected submit + 2 polls = 3 calls, got %d", len(runner.calls)) - } - if got := runner.calls[0].Tool; got != "submit_robot_create_task" { - t.Fatalf("first call tool = %q, want submit_robot_create_task", got) - } - for i := 1; i < 3; i++ { - if got := runner.calls[i].Tool; got != "query_robot_create_result" { - t.Fatalf("poll call %d tool = %q, want query_robot_create_result", i, got) - } - if got, _ := runner.calls[i].Params["taskId"].(string); got != "t-1" { - t.Fatalf("poll call %d taskId = %q, want t-1", i, got) - } - } - if !strings.Contains(out.String(), "APPROVAL_REQUIRED") || !strings.Contains(out.String(), "4680653193") { - t.Fatalf("final output missing terminal result, got:\n%s", out.String()) - } -} - -func TestDevAppRobotStatusPending(t *testing.T) { - pending := []string{"WAITING", "processing", " Running ", "PENDING", "INIT", "DOING"} - for _, s := range pending { - if !devAppRobotStatusPending(s) { - t.Errorf("status %q should be pending", s) - } - } - terminal := []string{"SUCCESS", "APPROVAL_REQUIRED", "FAILED", "ERROR", "", "unknown"} - for _, s := range terminal { - if devAppRobotStatusPending(s) { - t.Errorf("status %q should be terminal", s) - } - } -} - func TestDevAppVersionCommandsBuildToolParams(t *testing.T) { cases := []struct { name string @@ -300,45 +233,54 @@ func TestDevAppVersionCommandsBuildToolParams(t *testing.T) { wantParams map[string]any }{ { - name: "create", + name: "create lets server auto-increment version", + args: []string{"version", "create", "--unified-app-id", "u-1", "--desc", "新增机器人", "--yes"}, + wantTool: "create_dev_app_version", + wantParams: map[string]any{ + "unifiedAppId": "u-1", + "desc": "新增机器人", + }, + }, + { + name: "create with explicit version", args: []string{"version", "create", "--unified-app-id", "u-1", "--version", "1.0.1", "--desc", "新增机器人", "--yes"}, - wantTool: "create_open_dev_app_version", + wantTool: "create_dev_app_version", wantParams: map[string]any{ "unifiedAppId": "u-1", "version": "1.0.1", - "description": "新增机器人", + "desc": "新增机器人", }, }, { name: "list", - args: []string{"version", "list", "--unified-app-id", "u-1", "--page", "2", "--page-size", "5"}, - wantTool: "list_open_dev_app_versions", + args: []string{"version", "list", "--unified-app-id", "u-1", "--cursor", "tok-1", "--page-size", "5"}, + wantTool: "list_dev_app_versions", wantParams: map[string]any{ "unifiedAppId": "u-1", - "currentPage": 2, + "cursor": "tok-1", "pageSize": 5, }, }, { name: "get detail", args: []string{"version", "get", "--unified-app-id", "u-1", "--version-id", "v-1"}, - wantTool: "get_open_dev_app_version_detail", + wantTool: "get_dev_app_version_detail", wantParams: map[string]any{"unifiedAppId": "u-1", "versionId": "v-1"}, }, { - name: "check-approval forces dryRun", + name: "check-approval forces precheckOnly", args: []string{"version", "check-approval", "--unified-app-id", "u-1", "--version-id", "v-1"}, - wantTool: "publish_open_dev_app_version", - wantParams: map[string]any{"unifiedAppId": "u-1", "versionId": "v-1", "dryRun": true}, + wantTool: "publish_dev_app_version", + wantParams: map[string]any{"unifiedAppId": "u-1", "versionId": "v-1", "precheckOnly": true}, }, { - name: "publish sets dryRun false and sensitive", - args: []string{"version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1", "--confirm-sensitive", "--approver", "user-1", "--yes"}, - wantTool: "publish_open_dev_app_version", + name: "publish sets precheckOnly false and sensitive", + args: []string{"version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1", "--confirmed-sensitive", "--approver-user-id", "user-1", "--yes"}, + wantTool: "publish_dev_app_version", wantParams: map[string]any{ "unifiedAppId": "u-1", "versionId": "v-1", - "dryRun": false, + "precheckOnly": false, "confirmedSensitive": true, "approverUserId": "user-1", }, @@ -346,7 +288,7 @@ func TestDevAppVersionCommandsBuildToolParams(t *testing.T) { { name: "status", args: []string{"version", "status", "--unified-app-id", "u-1", "--version-id", "v-1"}, - wantTool: "get_open_dev_app_version_status", + wantTool: "get_dev_app_version_status", wantParams: map[string]any{"unifiedAppId": "u-1", "versionId": "v-1"}, }, } @@ -358,7 +300,7 @@ func TestDevAppVersionCommandsBuildToolParams(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs(append([]string{"devapp"}, tc.args...)) + root.SetArgs(append([]string{"dev", "app"}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) @@ -378,11 +320,10 @@ func TestDevAppRobotAndVersionWritesRequireGuard(t *testing.T) { name string args []string }{ - {name: "robot create", args: []string{"devapp", "robot", "create", "--app-name", "智能体", "--robot-name", "小助手", "--desc", "审批"}}, - {name: "robot config", args: []string{"devapp", "robot", "config", "--unified-app-id", "u-1", "--name", "小助手"}}, - {name: "robot offline", args: []string{"devapp", "robot", "offline", "--unified-app-id", "u-1"}}, - {name: "version create", args: []string{"devapp", "version", "create", "--unified-app-id", "u-1", "--version", "1.0.1"}}, - {name: "version publish", args: []string{"devapp", "version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1"}}, + {name: "robot config", args: []string{"dev", "app", "robot", "config", "--unified-app-id", "u-1", "--name", "小助手"}}, + {name: "robot disable", args: []string{"dev", "app", "robot", "disable", "--unified-app-id", "u-1"}}, + {name: "version create", args: []string{"dev", "app", "version", "create", "--unified-app-id", "u-1"}}, + {name: "version publish", args: []string{"dev", "app", "version", "publish", "--unified-app-id", "u-1", "--version-id", "v-1"}}, } for _, tc := range cases { @@ -398,7 +339,7 @@ func TestDevAppRobotAndVersionWritesRequireGuard(t *testing.T) { if err == nil { t.Fatal("Execute() error = nil, want write guard") } - if !strings.Contains(err.Error(), "write operation") { + if !strings.Contains(err.Error(), "写操作") { t.Fatalf("error = %q, want write guard", err.Error()) } if runner.last.Tool != "" { @@ -414,20 +355,20 @@ func TestDevAppListBuildsListByConditionParams(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"list", "--name", "Waker", "--page", "2", "--page-size", "5", "--sort", "gmt_modified", "--order", "desc"}) + root.SetArgs([]string{"list", "--name", "Waker", "--cursor", "tok-2", "--page-size", "5", "--sort-type", "gmt_modified", "--sort-order", "desc"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } - if got := runner.last.Tool; got != "list_open_dev_apps_by_condition" { - t.Fatalf("Tool = %q, want list_open_dev_apps_by_condition", got) + if got := runner.last.Tool; got != "list_dev_app" { + t.Fatalf("Tool = %q, want list_dev_app", got) } want := map[string]any{ - "currentPage": 2, - "pageSize": 5, - "appName": "Waker", - "sortType": "gmt_modified", - "sortOrder": "desc", + "cursor": "tok-2", + "pageSize": 5, + "name": "Waker", + "sortType": "gmt_modified", + "sortOrder": "desc", } if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) @@ -445,8 +386,8 @@ func TestDevAppGetBuildsDetailParams(t *testing.T) { if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } - if got := runner.last.Tool; got != "get_open_dev_app_detail" { - t.Fatalf("Tool = %q, want get_open_dev_app_detail", got) + if got := runner.last.Tool; got != "get_dev_app" { + t.Fatalf("Tool = %q, want get_dev_app", got) } want := map[string]any{"unifiedAppId": "u-1"} if !reflect.DeepEqual(runner.last.Params, want) { @@ -471,15 +412,15 @@ func TestDevAppCreateUsesCurrentInnerToolAndWriteGuard(t *testing.T) { root = newDevAppTestRoot(runner) root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"devapp", "create", "--name", "Demo", "--desc", "internal app", "--yes"}) + root.SetArgs([]string{"dev", "app", "create", "--name", "Demo", "--desc", "internal app", "--yes"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } - if got := runner.last.Tool; got != "create_inner_app" { - t.Fatalf("Tool = %q, want create_inner_app", got) + if got := runner.last.Tool; got != "create_dev_app" { + t.Fatalf("Tool = %q, want create_dev_app", got) } - want := map[string]any{"appName": "Demo", "appDesc": "internal app"} + want := map[string]any{"name": "Demo", "desc": "internal app"} if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) } @@ -491,15 +432,15 @@ func TestDevAppUpdateUsesCurrentInnerTool(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"devapp", "update", "--agent-id", "123", "--desc", "new desc", "--yes"}) + root.SetArgs([]string{"dev", "app", "update", "--unified-app-id", "u-123", "--desc", "new desc", "--yes"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } - if got := runner.last.Tool; got != "update_inner_app" { - t.Fatalf("Tool = %q, want update_inner_app", got) + if got := runner.last.Tool; got != "update_dev_app" { + t.Fatalf("Tool = %q, want update_dev_app", got) } - want := map[string]any{"agentId": 123, "appDesc": "new desc"} + want := map[string]any{"unifiedAppId": "u-123", "desc": "new desc"} if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) } @@ -512,23 +453,17 @@ func TestDevAppLifecycleBuildsLocatorParams(t *testing.T) { wantTool string wantParams map[string]any }{ - { - name: "delete by agent id", - args: []string{"delete", "--agent-id", "123", "--yes"}, - wantTool: "delete_inner_app", - wantParams: map[string]any{"agentId": 123}, - }, { name: "inactive by unified app id", - args: []string{"inactive", "--unified-app-id", "u-1", "--yes"}, - wantTool: "inactive_inner_app", + args: []string{"disable", "--unified-app-id", "u-1", "--yes"}, + wantTool: "disable_dev_app", wantParams: map[string]any{"unifiedAppId": "u-1"}, }, { - name: "active by app key", - args: []string{"active", "--app-key", "dingxxx", "--yes"}, - wantTool: "active_inner_app", - wantParams: map[string]any{"appKey": "dingxxx"}, + name: "active by unified id", + args: []string{"enable", "--unified-app-id", "u-2", "--yes"}, + wantTool: "enable_dev_app", + wantParams: map[string]any{"unifiedAppId": "u-2"}, }, } @@ -539,7 +474,7 @@ func TestDevAppLifecycleBuildsLocatorParams(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs(append([]string{"devapp"}, tc.args...)) + root.SetArgs(append([]string{"dev", "app"}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) @@ -554,6 +489,140 @@ func TestDevAppLifecycleBuildsLocatorParams(t *testing.T) { } } +// TestDevAppDeleteConfirmName covers the danger-tier delete: --confirm-name +// must match the located app's real name, and a name that can't be read is +// fail-closed (abort), never a silent proceed. +func TestDevAppDeleteConfirmName(t *testing.T) { + t.Run("matching name proceeds", func(t *testing.T) { + // get_dev_app 真实返回的应用名字段是 name(不是 appName)—— + // fixture 用 name 才能覆盖真实契约,避免 delete 取名回归。 + runner := &devAppResponseRunner{response: map[string]any{"name": "DemoApp"}} + root := newDevAppTestRoot(runner) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"dev", "app", "delete", "--unified-app-id", "u-123", "--confirm-name", "DemoApp", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runner.last.Tool != "delete_dev_app" { + t.Fatalf("Tool = %q, want delete_dev_app", runner.last.Tool) + } + }) + + t.Run("mismatched name aborts", func(t *testing.T) { + runner := &devAppResponseRunner{response: map[string]any{"name": "RealName"}} + root := newDevAppTestRoot(runner) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"dev", "app", "delete", "--unified-app-id", "u-123", "--confirm-name", "WrongName", "--yes"}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "名称不匹配") { + t.Fatalf("error = %v, want 名称不匹配", err) + } + if runner.last.Tool == "delete_dev_app" { + t.Fatal("delete must not run on name mismatch") + } + }) + + t.Run("unreadable name is fail-closed", func(t *testing.T) { + runner := &devAppResponseRunner{response: map[string]any{}} // no appName + root := newDevAppTestRoot(runner) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"dev", "app", "delete", "--unified-app-id", "u-123", "--confirm-name", "DemoApp", "--yes"}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "无法读取应用名") { + t.Fatalf("error = %v, want 无法读取应用名 (fail-closed)", err) + } + if runner.last.Tool == "delete_dev_app" { + t.Fatal("delete must not run when name is unreadable") + } + }) +} + +// TestDevAppEventSubscribeUsesEventCodes locks the param contract: the server's +// subscribe/unsubscribe tools (plural: subscribe_dev_app_events) read eventCodes +// as an ARRAY — real-device confirmed against the updated MCP schema. +func TestDevAppEventListForwardsKeyword(t *testing.T) { + runner := &captureRunner{} + root := newDevAppTestRoot(runner) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"dev", "app", "event", "list", "--unified-app-id", "u-1", "--keyword", "通讯录"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) + } + if runner.last.Tool != devAppEventListTool { + t.Fatalf("Tool = %q, want %q", runner.last.Tool, devAppEventListTool) + } + if got := runner.last.Params["keyword"]; got != "通讯录" { + t.Fatalf("keyword = %#v, want 通讯录", got) + } +} + +func TestDevAppEventSubscribeUsesEventCodes(t *testing.T) { + for _, tc := range []struct { + name string + args []string + wantTool string + }{ + {"subscribe", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--event-codes", "a,b", "--yes"}, "subscribe_dev_app_events"}, + {"unsubscribe", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--event-codes", "a,b", "--yes"}, "unsubscribe_dev_app_events"}, + } { + t.Run(tc.name, func(t *testing.T) { + runner := &captureRunner{} + root := newDevAppTestRoot(runner) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(tc.args) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) + } + if runner.last.Tool != tc.wantTool { + t.Fatalf("Tool = %q, want %q", runner.last.Tool, tc.wantTool) + } + got, ok := runner.last.Params["eventCodes"].([]string) + if !ok || len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("eventCodes = %#v, want []string{a,b}", runner.last.Params["eventCodes"]) + } + if _, bad := runner.last.Params["eventCode"]; bad { + t.Fatal("must not send singular eventCode") + } + }) + } +} + +func TestDevAppEventSubscribeRequiresEventCodes(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + {"subscribe", []string{"dev", "app", "event", "subscribe", "--unified-app-id", "u-1", "--yes"}}, + {"unsubscribe", []string{"dev", "app", "event", "unsubscribe", "--unified-app-id", "u-1", "--yes"}}, + } { + t.Run(tc.name, func(t *testing.T) { + runner := &captureRunner{} + root := newDevAppTestRoot(runner) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(tc.args) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "--event-codes 为必填") { + t.Fatalf("Execute() error = %v, want --event-codes 为必填", err) + } + if runner.last.Tool != "" { + t.Fatalf("runner should not be called, got tool %q", runner.last.Tool) + } + }) + } +} + func TestDevAppWebappCommandsBuildParams(t *testing.T) { cases := []struct { name string @@ -564,17 +633,17 @@ func TestDevAppWebappCommandsBuildParams(t *testing.T) { { name: "get", args: []string{"webapp", "get", "--unified-app-id", "u-1"}, - wantTool: "get_webapp_config", + wantTool: "get_extension_webapp_config", wantParams: map[string]any{"unifiedAppId": "u-1"}, }, { name: "config", - args: []string{"webapp", "config", "--unified-app-id", "u-1", "--homepage-link", "https://example.com", "--pc-homepage-link", "https://pc.example.com", "--yes"}, - wantTool: "set_webapp_config", + args: []string{"webapp", "config", "--unified-app-id", "u-1", "--homepage-url", "https://example.com", "--pc-homepage-url", "https://pc.example.com", "--yes"}, + wantTool: "set_extension_webapp_config", wantParams: map[string]any{ - "unifiedAppId": "u-1", - "homepageLink": "https://example.com", - "pcHomepageLink": "https://pc.example.com", + "unifiedAppId": "u-1", + "homepageUrl": "https://example.com", + "pcHomepageUrl": "https://pc.example.com", }, }, } @@ -586,7 +655,7 @@ func TestDevAppWebappCommandsBuildParams(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs(append([]string{"devapp"}, tc.args...)) + root.SetArgs(append([]string{"dev", "app"}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) @@ -610,31 +679,32 @@ func TestDevAppPermissionCommandsBuildParams(t *testing.T) { }{ { name: "list", - args: []string{"permission", "list", "--agent-id", "123", "--keyword", "手机号", "--status", "all", "--limit", "5"}, - wantTool: "list_open_dev_app_permissions", + args: []string{"permission", "list", "--unified-app-id", "u-123", "--keyword", "手机号", "--auth-status", "all", "--cursor", "tok-3", "--page-size", "5"}, + wantTool: "list_dev_app_permissions", wantParams: map[string]any{ - "agentId": 123, - "keyword": "手机号", - "authStatus": "ALL", - "limit": 5, + "unifiedAppId": "u-123", + "keyword": "手机号", + "authStatus": "ALL", + "cursor": "tok-3", + "pageSize": 5, }, }, { name: "add", - args: []string{"permission", "add", "--agent-id", "123", "--permissions", "Contact.User.mobile,qyapi_robot_sendmsg", "--yes"}, - wantTool: "apply_open_dev_app_permissions", + args: []string{"permission", "add", "--unified-app-id", "u-123", "--scope-values", "Contact.User.mobile,qyapi_robot_sendmsg", "--yes"}, + wantTool: "apply_dev_app_permissions", wantParams: map[string]any{ - "agentId": 123, - "scopeValues": []string{"Contact.User.mobile", "qyapi_robot_sendmsg"}, + "unifiedAppId": "u-123", + "scopeValues": []string{"Contact.User.mobile", "qyapi_robot_sendmsg"}, }, }, { name: "remove", - args: []string{"permission", "remove", "--agent-id", "123", "--permission", "Contact.User.mobile", "--yes"}, - wantTool: "remove_open_dev_app_permission", + args: []string{"permission", "remove", "--unified-app-id", "u-123", "--scope-values", "Contact.User.mobile", "--yes"}, + wantTool: "remove_dev_app_permissions", wantParams: map[string]any{ - "agentId": 123, - "scopeValue": "Contact.User.mobile", + "unifiedAppId": "u-123", + "scopeValues": []string{"Contact.User.mobile"}, }, }, } @@ -646,7 +716,7 @@ func TestDevAppPermissionCommandsBuildParams(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs(append([]string{"devapp"}, tc.args...)) + root.SetArgs(append([]string{"dev", "app"}, tc.args...)) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) @@ -667,15 +737,15 @@ func TestDevAppCredentialsGetBuildsParams(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"devapp", "credentials", "get", "--agent-id", "123"}) + root.SetArgs([]string{"dev", "app", "credentials", "get", "--unified-app-id", "u-123"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) } - if got := runner.last.Tool; got != "get_open_dev_app_credentials" { - t.Fatalf("Tool = %q, want get_open_dev_app_credentials", got) + if got := runner.last.Tool; got != "get_dev_app_credentials" { + t.Fatalf("Tool = %q, want get_dev_app_credentials", got) } - want := map[string]any{"agentId": 123} + want := map[string]any{"unifiedAppId": "u-123"} if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) } @@ -696,7 +766,7 @@ func TestDevAppCredentialsGetKeepsSecretFields(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"devapp", "credentials", "get", "--agent-id", "123"}) + root.SetArgs([]string{"dev", "app", "credentials", "get", "--unified-app-id", "u-123"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) @@ -716,10 +786,10 @@ func TestDevAppMemberCommandsValidateRequiredFlags(t *testing.T) { args []string wantErr string }{ - {name: "list requires app", cmd: "list", args: nil, wantErr: "--app-id is required"}, - {name: "add requires users", cmd: "add", args: []string{"--app-id", "app-001", "--member-type", "DEVELOPER", "--dry-run"}, wantErr: "--users is required"}, - {name: "add rejects empty users", cmd: "add", args: []string{"--app-id", "app-001", "--users", " , ", "--member-type", "DEVELOPER", "--dry-run"}, wantErr: "--users must contain at least one userId"}, - {name: "remove requires member type", cmd: "remove", args: []string{"--app-id", "app-001", "--users", "userId1", "--dry-run"}, wantErr: "--member-type is required"}, + {name: "list requires app", cmd: "list", args: nil, wantErr: "--unified-app-id 为必填"}, + {name: "add requires users", cmd: "add", args: []string{"--unified-app-id", "app-001", "--member-type", "DEVELOPER", "--dry-run"}, wantErr: "--user-ids 为必填"}, + {name: "add rejects empty users", cmd: "add", args: []string{"--unified-app-id", "app-001", "--user-ids", " , ", "--member-type", "DEVELOPER", "--dry-run"}, wantErr: "--user-ids 至少包含一个 userId"}, + {name: "remove requires member type", cmd: "remove", args: []string{"--unified-app-id", "app-001", "--user-ids", "userId1", "--dry-run"}, wantErr: "--member-type 为必填"}, } for _, tc := range cases { @@ -729,7 +799,7 @@ func TestDevAppMemberCommandsValidateRequiredFlags(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs(append([]string{"devapp", "member", tc.cmd}, tc.args...)) + root.SetArgs(append([]string{"dev", "app", "member", tc.cmd}, tc.args...)) err := root.Execute() if err == nil { @@ -752,11 +822,11 @@ func TestDevAppSecurityConfigBuildsOnlyProvidedLists(t *testing.T) { root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{ - "devapp", "security", "config", - "--app-id", "app-001", + "dev", "app", "security", "config", + "--unified-app-id", "app-001", "--ip-whitelist", "192.0.2.10,192.0.2.11", - "--redirect-url", "https://callback.example.invalid/callback", - "--sso-url", "https://sso.example.invalid/sso", + "--redirect-urls", "https://callback.example.invalid/callback", + "--sso-urls", "https://sso.example.invalid/sso", "--dry-run", }) @@ -767,14 +837,14 @@ func TestDevAppSecurityConfigBuildsOnlyProvidedLists(t *testing.T) { if got := runner.last.CanonicalProduct; got != "devapp" { t.Fatalf("CanonicalProduct = %q, want devapp", got) } - if got := runner.last.Tool; got != "update_app_security_config" { - t.Fatalf("Tool = %q, want update_app_security_config", got) + if got := runner.last.Tool; got != "update_dev_app_security_config" { + t.Fatalf("Tool = %q, want update_dev_app_security_config", got) } want := map[string]any{ - "unifiedAppId": "app-001", - "ipWhiteList": []string{"192.0.2.10", "192.0.2.11"}, - "redirectUrls": []string{"https://callback.example.invalid/callback"}, - "otherAuthUrls": []string{"https://sso.example.invalid/sso"}, + "unifiedAppId": "app-001", + "ipWhitelist": []string{"192.0.2.10", "192.0.2.11"}, + "redirectUrls": []string{"https://callback.example.invalid/callback"}, + "ssoUrls": []string{"https://sso.example.invalid/sso"}, } if !reflect.DeepEqual(runner.last.Params, want) { t.Fatalf("Params = %#v, want %#v", runner.last.Params, want) @@ -787,7 +857,7 @@ func TestDevAppSecurityConfigOmitsAbsentOptionalLists(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"devapp", "security", "config", "--app-id", "app-001", "--redirect-url", "https://callback.example.invalid/callback", "--dry-run"}) + root.SetArgs([]string{"dev", "app", "security", "config", "--unified-app-id", "app-001", "--redirect-urls", "https://callback.example.invalid/callback", "--dry-run"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) @@ -808,13 +878,13 @@ func TestDevAppSecurityConfigRequiresAtLeastOneConfig(t *testing.T) { var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - root.SetArgs([]string{"devapp", "security", "config", "--app-id", "app-001", "--dry-run"}) + root.SetArgs([]string{"dev", "app", "security", "config", "--unified-app-id", "app-001", "--dry-run"}) err := root.Execute() if err == nil { t.Fatal("Execute() error = nil, want validation error") } - if !strings.Contains(err.Error(), "one of --ip-whitelist, --redirect-url, or --sso-url is required") { + if !strings.Contains(err.Error(), "至少提供一项安全配置:--ip-whitelist、--redirect-urls 或 --sso-urls") { t.Fatalf("error = %q", err.Error()) } if runner.last.Tool != "" { @@ -829,15 +899,15 @@ func TestDevAppMemberAndSecurityRequireWriteGuard(t *testing.T) { }{ { name: "member add", - args: []string{"devapp", "member", "add", "--app-id", "app-001", "--users", "userId1", "--member-type", "DEVELOPER"}, + args: []string{"dev", "app", "member", "add", "--unified-app-id", "app-001", "--user-ids", "userId1", "--member-type", "DEVELOPER"}, }, { name: "member remove", - args: []string{"devapp", "member", "remove", "--app-id", "app-001", "--users", "userId1", "--member-type", "DEVELOPER"}, + args: []string{"dev", "app", "member", "remove", "--unified-app-id", "app-001", "--user-ids", "userId1", "--member-type", "DEVELOPER"}, }, { name: "security config", - args: []string{"devapp", "security", "config", "--app-id", "app-001", "--redirect-url", "https://callback.example.invalid/callback"}, + args: []string{"dev", "app", "security", "config", "--unified-app-id", "app-001", "--redirect-urls", "https://callback.example.invalid/callback"}, }, } @@ -854,7 +924,7 @@ func TestDevAppMemberAndSecurityRequireWriteGuard(t *testing.T) { if err == nil { t.Fatal("Execute() error = nil, want write guard") } - if !strings.Contains(err.Error(), "write operation") { + if !strings.Contains(err.Error(), "写操作") { t.Fatalf("error = %q, want write guard", err.Error()) } if runner.last.Tool != "" { @@ -863,3 +933,80 @@ func TestDevAppMemberAndSecurityRequireWriteGuard(t *testing.T) { }) } } + +func TestDevAppUnwrapsSuccessfulServiceResult(t *testing.T) { + runner := &devAppResponseRunner{ + response: map[string]any{ + "content": map[string]any{ + "success": true, + "errorCode": nil, + "errorMsg": nil, + "result": map[string]any{ + "items": []any{ + map[string]any{"versionId": "v-1"}, + }, + "hasMore": false, + }, + }, + }, + } + root := newDevAppTestRoot(runner) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"dev", "app", "version", "list", "--unified-app-id", "u-1"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String()) + } + var rendered map[string]any + if err := json.Unmarshal(out.Bytes(), &rendered); err != nil { + t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String()) + } + if _, ok := rendered["success"]; ok { + t.Fatalf("output kept ServiceResult wrapper: %#v", rendered) + } + items, ok := rendered["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("items = %#v, want one item", rendered["items"]) + } +} + +func TestNormalizeDevAppRemovePermissionScopeValues(t *testing.T) { + result := executor.Result{ + Response: map[string]any{ + "content": map[string]any{ + "removedScopeValues": []any{ + map[string]any{"scopeValue": "qyapi_robot_sendmsg", "scopeName": "机器人发消息"}, + map[string]any{"scopeValue": "Contact.User.mobile", "scopeName": "手机号"}, + }, + }, + }, + } + + normalized := normalizeDevAppToolResult(devAppPermissionRmTool, result) + content := normalized.Response["content"].(map[string]any) + got := content["removedScopeValues"] + want := []string{"qyapi_robot_sendmsg", "Contact.User.mobile"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("removedScopeValues = %#v, want %#v", got, want) + } +} + +func TestNormalizeDevAppLifecycleResult(t *testing.T) { + disable := normalizeDevAppToolResult(devAppDisableTool, executor.Result{ + Response: map[string]any{"content": map[string]any{"deleted": false}}, + }) + disableContent := disable.Response["content"].(map[string]any) + if disabled, _ := disableContent["disabled"].(bool); !disabled { + t.Fatalf("disabled = %#v, want true", disableContent["disabled"]) + } + + enable := normalizeDevAppToolResult(devAppEnableTool, executor.Result{ + Response: map[string]any{"content": map[string]any{"deleted": false}}, + }) + enableContent := enable.Response["content"].(map[string]any) + if enabled, _ := enableContent["enabled"].(bool); !enabled { + t.Fatalf("enabled = %#v, want true", enableContent["enabled"]) + } +} diff --git a/internal/helpers/devdoc.go b/internal/helpers/devdoc.go index f8495532..b90bfe65 100644 --- a/internal/helpers/devdoc.go +++ b/internal/helpers/devdoc.go @@ -14,6 +14,8 @@ package helpers import ( + stderrors "errors" + "strconv" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" @@ -22,6 +24,12 @@ import ( "github.com/spf13/cobra" ) +const ( + devdocArticleSearchTool = "search_open_platform_docs_rag" + devdocArticleSearchLegacyTool = "search_open_platform_docs" + devdocErrorDiagnoseTool = "search_open_error_code_rag" +) + func init() { RegisterPublic(func() Handler { return devdocHandler{} @@ -57,22 +65,107 @@ func (devdocHandler) Command(runner executor.Runner) *cobra.Command { } article.AddCommand(newDevdocArticleSearchCommand(runner)) root.AddCommand(article) + errs := &cobra.Command{ + Use: "error", + Short: "错误排查", + Args: cobra.NoArgs, + TraverseChildren: true, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + errs.AddCommand(newDevdocErrorDiagnoseCommand(runner)) + root.AddCommand(errs) return root } func newDevdocArticleSearchCommand(runner executor.Runner) *cobra.Command { cmd := &cobra.Command{ - Use: "search [keyword]", - Short: "搜索开放平台文档", + Use: "search [query]", + Short: "搜索开放平台文档", + // Bind the devdoc MCP tool so `dws schema dev.doc.search` can fetch this + // command's real parameters live from the devdoc server (mcp-source), + // the same way dev app commands resolve against op-app. + Annotations: map[string]string{"mcp-tool": devdocArticleSearchTool, "mcp-source": "devdoc"}, Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - keyword := devdocFlagOrFallback(cmd, "query", "keyword") - if keyword == "" && len(args) > 0 { - keyword = strings.TrimSpace(args[0]) + query := devdocFlagOrFallback(cmd, "keyword", "query") + if query == "" && len(args) > 0 { + query = strings.TrimSpace(args[0]) } - if keyword == "" { - return apperrors.NewValidation("--query is required") + if query == "" { + return apperrors.NewValidation("--keyword is required") + } + page, _ := cmd.Flags().GetInt("page") + if page < 1 { + page = 1 + } + size := devdocPageSize(cmd) + cursor := devdocFlagOrFallback(cmd, "cursor") + return runDevdocToolWithFallback(cmd, runner, devdocArticleSearchTool, + devdocSearchParams(query, page, size, cursor), + devdocArticleSearchLegacyTool, + devdocLegacySearchParams(query, page, size, cursor)) + }, + } + preferLegacyLeaf(cmd) + // The published devdoc RAG action accepts keyword. Keep --query as a hidden + // compatibility alias for older scripts. + cmd.Flags().String("keyword", "", "搜索关键词 (必填)") + addDevdocHiddenStringFlag(cmd, "query", "--keyword 的兼容别名") + cmd.Flags().Int("page", 1, "分页页码 (从 1 开始,默认 1)") + cmd.Flags().String("cursor", "", "分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page") + cmd.Flags().Int("size", 20, "单页条数 (默认 20)") + cmd.Flags().Int("page-size", 0, "--size 的别名") + _ = cmd.Flags().MarkHidden("page-size") + return cmd +} + +// devdocPageSize reads the page size, accepting --size (primary, == MCP param) +// with --page-size as a compatibility alias; falls back to 20 when neither set. +func devdocPageSize(cmd *cobra.Command) int { + if cmd.Flags().Changed("size") { + if v, _ := cmd.Flags().GetInt("size"); v > 0 { + return v + } + } + if cmd.Flags().Changed("page-size") { + if v, _ := cmd.Flags().GetInt("page-size"); v > 0 { + return v + } + } + if v, _ := cmd.Flags().GetInt("size"); v > 0 { + return v + } + return 20 +} + +func newDevdocErrorDiagnoseCommand(runner executor.Runner) *cobra.Command { + cmd := &cobra.Command{ + Use: "diagnose", + Aliases: []string{"troubleshoot"}, + Short: "排查开放平台调用错误", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + errorCode := devdocFlagOrFallback(cmd, "error-code") + errorMessage := devdocFlagOrFallback(cmd, "error-message") + api := devdocFlagOrFallback(cmd, "api") + contextText := devdocFlagOrFallback(cmd, "context") + requestID := devdocFlagOrFallback(cmd, "request-id") + query := devdocFlagOrFallback(cmd, "query") + + parts := make([]string, 0, 4) + for _, part := range []string{query, errorMessage, api, contextText} { + if trimmed := strings.TrimSpace(part); trimmed != "" { + parts = append(parts, trimmed) + } + } + query = strings.Join(parts, " ") + if errorCode == "" && requestID == "" && query == "" { + return apperrors.NewValidation("--query, --error-code, or --request-id is required") } page, _ := cmd.Flags().GetInt("page") if page < 1 { @@ -82,22 +175,95 @@ func newDevdocArticleSearchCommand(runner executor.Runner) *cobra.Command { if size < 1 { size = 10 } - return runDevdocTool(cmd, runner, "search_open_platform_docs", map[string]any{ - "keyword": keyword, - "page": page, - "size": size, + cursor := devdocFlagOrFallback(cmd, "cursor") + + params := map[string]any{ + "size": size, + } + if cursor != "" { + params["cursor"] = cursor + } else { + params["page"] = page + } + if errorCode != "" { + params["errorCode"] = errorCode + } + if query != "" { + params["query"] = query + } + if requestID != "" { + params["requestId"] = requestID + } + + fallbackQuery := strings.TrimSpace(strings.Join([]string{errorCode, query, requestID}, " ")) + return runDevdocToolWithFallbacks(cmd, runner, devdocErrorDiagnoseTool, params, []devdocFallbackTool{ + {tool: devdocArticleSearchTool, params: devdocSearchParams(fallbackQuery, page, size, cursor)}, + {tool: devdocArticleSearchLegacyTool, params: devdocLegacySearchParams(fallbackQuery, page, size, cursor)}, }) }, } - preferLegacyLeaf(cmd) - cmd.Flags().String("query", "", "搜索关键词 (必填)") - addDevdocHiddenStringFlag(cmd, "keyword", "--query 的悟空兼容别名") + cmd.Flags().String("query", "", "原始排查问题") + cmd.Flags().String("error-code", "", "错误码") + cmd.Flags().String("error-message", "", "错误描述,会合并进原始问题") + cmd.Flags().String("api", "", "API 名称,会合并进原始问题作为补充检索词") + cmd.Flags().String("context", "", "额外排查上下文,会合并进原始问题") + cmd.Flags().String("request-id", "", "开放平台 requestId") cmd.Flags().Int("page", 1, "分页页码 (从 1 开始,默认 1)") + cmd.Flags().String("cursor", "", "分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page") cmd.Flags().Int("size", 10, "分页大小 (默认 10)") return cmd } +func devdocSearchParams(query string, page int, size int, cursor string) map[string]any { + params := map[string]any{ + "keyword": query, + "size": size, + } + if cursor != "" { + params["cursor"] = cursor + } else { + params["page"] = page + } + return params +} + +func devdocLegacySearchParams(keyword string, page int, size int, cursor string) map[string]any { + return map[string]any{ + "keyword": keyword, + "page": devdocPageFromCursor(page, cursor), + "size": size, + } +} + +func devdocPageFromCursor(page int, cursor string) int { + if cursor == "" { + return page + } + cursorPage, err := strconv.Atoi(cursor) + if err == nil && cursorPage > 0 { + return cursorPage + } + return page +} + func runDevdocTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error { + return runDevdocToolWithFallback(cmd, runner, tool, params, "", nil) +} + +type devdocFallbackTool struct { + tool string + params map[string]any +} + +func runDevdocToolWithFallback(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any, fallbackTool string, fallbackParams map[string]any) error { + var fallbacks []devdocFallbackTool + if fallbackTool != "" { + fallbacks = append(fallbacks, devdocFallbackTool{tool: fallbackTool, params: fallbackParams}) + } + return runDevdocToolWithFallbacks(cmd, runner, tool, params, fallbacks) +} + +func runDevdocToolWithFallbacks(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any, fallbacks []devdocFallbackTool) error { invocation := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "devdoc", @@ -108,12 +274,103 @@ func runDevdocTool(cmd *cobra.Command, runner executor.Runner, tool string, para return writeCommandPayload(cmd, invocation) } result, err := runner.Run(cmd.Context(), invocation) + currentTool := tool + for _, fallback := range fallbacks { + if err == nil && !shouldFallbackDevdocResult(currentTool, result) { + break + } + if err != nil && !isDevdocToolNotFoundError(err) { + break + } + if fallback.tool == "" { + continue + } + fallbackParams := fallback.params + if fallbackParams == nil { + fallbackParams = params + } + fallbackInvocation := executor.NewHelperInvocation( + cobracmd.LegacyCommandPath(cmd), + "devdoc", + fallback.tool, + fallbackParams, + ) + result, err = runner.Run(cmd.Context(), fallbackInvocation) + currentTool = fallback.tool + } if err != nil { return err } return writeCommandPayload(cmd, result) } +func shouldFallbackDevdocResult(tool string, result executor.Result) bool { + if tool != devdocArticleSearchTool || !result.Invocation.Implemented { + return false + } + content, ok := result.Response["content"].(map[string]any) + if !ok { + return false + } + return isEmptyDevdocArticleRAGContent(content) +} + +func isEmptyDevdocArticleRAGContent(content map[string]any) bool { + if hasDevdocArticleRAGPayload(content) { + return false + } + for _, key := range []string{"materials", "references", "ragContext", "result", "success"} { + if _, ok := content[key]; ok { + return true + } + } + return false +} + +func hasDevdocArticleRAGPayload(content map[string]any) bool { + if hasNonEmptyList(content["materials"]) || hasNonEmptyList(content["references"]) || hasNonEmptyString(content["ragContext"]) { + return true + } + result, _ := content["result"].(map[string]any) + return hasNonEmptyList(result["items"]) || hasNonEmptyList(result["materials"]) || + hasNonEmptyList(result["references"]) || hasNonEmptyString(result["ragContext"]) +} + +func hasNonEmptyList(value any) bool { + items, ok := value.([]any) + return ok && len(items) > 0 +} + +func hasNonEmptyString(value any) bool { + text, ok := value.(string) + return ok && strings.TrimSpace(text) != "" +} + +func isDevdocToolNotFoundError(err error) bool { + if err == nil { + return false + } + msg := normalizedDevdocErrorText(err) + return strings.Contains(msg, "tool not found") || + strings.Contains(msg, "unknown tool") || + strings.Contains(msg, "mcp_tool_not_found") || + strings.Contains(msg, "未找到指定工具") +} + +func normalizedDevdocErrorText(err error) string { + parts := []string{strings.ToLower(err.Error())} + var typed *apperrors.Error + if stderrors.As(err, &typed) && typed != nil { + parts = append(parts, + strings.ToLower(typed.Reason), + strings.ToLower(typed.ServerDiag.ServerErrorCode), + strings.ToLower(typed.ServerDiag.TechnicalDetail), + strings.ToLower(typed.Hint), + ) + } + return strings.Join(parts, " ") +} + func devdocFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string { names := append([]string{primary}, aliases...) for _, name := range names { diff --git a/internal/helpers/devdoc_test.go b/internal/helpers/devdoc_test.go index 96c042f7..8fa9b478 100644 --- a/internal/helpers/devdoc_test.go +++ b/internal/helpers/devdoc_test.go @@ -16,17 +16,37 @@ package helpers import ( "bytes" "context" + "errors" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" ) type devdocCommandRunner struct { - last executor.Invocation + last executor.Invocation + calls []executor.Invocation + errors map[string]error + responses map[string]map[string]any } func (r *devdocCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) { r.last = invocation + r.calls = append(r.calls, invocation) + if r.errors != nil { + if err := r.errors[invocation.Tool]; err != nil { + return executor.Result{}, err + } + } + if r.responses != nil { + if content, ok := r.responses[invocation.Tool]; ok { + invocation.Implemented = true + return executor.Result{ + Invocation: invocation, + Response: map[string]any{"content": content}, + }, nil + } + } return executor.Result{Invocation: invocation}, nil } @@ -38,23 +58,326 @@ func TestDevdocArticleSearchAcceptsWukongKeywordAlias(t *testing.T) { var out, errOut bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&errOut) - cmd.SetArgs([]string{"article", "search", "--keyword", "openConversationId", "--page", "2", "--size", "5"}) + cmd.SetArgs([]string{"article", "search", "--keyword", "openConversationId", "--cursor", "tok-d", "--page-size", "5"}) if err := cmd.Execute(); err != nil { t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String()) } - if runner.last.Tool != "search_open_platform_docs" { - t.Fatalf("tool = %q, want search_open_platform_docs", runner.last.Tool) + if runner.last.Tool != devdocArticleSearchTool { + t.Fatalf("tool = %q, want %s", runner.last.Tool, devdocArticleSearchTool) } if got := runner.last.Params["keyword"]; got != "openConversationId" { t.Fatalf("keyword = %#v, want openConversationId", got) } - if got := runner.last.Params["page"]; got != 2 { - t.Fatalf("page = %#v, want 2", got) + if _, ok := runner.last.Params["query"]; ok { + t.Fatalf("query must not be sent to article RAG search: %#v", runner.last.Params) + } + if got := runner.last.Params["cursor"]; got != "tok-d" { + t.Fatalf("cursor = %#v, want tok-d", got) } if got := runner.last.Params["size"]; got != 5 { t.Fatalf("size = %#v, want 5", got) } + if _, ok := runner.last.Params["pageSize"]; ok { + t.Fatalf("pageSize must not be sent to RAG search: %#v", runner.last.Params) + } + if _, ok := runner.last.Params["CliRagSearchReqVO"]; ok { + t.Fatalf("CliRagSearchReqVO wrapper must not be sent to RAG search: %#v", runner.last.Params) + } +} + +func TestDevdocArticleSearchPassesCursor(t *testing.T) { + t.Parallel() + + runner := &devdocCommandRunner{} + cmd := devdocHandler{}.Command(runner) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"article", "search", "--query", "Webhook", "--cursor", "3", "--size", "5"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String()) + } + if runner.last.Tool != devdocArticleSearchTool { + t.Fatalf("tool = %q, want %s", runner.last.Tool, devdocArticleSearchTool) + } + if got := runner.last.Params["cursor"]; got != "3" { + t.Fatalf("cursor = %#v, want 3", got) + } + if _, ok := runner.last.Params["page"]; ok { + t.Fatalf("page must be omitted when cursor is set: %#v", runner.last.Params) + } +} + +func TestDevdocErrorDiagnoseBuildsErrorSearchQuery(t *testing.T) { + t.Parallel() + + runner := &devdocCommandRunner{} + cmd := devdocHandler{}.Command(runner) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{ + "error", "diagnose", + "--error-code", "40014", + "--error-message", "不合法的access_token", + "--api", "获取用户信息", + "--cursor", "2", + "--page", "2", + "--size", "3", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String()) + } + if runner.last.Tool != devdocErrorDiagnoseTool { + t.Fatalf("tool = %q, want %s", runner.last.Tool, devdocErrorDiagnoseTool) + } + if got := runner.last.Params["errorCode"]; got != "40014" { + t.Fatalf("errorCode = %#v, want 40014", got) + } + if got := runner.last.Params["query"]; got != "不合法的access_token 获取用户信息" { + t.Fatalf("query = %#v, want merged message and api", got) + } + if _, ok := runner.last.Params["page"]; ok { + t.Fatalf("page must be omitted when cursor is set: %#v", runner.last.Params) + } + if got := runner.last.Params["cursor"]; got != "2" { + t.Fatalf("cursor = %#v, want 2", got) + } + if got := runner.last.Params["size"]; got != 3 { + t.Fatalf("size = %#v, want 3", got) + } +} + +func TestDevdocErrorDiagnoseFallsBackToArticleSearchTools(t *testing.T) { + t.Parallel() + + runner := &devdocCommandRunner{ + errors: map[string]error{ + devdocErrorDiagnoseTool: errors.New("PARAM_ERROR - 未找到指定工具"), + devdocArticleSearchTool: errors.New("unknown tool"), + }, + } + cmd := devdocHandler{}.Command(runner) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{ + "error", "diagnose", + "--error-code", "40014", + "--query", "获取用户信息", + "--request-id", "req-1", + "--cursor", "2", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String()) + } + if len(runner.calls) != 3 { + t.Fatalf("calls = %d, want 3", len(runner.calls)) + } + if got := runner.calls[0].Tool; got != devdocErrorDiagnoseTool { + t.Fatalf("first tool = %q, want %s", got, devdocErrorDiagnoseTool) + } + if got := runner.calls[1].Tool; got != devdocArticleSearchTool { + t.Fatalf("second tool = %q, want %s", got, devdocArticleSearchTool) + } + if got := runner.calls[1].Params["cursor"]; got != "2" { + t.Fatalf("RAG fallback cursor = %#v, want 2", got) + } + if got := runner.calls[1].Params["keyword"]; got != "40014 获取用户信息 req-1" { + t.Fatalf("RAG fallback keyword = %#v, want merged diagnostic query", got) + } + if _, ok := runner.calls[1].Params["query"]; ok { + t.Fatalf("RAG fallback query must not be sent: %#v", runner.calls[1].Params) + } + if _, ok := runner.calls[1].Params["page"]; ok { + t.Fatalf("RAG fallback page must be omitted when cursor is set: %#v", runner.calls[1].Params) + } + if got := runner.calls[2].Tool; got != devdocArticleSearchLegacyTool { + t.Fatalf("third tool = %q, want %s", got, devdocArticleSearchLegacyTool) + } + if got := runner.last.Params["keyword"]; got != "40014 获取用户信息 req-1" { + t.Fatalf("fallback keyword = %#v, want merged diagnostic keyword", got) + } + if _, ok := runner.last.Params["cursor"]; ok { + t.Fatalf("legacy fallback params must not include cursor: %#v", runner.last.Params) + } + if got := runner.last.Params["page"]; got != 2 { + t.Fatalf("legacy fallback page = %#v, want cursor-derived page 2", got) + } +} + +func TestDevdocArticleSearchFallsBackOnStructuredToolMetadataError(t *testing.T) { + t.Parallel() + + runner := &devdocCommandRunner{ + errors: map[string]error{ + devdocArticleSearchTool: apperrors.NewAPI("business error: success=false", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: "PARAM_ERROR", + TechnicalDetail: "Tool metadata API error: PARAM_ERROR - 未找到指定工具", + }), + ), + }, + } + cmd := devdocHandler{}.Command(runner) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"article", "search", "--query", "Webhook", "--cursor", "3"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String()) + } + if len(runner.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(runner.calls)) + } + if got := runner.calls[0].Tool; got != devdocArticleSearchTool { + t.Fatalf("first tool = %q, want %s", got, devdocArticleSearchTool) + } + if got := runner.calls[1].Tool; got != devdocArticleSearchLegacyTool { + t.Fatalf("second tool = %q, want %s", got, devdocArticleSearchLegacyTool) + } + if got := runner.calls[1].Params["page"]; got != 3 { + t.Fatalf("legacy fallback page = %#v, want cursor-derived page 3", got) + } +} + +func TestDevdocArticleSearchDoesNotFallbackOnGenericToolMetadataError(t *testing.T) { + t.Parallel() + + runner := &devdocCommandRunner{ + errors: map[string]error{ + devdocArticleSearchTool: apperrors.NewAPI("business error: success=false", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: "PARAM_ERROR", + TechnicalDetail: "Tool metadata API error: PARAM_ERROR - 参数不能为空", + }), + ), + }, + } + cmd := devdocHandler{}.Command(runner) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"article", "search", "--query", "Webhook"}) + + if err := cmd.Execute(); err == nil { + t.Fatalf("Execute() error = nil, want metadata PARAM_ERROR") + } + if len(runner.calls) != 1 { + t.Fatalf("calls = %d, want 1", len(runner.calls)) + } + if got := runner.calls[0].Tool; got != devdocArticleSearchTool { + t.Fatalf("first tool = %q, want %s", got, devdocArticleSearchTool) + } +} + +func TestDevdocArticleSearchFallsBackOnEmptyRAGContent(t *testing.T) { + t.Parallel() + + runner := &devdocCommandRunner{ + responses: map[string]map[string]any{ + devdocArticleSearchTool: { + "materials": []any{}, + "references": []any{}, + "ragContext": nil, + }, + devdocArticleSearchLegacyTool: { + "success": true, + "result": map[string]any{ + "items": []any{map[string]any{"title": "OAuth2.0鉴权 - 开放平台"}}, + }, + }, + }, + } + cmd := devdocHandler{}.Command(runner) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"article", "search", "--keyword", "OAuth2", "--size", "2"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String()) + } + if len(runner.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(runner.calls)) + } + if got := runner.calls[0].Tool; got != devdocArticleSearchTool { + t.Fatalf("first tool = %q, want %s", got, devdocArticleSearchTool) + } + if got := runner.calls[1].Tool; got != devdocArticleSearchLegacyTool { + t.Fatalf("second tool = %q, want %s", got, devdocArticleSearchLegacyTool) + } +} + +func TestDevdocArticleSearchFallsBackOnPaginationOnlyRAGContent(t *testing.T) { + t.Parallel() + + runner := &devdocCommandRunner{ + responses: map[string]map[string]any{ + devdocArticleSearchTool: { + "success": true, + "result": map[string]any{"nextCursor": "2"}, + }, + devdocArticleSearchLegacyTool: { + "success": true, + "result": map[string]any{ + "items": []any{map[string]any{"title": "获取用户token - 开放平台"}}, + }, + }, + }, + } + cmd := devdocHandler{}.Command(runner) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"article", "search", "--query", "OAuth2", "--cursor", "3"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String()) + } + if len(runner.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(runner.calls)) + } + if got := runner.calls[1].Tool; got != devdocArticleSearchLegacyTool { + t.Fatalf("second tool = %q, want %s", got, devdocArticleSearchLegacyTool) + } + if got := runner.calls[1].Params["page"]; got != 3 { + t.Fatalf("legacy fallback page = %#v, want cursor-derived page 3", got) + } +} + +func TestDevdocArticleSearchKeepsNonEmptyRAGContent(t *testing.T) { + t.Parallel() + + runner := &devdocCommandRunner{ + responses: map[string]map[string]any{ + devdocArticleSearchTool: { + "materials": []any{map[string]any{"title": "OAuth2.0鉴权 - 开放平台"}}, + }, + }, + } + cmd := devdocHandler{}.Command(runner) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs([]string{"article", "search", "--keyword", "OAuth2"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String()) + } + if len(runner.calls) != 1 { + t.Fatalf("calls = %d, want 1", len(runner.calls)) + } + if got := runner.calls[0].Tool; got != devdocArticleSearchTool { + t.Fatalf("first tool = %q, want %s", got, devdocArticleSearchTool) + } } func TestDevdocArticleSearchAcceptsPositionalKeyword(t *testing.T) { @@ -73,4 +396,7 @@ func TestDevdocArticleSearchAcceptsPositionalKeyword(t *testing.T) { if got := runner.last.Params["keyword"]; got != "MCP" { t.Fatalf("keyword = %#v, want MCP", got) } + if _, ok := runner.last.Params["query"]; ok { + t.Fatalf("query must not be sent to article RAG search: %#v", runner.last.Params) + } } diff --git a/internal/market/registry.go b/internal/market/registry.go index 78bd0950..50f0c308 100644 --- a/internal/market/registry.go +++ b/internal/market/registry.go @@ -668,7 +668,12 @@ func isPrivateHost(host string) bool { } func NormalizeServers(response ListResponse, source string) []ServerDescriptor { + return NormalizeServersForBaseURL(response, source, "") +} + +func NormalizeServersForBaseURL(response ListResponse, source string, discoveryBaseURL string) []ServerDescriptor { bestByEndpoint := make(map[string]ServerDescriptor) + forcePreGateway := isPreMCPBaseURL(discoveryBaseURL) for _, envelope := range response.Servers { meta := envelope.Meta.Registry @@ -681,7 +686,7 @@ func NormalizeServers(response ListResponse, source string) []ServerDescriptor { continue } - endpoint := NormalizeEndpoint(remoteURL) + endpoint := normalizeEndpoint(remoteURL, forcePreGateway) descriptor := ServerDescriptor{ Key: ServerKey(endpoint), DisplayName: strings.TrimSpace(envelope.Server.Name), @@ -822,10 +827,15 @@ func hasDeprecatedMarker(displayName string) bool { } func NormalizeEndpoint(raw string) string { + return normalizeEndpoint(raw, false) +} + +func normalizeEndpoint(raw string, forcePreGateway bool) string { parsed, err := url.Parse(raw) if err != nil { return strings.TrimSpace(raw) } + normalizeGatewayHost(parsed, forcePreGateway) parsed.Fragment = "" parsed.Path = strings.TrimRight(parsed.Path, "/") values := parsed.Query() @@ -846,6 +856,68 @@ func NormalizeEndpoint(raw string) string { return parsed.String() } +func normalizeGatewayHost(parsed *url.URL, forcePreGateway bool) { + if parsed == nil { + return + } + host := parsed.Hostname() + nextHost, ok := normalizedGatewayHost(host, forcePreGateway) + if !ok { + return + } + if port := parsed.Port(); port != "" { + parsed.Host = net.JoinHostPort(nextHost, port) + } else { + parsed.Host = nextHost + } +} + +func normalizedGatewayHost(host string, forcePreGateway bool) (string, bool) { + host = strings.TrimSpace(strings.ToLower(host)) + if host == "" { + return "", false + } + if forcePreGateway { + switch { + case host == "mcp.dingtalk.com" || host == "mcp-gw.dingtalk.com" || + host == "pre-mcp.dingtalk.com" || host == "pre-mcp-gw.dingtalk.com": + return "pre-mcp-gw.dingtalk.com", true + case strings.HasPrefix(host, "pre-mcp-gw."): + return host, true + case strings.HasPrefix(host, "pre-mcp."): + return strings.Replace(host, "pre-mcp.", "pre-mcp-gw.", 1), true + case strings.HasPrefix(host, "mcp-gw."): + return strings.Replace(host, "mcp-gw.", "pre-mcp-gw.", 1), true + case strings.HasPrefix(host, "mcp."): + return strings.Replace(host, "mcp.", "pre-mcp-gw.", 1), true + default: + return "", false + } + } + switch { + case host == "mcp.dingtalk.com": + return "mcp-gw.dingtalk.com", true + case strings.HasPrefix(host, "pre-mcp."): + return strings.Replace(host, "pre-mcp.", "pre-mcp-gw.", 1), true + case strings.HasPrefix(host, "mcp."): + return strings.Replace(host, "mcp.", "mcp-gw.", 1), true + default: + return "", false + } +} + +func isPreMCPBaseURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return false + } + host := strings.ToLower(parsed.Hostname()) + return host == "pre-mcp.dingtalk.com" || + host == "pre-mcp-gw.dingtalk.com" || + strings.HasPrefix(host, "pre-mcp.") || + strings.HasPrefix(host, "pre-mcp-gw.") +} + func ServerKey(endpoint string) string { sum := sha256.Sum256([]byte(endpoint)) return hex.EncodeToString(sum[:8]) diff --git a/internal/market/registry_test.go b/internal/market/registry_test.go index 5b030104..577f4c47 100644 --- a/internal/market/registry_test.go +++ b/internal/market/registry_test.go @@ -101,6 +101,74 @@ func TestNormalizeServersDeduplicatesSameNameAcrossEndpoints(t *testing.T) { } } +func TestNormalizeServersConvertsPreMCPRemoteToGatewayEndpoint(t *testing.T) { + t.Parallel() + + response := ListResponse{ + Servers: []ServerEnvelope{ + { + Server: RegistryServer{ + Name: "钉钉开放平台文档搜索", + Remotes: []RegistryRemote{ + {Type: "streamable-http", URL: "https://pre-mcp.dingtalk.com/server/devdoc?key=b&key=a#fragment"}, + }, + }, + Meta: EnvelopeMeta{ + Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-06-16T00:00:00Z"}, + CLI: CLIOverlay{ID: "devdoc", Command: "devdoc"}, + }, + }, + }, + } + + servers := NormalizeServers(response, "live_market") + if len(servers) != 1 { + t.Fatalf("NormalizeServers() len = %d, want 1", len(servers)) + } + want := "https://pre-mcp-gw.dingtalk.com/server/devdoc?key=a&key=b" + if servers[0].Endpoint != want { + t.Fatalf("NormalizeServers() endpoint = %q, want %q", servers[0].Endpoint, want) + } +} + +func TestNormalizeServersForBaseURLForcesPreGatewayEndpoint(t *testing.T) { + t.Parallel() + + response := ListResponse{ + Servers: []ServerEnvelope{ + { + Server: RegistryServer{ + Name: "钉钉文档", + Remotes: []RegistryRemote{ + {Type: "streamable-http", URL: "https://mcp-gw.dingtalk.com/server/doc?key=b&key=a#fragment"}, + }, + }, + Meta: EnvelopeMeta{ + Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-06-16T00:00:00Z"}, + CLI: CLIOverlay{ID: "doc", Command: "doc"}, + }, + }, + }, + } + + prodServers := NormalizeServers(response, "live_market") + if len(prodServers) != 1 { + t.Fatalf("NormalizeServers() len = %d, want 1", len(prodServers)) + } + if want := "https://mcp-gw.dingtalk.com/server/doc?key=a&key=b"; prodServers[0].Endpoint != want { + t.Fatalf("NormalizeServers() endpoint = %q, want %q", prodServers[0].Endpoint, want) + } + + preServers := NormalizeServersForBaseURL(response, "live_market", "https://pre-mcp.dingtalk.com") + if len(preServers) != 1 { + t.Fatalf("NormalizeServersForBaseURL() len = %d, want 1", len(preServers)) + } + want := "https://pre-mcp-gw.dingtalk.com/server/doc?key=a&key=b" + if preServers[0].Endpoint != want { + t.Fatalf("NormalizeServersForBaseURL() endpoint = %q, want %q", preServers[0].Endpoint, want) + } +} + // TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint guards the // bot-root / bot-message / bot-group split (issue: chat bot subtree vanished // from the CLI after NormalizeServers collapsed three envelopes that share a diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index c5cafdcb..2c14d9ab 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -25,6 +25,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -58,23 +59,25 @@ func resolveSessionIDFromEnv() string { return "" } -// agentCodeEnv is the canonical (and only) environment variable name -// used as a per-shell fallback for the --agentCode flag on `dws pat *` -// commands. +// agentCodeEnv is the canonical environment variable name used as a +// per-shell fallback for the --agentCode flag on `dws pat *` commands. // // Why: agent hosts may set their business agent code once when spawning // a long-lived shell / sub-process. Exposing DINGTALK_DWS_AGENTCODE lets // the host export the code once and let the CLI resolve it on every pat // subcommand. The flag always wins when both are set so scripted one-offs -// remain deterministic. When neither flag nor env is set, the request is -// sent without agentCode and lippi-pat-core applies its default agentCode. +// remain deterministic. When neither flag nor env is set, `pat chmod` fails +// locally instead of letting the server reject the request later. Batch PAT +// tools receive the resolved agentCode in arguments, while the CLI also keeps +// exporting it through env for older gateway paths. // -// Namespace note: DWS_AGENTCODE / DINGTALK_AGENTCODE / REWIND_AGENTCODE -// are explicitly NOT consumed. The legacy DWS_AGENTCODE alias was -// hard-removed once the public integration surface landed on -// DINGTALK_DWS_AGENTCODE; hosts must migrate rather than rely on a -// silent fallback. -const agentCodeEnv = "DINGTALK_DWS_AGENTCODE" +// Namespace note: DWS_DINGTALK_AGENTCODE is kept as a compatibility alias for +// hosts that shipped the reversed prefix early. DWS_AGENTCODE / +// DINGTALK_AGENTCODE / REWIND_AGENTCODE are explicitly NOT consumed. +const ( + agentCodeEnv = authpkg.AgentCodeEnv + agentCodeEnvCompat = authpkg.AgentCodeEnvCompat +) // agentCodePattern is the validation regex for any --agentCode value // resolved from either the flag or the agent-code env var. It matches @@ -84,16 +87,12 @@ const agentCodeEnv = "DINGTALK_DWS_AGENTCODE" // argument. var agentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) -// resolveAgentCodeFromEnv returns the fallback agent code from the -// canonical DINGTALK_DWS_AGENTCODE env var. The second return value -// reports the env name that was consumed (for error attribution); it -// is "" when the env is unset or blank. No legacy aliases are honored. +// resolveAgentCodeFromEnv returns the fallback agent code from the canonical +// DINGTALK_DWS_AGENTCODE env var, then the DWS_DINGTALK_AGENTCODE compatibility +// alias. The second return value reports the env name that was consumed (for +// error attribution); it is "" when both env vars are unset or blank. func resolveAgentCodeFromEnv() (string, string) { - primary := strings.TrimSpace(os.Getenv(agentCodeEnv)) - if primary != "" { - return primary, agentCodeEnv - } - return "", "" + return authpkg.AgentCodeFromEnv() } // validateAgentCode rejects agent codes that would be ambiguous or unsafe @@ -116,7 +115,8 @@ func validateAgentCode(code string) error { // // 1. explicit --agentCode flag value (highest priority; wins over env) // 2. DINGTALK_DWS_AGENTCODE env var (per-shell primary fallback) -// 3. empty ("") when required=false; typed error when required=true. +// 3. DWS_DINGTALK_AGENTCODE env var (compatibility fallback) +// 4. empty ("") when required=false; typed error when required=true. // // Any non-empty resolved value is validated via validateAgentCode, so // callers never have to re-validate. @@ -128,17 +128,30 @@ func resolveAgentCode(flagVal string, required bool) (string, error) { } if code == "" { if required { - return "", fmt.Errorf( - "flag --agentCode is required (or set env %s)\n hint: dws pat chmod ... --agentCode \n hint: export %s=", - agentCodeEnv, agentCodeEnv) + return "", apperrors.NewValidation( + fmt.Sprintf("flag --agentCode is required (or set env %s or %s)", agentCodeEnv, agentCodeEnvCompat), + apperrors.WithReason("missing_agent_code"), + apperrors.WithHint(fmt.Sprintf("dws pat chmod ... --agentCode \n or: export %s=", agentCodeEnv)), + apperrors.WithActions( + "dws pat chmod ... --agentCode ", + fmt.Sprintf("export %s=", agentCodeEnv), + fmt.Sprintf("export %s=", agentCodeEnvCompat), + ), + ) } return "", nil } if err := validateAgentCode(code); err != nil { if envSource != "" { - return "", fmt.Errorf("%s env: %w", envSource, err) + return "", apperrors.NewValidation( + fmt.Sprintf("%s env: %v", envSource, err), + apperrors.WithReason("invalid_agent_code"), + ) } - return "", err + return "", apperrors.NewValidation( + err.Error(), + apperrors.WithReason("invalid_agent_code"), + ) } return code, nil } @@ -159,8 +172,25 @@ const ( patBatchUnsupportedCode = "PAT_BATCH_AUTH_UNSUPPORTED" patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" + patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD" + patForgedIdentityCodeLower = "pat_forged_identity_field" ) +var patBatchMetadataContractCodes = map[string]bool{ + "pat_batch_auth_metadata_required": true, + "pat_batch_scope_not_declared": true, + "pat_batch_product_not_declared": true, +} + +var patBatchIdentityArgumentKeys = map[string]bool{ + "agentCode": true, + "sessionId": true, + "orgId": true, + "uid": true, + "source": true, + "caller": true, +} + var validGrantTypes = map[string]bool{ "once": true, "session": true, @@ -204,7 +234,7 @@ grantType 规则: dws pat chmod --recommend --grant-type session --session-id session-xxx`, RunE: func(cmd *cobra.Command, args []string) error { flagVal, _ := cmd.Flags().GetString("agentCode") - agentCode, err := resolveAgentCode(flagVal, false) + agentCode, err := resolveAgentCode(flagVal, true) if err != nil { return err } @@ -239,8 +269,6 @@ grantType 规则: fmt.Printf("%-16s%s\n", "Tool:", patBatchGrantToolName) if agentCode != "" { fmt.Printf("%-16s%s\n", "AgentCode:", agentCode) - } else { - fmt.Printf("%-16s%s\n", "AgentCode:", "(server default)") } fmt.Printf("%-16s%v\n", "Scope:", scopes) fmt.Printf("%-16s%s\n", "GrantType:", grantType) @@ -315,7 +343,7 @@ grantType 规则: } chmodCmd.Flags().String("agentCode", "", - "Agent 唯一标识(可选;不填则由服务端写入默认 AgentCode;env DINGTALK_DWS_AGENTCODE 可注入,flag 优先)") + "Agent 唯一标识(必填;也可通过 env DINGTALK_DWS_AGENTCODE/DWS_DINGTALK_AGENTCODE 注入,flag 优先;会进入 batch 参数并同步注入兼容 env)") chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent") chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)") chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价") @@ -388,13 +416,11 @@ func callPATBatchGrantWithLegacyFallback( if c == nil { return nil, fmt.Errorf("internal error: tool runtime not initialized") } - result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { - return c.CallTool(ctx, "pat", patBatchGrantToolName, batchArgs) - }) - if err == nil && !isPATBatchUnsupportedResult(result) { + result, err := callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchGrantToolName, batchArgs) + if err == nil && !isPATBatchFallbackResult(result) { return result, nil } - if err != nil && !isPATBatchUnsupportedError(err) && !isToolNotRegisteredError(err) { + if err != nil && !isPATBatchFallbackError(err) && !isToolNotRegisteredError(err) { return nil, err } return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { @@ -414,8 +440,19 @@ func callPATBatchPlan(ctx context.Context, c edition.ToolCaller, agentCode, sess if c == nil { return nil, fmt.Errorf("internal error: tool runtime not initialized") } + return callPATBatchToolWithIdentityFallback(ctx, c, agentCode, sessionID, patBatchPlanToolName, args) +} + +func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCaller, agentCode, sessionID, toolName string, args map[string]any) (*edition.ToolResult, error) { + result, err := withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { + return c.CallTool(ctx, "pat", toolName, args) + }) + if !shouldRetryPATBatchWithoutIdentityArgs(result, err, args) { + return result, err + } + compatArgs := cloneWithoutPATIdentityArgs(args) return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { - return c.CallTool(ctx, "pat", patBatchPlanToolName, args) + return c.CallTool(ctx, "pat", toolName, compatArgs) }) } @@ -485,6 +522,25 @@ func firstToolResultText(result *edition.ToolResult) string { } func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { + return patBatchResultHasCode(result, func(code string) bool { + return strings.EqualFold(code, patBatchUnsupportedCode) + }) +} + +func isPATBatchFallbackResult(result *edition.ToolResult) bool { + return patBatchResultHasCode(result, func(code string) bool { + normalized := strings.ToLower(strings.TrimSpace(code)) + return strings.EqualFold(code, patBatchUnsupportedCode) || patBatchMetadataContractCodes[normalized] + }) +} + +func isPATForgedIdentityResult(result *edition.ToolResult) bool { + return patBatchResultHasCode(result, func(code string) bool { + return strings.EqualFold(code, patForgedIdentityCode) + }) +} + +func patBatchResultHasCode(result *edition.ToolResult, matches func(string) bool) bool { text := firstToolResultText(result) if text == "" { return false @@ -494,7 +550,7 @@ func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { return false } for _, key := range []string{"code", "errorCode", "error_code"} { - if code, ok := body[key].(string); ok && strings.EqualFold(strings.TrimSpace(code), patBatchUnsupportedCode) { + if code, ok := body[key].(string); ok && matches(strings.TrimSpace(code)) { return true } } @@ -505,6 +561,53 @@ func isPATBatchUnsupportedError(err error) bool { return err != nil && strings.Contains(normalizedPATErrorText(err), patBatchUnsupportedCodeLower) } +func isPATBatchFallbackError(err error) bool { + if isPATBatchUnsupportedError(err) { + return true + } + text := normalizedPATErrorText(err) + for code := range patBatchMetadataContractCodes { + if strings.Contains(text, code) { + return true + } + } + return false +} + +func isPATForgedIdentityError(err error) bool { + return err != nil && strings.Contains(normalizedPATErrorText(err), patForgedIdentityCodeLower) +} + +func shouldRetryPATBatchWithoutIdentityArgs(result *edition.ToolResult, err error, args map[string]any) bool { + if !hasPATIdentityArgs(args) { + return false + } + if err != nil { + return isPATForgedIdentityError(err) + } + return isPATForgedIdentityResult(result) +} + +func hasPATIdentityArgs(args map[string]any) bool { + for key := range args { + if patBatchIdentityArgumentKeys[key] { + return true + } + } + return false +} + +func cloneWithoutPATIdentityArgs(args map[string]any) map[string]any { + out := make(map[string]any, len(args)) + for key, value := range args { + if patBatchIdentityArgumentKeys[key] { + continue + } + out[key] = value + } + return out +} + // callPATToolWithLegacyFallback invokes the canonical PAT grant tool first, // then silently retries the legacy Chinese alias when the server has not // registered the canonical tool yet. The retry intentionally emits no stderr diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 57443096..d7441cd4 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -68,8 +68,11 @@ func (f *fakeToolCaller) Format() string { return "json" } func (f *fakeToolCaller) DryRun() bool { return f.dryRun } type recordedToolCall struct { - tool string - args map[string]any + tool string + args map[string]any + agentEnv string + sessionEnv string + dingSessionEnv string } type fallbackToolCaller struct { @@ -198,6 +201,7 @@ func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false } type sequenceToolCaller struct { calls []recordedToolCall responses []string + errs []error dryRun bool } @@ -207,6 +211,12 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri copied[k] = v } s.calls = append(s.calls, recordedToolCall{tool: toolName, args: copied}) + s.calls[len(s.calls)-1].agentEnv = os.Getenv(agentCodeEnv) + s.calls[len(s.calls)-1].sessionEnv = os.Getenv(sessionIDEnvDWS) + s.calls[len(s.calls)-1].dingSessionEnv = os.Getenv(sessionIDEnvDingtalk) + if len(s.errs) >= len(s.calls) && s.errs[len(s.calls)-1] != nil { + return nil, s.errs[len(s.calls)-1] + } response := `{"success":true,"data":{}}` if len(s.responses) >= len(s.calls) { response = s.responses[len(s.calls)-1] @@ -305,8 +315,11 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { if got := fake.calls[0].args["recommend"]; got != false { t.Fatalf("recommend = %#v, want false", got) } + if got := fake.calls[0].agentEnv; got != "qoderwork" { + t.Fatalf("plan agent env = %q, want qoderwork", got) + } if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { - t.Fatalf("plan agentCode = %#v, want qoderwork", got) + t.Fatalf("batch plan agentCode = %#v, want qoderwork", got) } if fake.calls[1].tool != patBatchGrantToolName { t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) @@ -314,12 +327,15 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "aitable.record:read"}) { t.Fatalf("grant scopes = %#v, want selected scopes", got) } + if got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("grant agent env = %q, want qoderwork", got) + } if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { - t.Fatalf("grant agentCode = %#v, want qoderwork", got) + t.Fatalf("batch grant agentCode = %#v, want qoderwork", got) } } -func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) { +func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ `{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`, @@ -339,20 +355,32 @@ func TestChmod_productsSessionModePassesSessionIDToPlanAndGrant(t *testing.T) { if got := fake.calls[0].args["grantType"]; got != "session" { t.Fatalf("plan grantType = %#v, want session", got) } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("plan agentCode = %#v, want qoderwork", got) + } if got := fake.calls[0].args["sessionId"]; got != "session-123" { t.Fatalf("plan sessionId = %#v, want session-123", got) } - if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { - t.Fatalf("plan agentCode = %#v, want qoderwork", got) + if got := fake.calls[0].agentEnv; got != "qoderwork" { + t.Fatalf("plan agent env = %q, want qoderwork", got) + } + if fake.calls[0].dingSessionEnv != "session-123" { + t.Fatalf("plan %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv) } if got := fake.calls[1].args["grantType"]; got != "session" { t.Fatalf("grant grantType = %#v, want session", got) } + if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { + t.Fatalf("grant agentCode = %#v, want qoderwork", got) + } if got := fake.calls[1].args["sessionId"]; got != "session-123" { t.Fatalf("grant sessionId = %#v, want session-123", got) } - if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { - t.Fatalf("grant agentCode = %#v, want qoderwork", got) + if got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("grant agent env = %q, want qoderwork", got) + } + if fake.calls[1].dingSessionEnv != "session-123" { + t.Fatalf("grant %s env = %q, want session-123", sessionIDEnvDingtalk, fake.calls[1].dingSessionEnv) } } @@ -378,11 +406,100 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { if fake.calls[0].tool != patBatchPlanToolName { t.Fatalf("plan tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("plan agentCode = %#v, want qoderwork", got) + } if got := fake.calls[0].args["sessionId"]; got != "env-session-123" { t.Fatalf("plan sessionId = %#v, want env-session-123", got) } + if got := fake.calls[0].agentEnv; got != "qoderwork" { + t.Fatalf("plan agent env = %q, want qoderwork", got) + } + if fake.calls[0].dingSessionEnv != "env-session-123" { + t.Fatalf("plan %s env = %q, want env-session-123", sessionIDEnvDingtalk, fake.calls[0].dingSessionEnv) + } +} + +func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) { + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "qoderwork") + fake := &sequenceToolCaller{ + errs: []error{ + apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: patForgedIdentityCode, + }), + ), + nil, + }, + responses: []string{ + "", + `{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("products", "calendar") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want 2", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName || fake.calls[1].tool != patBatchPlanToolName { + t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchPlanToolName) + } if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { - t.Fatalf("plan agentCode = %#v, want qoderwork", got) + t.Fatalf("first plan agentCode = %#v, want qoderwork", got) + } + if _, ok := fake.calls[1].args["agentCode"]; ok { + t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args) + } + if got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got) + } +} + +func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) { + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "qoderwork") + fake := &sequenceToolCaller{ + errs: []error{ + apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: patForgedIdentityCode, + }), + ), + nil, + }, + responses: []string{ + "", + `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "once") + + if err := cmd.RunE(cmd, []string{"calendar.event:read"}); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want 2", len(fake.calls)) + } + if fake.calls[0].tool != patBatchGrantToolName || fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("tools = %q, %q; want repeated %q", fake.calls[0].tool, fake.calls[1].tool, patBatchGrantToolName) + } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("first grant agentCode = %#v, want qoderwork", got) + } + if _, ok := fake.calls[1].args["agentCode"]; ok { + t.Fatalf("compat retry must omit agentCode arg: %#v", fake.calls[1].args) + } + if got := fake.calls[1].agentEnv; got != "qoderwork" { + t.Fatalf("compat retry %s = %q, want qoderwork", agentCodeEnv, got) } } @@ -407,6 +524,7 @@ func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) { } func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") t.Setenv(sessionIDEnvDingtalk, "ding-session-123") fake := &fakeToolCaller{resultOK: true} @@ -416,6 +534,9 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { t.Fatalf("chmod RunE error = %v", err) } + if got := fake.gotArgs["agentCode"]; got != "qoderwork" { + t.Fatalf("agentCode arg = %#v, want qoderwork", got) + } if got := fake.gotArgs["sessionId"]; got != "ding-session-123" { t.Fatalf("sessionId arg = %#v, want ding-session-123", got) } @@ -428,6 +549,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { } func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") t.Setenv(sessionIDEnvDingtalk, "stale-session") fake := &fakeToolCaller{resultOK: true} @@ -438,6 +560,9 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) { t.Fatalf("chmod RunE error = %v", err) } + if got := fake.gotArgs["agentCode"]; got != "qoderwork" { + t.Fatalf("agentCode arg = %#v, want qoderwork", got) + } if got := fake.gotArgs["sessionId"]; got != "flag-session" { t.Fatalf("sessionId arg = %#v, want flag-session", got) } @@ -523,7 +648,7 @@ func TestChmod_explicitScopesDryRunShowsBatchGrantTool(t *testing.T) { // TestChmod_agentCode_env_fallback verifies that when --agentCode is // omitted but DINGTALK_DWS_AGENTCODE is exported, the resolver picks -// the env value up and forwards it verbatim in the MCP argv. +// the env value up for both batch arguments and gateway-compatible env. func TestChmod_agentCode_env_fallback(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") @@ -543,7 +668,7 @@ func TestChmod_agentCode_env_fallback(t *testing.T) { t.Fatalf("agent env = %q, want %q", got, "qoderwork") } if got := fake.gotArgs["agentCode"]; got != "qoderwork" { - t.Fatalf("batch argv agentCode = %#v, want qoderwork", got) + t.Fatalf("batch agentCode = %#v, want qoderwork", got) } if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) { t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"}) @@ -553,8 +678,9 @@ func TestChmod_agentCode_env_fallback(t *testing.T) { } } -func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) { +func TestChmod_agentCode_compatEnvFallback(t *testing.T) { t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "compatwork") fake := &fakeToolCaller{resultOK: true} cmd := buildChmod(t, fake) @@ -566,14 +692,31 @@ func TestChmod_withoutAgentCodeUsesServerDefault(t *testing.T) { if fake.gotTool != patBatchGrantToolName { t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName) } - if got := fake.gotAgentEnv; got != "" { - t.Fatalf("agent env = %q, want empty so server default agentCode is used", got) + if got := fake.gotArgs["agentCode"]; got != "compatwork" { + t.Fatalf("batch agentCode = %#v, want compatwork", got) } - if _, ok := fake.gotArgs["agentCode"]; ok { - t.Fatalf("batch argv must omit agentCode when caller leaves it unset: %#v", fake.gotArgs) + if got := fake.gotAgentEnv; got != "compatwork" { + t.Fatalf("%s during CallTool = %q, want compatwork", agentCodeEnv, got) } - if got := fake.gotArgs["scopes"]; !stringSliceArgEqual(got, []string{"aitable.record:read"}) { - t.Fatalf("scopes in argv = %#v, want %#v", got, []string{"aitable.record:read"}) +} + +func TestChmod_withoutAgentCodeFailsBeforeMCP(t *testing.T) { + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") + + fake := &fakeToolCaller{resultOK: true} + cmd := buildChmod(t, fake) + _ = cmd.Flags().Set("grant-type", "once") + + err := cmd.RunE(cmd, []string{"aitable.record:read"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want missing agentCode error") + } + if !strings.Contains(err.Error(), "flag --agentCode is required") { + t.Fatalf("error = %q, want missing agentCode hint", err.Error()) + } + if fake.callN != 0 { + t.Fatalf("CallTool was invoked %d times; missing agentCode must fail before MCP", fake.callN) } } @@ -786,6 +929,40 @@ func TestCallPATToolWithLegacyFallback_patContractErrorDoesNotRetryLegacyAlias(t } } +func TestChmod_batchMetadataScopeErrorFallsBackToPATGrant(t *testing.T) { + fake := &sequenceToolCaller{ + responses: []string{ + `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{"scopes":["mail:send"]}}`, + `{"success":true,"data":{"authRequestId":"req-ok"}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("agentCode", "qoderwork") + _ = cmd.Flags().Set("grant-type", "once") + + if err := cmd.RunE(cmd, []string{"mail:send"}); err != nil { + t.Fatalf("chmod RunE error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool call count = %d, want 2", len(fake.calls)) + } + if fake.calls[0].tool != patBatchGrantToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchGrantToolName) + } + if fake.calls[1].tool != patGrantToolName { + t.Fatalf("fallback tool = %q, want %q", fake.calls[1].tool, patGrantToolName) + } + if got := fake.calls[0].args["agentCode"]; got != "qoderwork" { + t.Fatalf("batch agentCode = %#v, want qoderwork", got) + } + if got := fake.calls[1].args["agentCode"]; got != "qoderwork" { + t.Fatalf("fallback agentCode = %#v, want qoderwork", got) + } + if got := fake.calls[1].args["scopes"]; !stringSliceArgEqual(got, []string{"mail:send"}) { + t.Fatalf("fallback scopes = %#v, want mail:send", got) + } +} + func TestIsToolNotRegisteredError_ChineseGatewayMessage(t *testing.T) { err := errors.New("pat chmod failed: business error: PARAM_ERROR - 未找到指定工具") if !isToolNotRegisteredError(err) { @@ -813,6 +990,13 @@ func TestIsPATBatchUnsupportedResultCaseInsensitive(t *testing.T) { } } +func TestIsPATBatchFallbackResultIncludesMetadataContractErrors(t *testing.T) { + result := &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED"}`}}} + if !isPATBatchFallbackResult(result) { + t.Fatal("isPATBatchFallbackResult() = false, want true") + } +} + func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) { err := apperrors.NewAPI("business error: success=false", apperrors.WithReason("business_error"), @@ -825,6 +1009,18 @@ func TestIsPATBatchUnsupportedErrorUsesNormalizedDiagnostics(t *testing.T) { } } +func TestIsPATBatchFallbackErrorIncludesMetadataContractDiagnostics(t *testing.T) { + err := apperrors.NewAPI("business error: success=false", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: "PAT_BATCH_PRODUCT_NOT_DECLARED", + }), + ) + if !isPATBatchFallbackError(err) { + t.Fatal("isPATBatchFallbackError() = false, want true") + } +} + func TestHandleToolResult_emptyResultReturnsError(t *testing.T) { err := handleToolResult(nil, nil, &edition.ToolResult{}) if err == nil { @@ -978,34 +1174,35 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) { t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval") } if got := fake.gotArgs["agentCode"]; got != "flagval" { - t.Fatalf("batch argv agentCode = %#v, want flagval", got) + t.Fatalf("batch agentCode = %#v, want flagval", got) } } // TestChmod_agentCode_legacy_env_not_recognized is a reverse-guard: after // the SSOT hard-removal of the DWS_AGENTCODE alias, exporting only the -// legacy env MUST NOT be consumed. The command is still allowed to run, -// omits agentCode, and lets lippi-pat-core write its default agentCode. +// legacy env MUST NOT satisfy the required agentCode contract. func TestChmod_agentCode_legacy_env_not_recognized(t *testing.T) { t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") t.Setenv("DWS_AGENTCODE", "legacyval") fake := &fakeToolCaller{resultOK: true} cmd := buildChmod(t, fake) _ = cmd.Flags().Set("grant-type", "once") - if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil { - t.Fatalf("chmod RunE error = %v", err) + err := cmd.RunE(cmd, []string{"aitable.record:read"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want missing agentCode error") } - if fake.callN != 1 { - t.Fatalf("CallTool was invoked %d times, want 1", fake.callN) + if !strings.Contains(err.Error(), "flag --agentCode is required") { + t.Fatalf("error = %q, want missing agentCode hint", err.Error()) + } + if fake.callN != 0 { + t.Fatalf("CallTool was invoked %d times; legacy env must not satisfy agentCode", fake.callN) } if got := fake.gotAgentEnv; got != "" { t.Fatalf("agent env = %q, want empty; legacy DWS_AGENTCODE must not be consumed", got) } - if _, ok := fake.gotArgs["agentCode"]; ok { - t.Fatalf("batch argv must omit agentCode when only legacy env is set: %#v", fake.gotArgs) - } } // --------------------------------------------------------------------------- @@ -1046,8 +1243,21 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { code, src, "qoderwork", agentCodeEnv) } - // Empty primary → ("", ""). + t.Setenv(agentCodeEnvCompat, "compatwork") + if code, src := resolveAgentCodeFromEnv(); code != "qoderwork" || src != agentCodeEnv { + t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want primary (%q, %q)", + code, src, "qoderwork", agentCodeEnv) + } + t.Setenv(agentCodeEnv, "") + if code, src := resolveAgentCodeFromEnv(); code != "compatwork" || src != agentCodeEnvCompat { + t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want compat (%q, %q)", + code, src, "compatwork", agentCodeEnvCompat) + } + + // Empty primary + empty compat → ("", ""). + t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty", code, src) } @@ -1055,6 +1265,7 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { // Reverse-guard: legacy DWS_AGENTCODE MUST NOT be picked up when the // canonical env is unset — it was hard-removed as a legacy alias. t.Setenv(agentCodeEnv, "") + t.Setenv(agentCodeEnvCompat, "") t.Setenv("DWS_AGENTCODE", "legacy") if code, src := resolveAgentCodeFromEnv(); code != "" || src != "" { t.Errorf("resolveAgentCodeFromEnv() = (%q, %q), want empty — legacy DWS_AGENTCODE must be ignored", diff --git a/internal/pat/pat.go b/internal/pat/pat.go index 3d17b11c..ab55a162 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -37,7 +37,10 @@ func RegisterCommands(root *cobra.Command, c edition.ToolCaller) { pat chmod 默认输出轻量授权摘要;显式 --format json / --verbose 时, 才返回服务端完整 JSON(含逐 scope 明细),便于机器校验。 浏览器是否打开由本地 PAT 策略单独决定,与 json / non-json 独立。 - 生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。 + pat chmod 必须传 --agentCode,或设置 DINGTALK_DWS_AGENTCODE / + DWS_DINGTALK_AGENTCODE;CLI 会把 agentCode 放入 batch 请求参数, + 并同步注入 gateway 兼容身份头。 + 浏览器策略生效时会优先按 DINGTALK_DWS_AGENTCODE 读取 agent 策略,再回退到默认策略。 写入 agent 策略需显式传 --agentCode;不传则写入全局默认策略。 Host-owned PAT 开关: diff --git a/internal/transport/diagnostics.go b/internal/transport/diagnostics.go index cd62a465..a50f5a07 100644 --- a/internal/transport/diagnostics.go +++ b/internal/transport/diagnostics.go @@ -16,6 +16,7 @@ package transport import ( "encoding/json" "net/http" + "strings" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) @@ -38,16 +39,11 @@ func ExtractServerDiagnostics(data json.RawMessage) apperrors.ServerDiagnostics if len(data) == 0 { return apperrors.ServerDiagnostics{} } - var fields serverDiagFields - if json.Unmarshal(data, &fields) != nil { + var content map[string]any + if json.Unmarshal(data, &content) != nil { return apperrors.ServerDiagnostics{} } - return apperrors.ServerDiagnostics{ - TraceID: coalesceStr(fields.TraceID, fields.TraceIDCamel), - ServerErrorCode: coalesceStr(fields.Code, fields.ErrorCode), - TechnicalDetail: fields.TechnicalDetail, - ServerRetryable: fields.Retryable, - } + return ExtractServerDiagnosticsFromMap(content) } // ExtractServerDiagnosticsFromMap parses server diagnostic fields from a @@ -58,7 +54,7 @@ func ExtractServerDiagnosticsFromMap(content map[string]any) apperrors.ServerDia } diag := apperrors.ServerDiagnostics{ TraceID: stringFromMap(content, "trace_id", "traceId"), - ServerErrorCode: stringFromMap(content, "code", "errorCode"), + ServerErrorCode: serverErrorCodeFromMap(content, 0), TechnicalDetail: stringFromMap(content, "technical_detail"), } if v, ok := content["retryable"].(bool); ok { @@ -102,3 +98,50 @@ func stringFromMap(m map[string]any, keys ...string) string { } return "" } + +func serverErrorCodeFromMap(content map[string]any, depth int) string { + if content == nil || depth > 8 { + return "" + } + direct := stringFromMap(content, "errorCode", "code", "server_error_code") + if isWrapperServerCode(direct) { + if nested := nestedServerErrorCode(content, depth); nested != "" { + return nested + } + } + if direct != "" { + return direct + } + return nestedServerErrorCode(content, depth) +} + +func nestedServerErrorCode(content map[string]any, depth int) string { + for _, key := range []string{"content", "result", "data"} { + switch child := content[key].(type) { + case map[string]any: + if code := serverErrorCodeFromMap(child, depth+1); code != "" { + return code + } + case []any: + for _, item := range child { + childMap, ok := item.(map[string]any) + if !ok { + continue + } + if code := serverErrorCodeFromMap(childMap, depth+1); code != "" { + return code + } + } + } + } + return "" +} + +func isWrapperServerCode(code string) bool { + switch strings.ToUpper(strings.TrimSpace(code)) { + case "ERROR", "BUSINESS_ERROR", "-1": + return true + default: + return false + } +} diff --git a/internal/transport/diagnostics_test.go b/internal/transport/diagnostics_test.go index 4d018454..2a25dd5f 100644 --- a/internal/transport/diagnostics_test.go +++ b/internal/transport/diagnostics_test.go @@ -76,6 +76,26 @@ func TestExtractServerDiagnosticsFromMap(t *testing.T) { } } +func TestExtractServerDiagnosticsFromMapNestedBusinessCode(t *testing.T) { + t.Parallel() + content := map[string]any{ + "trace_id": "trace-002", + "code": "BUSINESS_ERROR", + "result": map[string]any{ + "success": false, + "errorCode": "ROBOT_NOT_FOUND", + "errorMsg": "robot info is not exist", + }, + } + diag := ExtractServerDiagnosticsFromMap(content) + if diag.TraceID != "trace-002" { + t.Fatalf("TraceID = %q, want trace-002", diag.TraceID) + } + if diag.ServerErrorCode != "ROBOT_NOT_FOUND" { + t.Fatalf("ServerErrorCode = %q, want ROBOT_NOT_FOUND", diag.ServerErrorCode) + } +} + func TestExtractServerDiagnosticsFromMap_Empty(t *testing.T) { t.Parallel() diag := ExtractServerDiagnosticsFromMap(nil) diff --git a/pkg/config/constants.go b/pkg/config/constants.go index c986e45a..0848ab37 100644 --- a/pkg/config/constants.go +++ b/pkg/config/constants.go @@ -161,9 +161,9 @@ const ( // Shared across auth, errors, and device-flow packages. const ( - // DefaultMCPBaseURL is the DingTalk MCP base URL. + // DefaultMCPBaseURL is the DingTalk MCP base URL for the pre-release build. // Override at runtime via ~/.dws/mcp_url file. - DefaultMCPBaseURL = "https://mcp.dingtalk.com" + DefaultMCPBaseURL = "https://pre-mcp.dingtalk.com" // DefaultTerminalBaseURL is the DingTalk developer platform base URL. // Override at runtime via ~/.dws/terminal_url file. @@ -189,7 +189,7 @@ func DefaultConfigDir() string { // GetMCPBaseURL returns the MCP base URL with priority: // 1. ~/.dws/mcp_url file content (for custom environment) -// 2. Default value (https://mcp.dingtalk.com) +// 2. Default value (https://pre-mcp.dingtalk.com) func GetMCPBaseURL() string { mcpURLPath := filepath.Join(DefaultConfigDir(), "mcp_url") if data, err := os.ReadFile(mcpURLPath); err == nil { diff --git a/scripts/README.md b/scripts/README.md index 5513489a..28069f23 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -11,7 +11,7 @@ These repo-local entrypoints are the supported shell entrypoints for building, t Script groups: -- Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh` +- Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh`, `./scripts/install-devapp.sh` - Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh` - Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-open-source-assets.sh` - Release helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh` diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 new file mode 100644 index 00000000..e2bee7a8 --- /dev/null +++ b/scripts/install-devapp.ps1 @@ -0,0 +1,139 @@ +# Copyright 2026 Alibaba Group +# Licensed under the Apache License, Version 2.0 +# +# One-command installer for the dws Dev preview on native Windows (PowerShell). +# Downloads the dev binary (dws.exe) + dingtalk-dev skill from the fork's GitHub Releases. +# +# Usage: +# irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex +# +# Env (all optional): +# DEVAPP_REPO fork holding dev releases (default: wxianfeng/dingtalk-workspace-cli) +# DEVAPP_VERSION pin a dev release tag (default: latest release on the fork) +# DWS_ARCH architecture override (amd64 or arm64) +# DWS_INSTALL_DIR binary dir (default: ~/.local/bin) +# DWS_NO_SKILLS set 1 to skip the dev skill + +$ErrorActionPreference = "Stop" + +$Repo = if ($env:DEVAPP_REPO) { $env:DEVAPP_REPO } else { "wxianfeng/dingtalk-workspace-cli" } +$Version = $env:DEVAPP_VERSION +$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" } +$NoSkills = $env:DWS_NO_SKILLS -eq "1" +$SkillName = "dingtalk-dev" + +function Say($m) { Write-Host " $m" } +function Die($m) { Write-Host " X $m" -ForegroundColor Red; exit 1 } + +function Get-Arch { + if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH } + try { + switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) { + "X64" { return "amd64" } + "Arm64" { return "arm64" } + } + } catch {} + switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { return "amd64" } + "ARM64" { return "arm64" } + default { Die "Could not detect architecture. Set DWS_ARCH to amd64 or arm64." } + } +} + +# GitHub's /releases/latest excludes prereleases; read the releases list (newest +# first) and take the top tag — the dev preview is published as a prerelease. +if (-not $Version) { + try { + $rel = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases?per_page=1" ` + -Headers @{ "User-Agent" = "dws-devapp-installer" } -UseBasicParsing + $Version = $rel[0].tag_name + } catch {} + if (-not $Version) { Die "No release found on $Repo. Push a dev tag (e.g. v1.0.39-dev.1) to trigger CI, or set DEVAPP_VERSION." } +} + +$arch = Get-Arch +$tmp = Join-Path $env:TEMP ("dws-dev-" + [System.Guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Path $tmp -Force | Out-Null + +Write-Host "" +Say "dws Dev preview installer (Windows, pre-built binary)" +Say "Repo: $Repo" +Say "Version: $Version" +Say "Target: windows/$arch" +Write-Host "" + +# 1) binary +$asset = "dws-windows-$arch.zip" +$zip = Join-Path $tmp $asset +Say "Downloading $asset ..." +try { + Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/$asset" ` + -OutFile $zip -UseBasicParsing +} catch { Die "Binary download failed - does release $Version have $asset?" } + +Expand-Archive -Path $zip -DestinationPath $tmp -Force +$exe = Get-ChildItem -Path $tmp -Recurse -Filter "dws.exe" | Select-Object -First 1 +if (-not $exe) { Die "dws.exe not found inside $asset" } +if (-not (Test-Path $InstallDir)) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null } +Copy-Item -Path $exe.FullName -Destination (Join-Path $InstallDir "dws.exe") -Force +Say "Binary -> $InstallDir\dws.exe" + +# 2) dev skill from the release's skills bundle +if (-not $NoSkills) { + try { + $skzip = Join-Path $tmp "dws-skills.zip" + Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" ` + -OutFile $skzip -UseBasicParsing + $skdir = Join-Path $tmp "sk" + Expand-Archive -Path $skzip -DestinationPath $skdir -Force + + $src = $null + foreach ($c in @("multi\$SkillName", "skills\multi\$SkillName", "$SkillName")) { + $p = Join-Path $skdir $c + if (Test-Path (Join-Path $p "SKILL.md")) { $src = $p; break } + } + if ($src) { + # cache so `dws skill setup --mode multi` can find a source later + $cache = Join-Path $HOME ".dws\skills\multi\$SkillName" + if (Test-Path $cache) { Remove-Item -Recurse -Force $cache } + New-Item -ItemType Directory -Path $cache -Force | Out-Null + Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force + + $agentDirs = @( + ".agents\skills", ".claude\skills", ".cursor\skills", ".qoder\skills", ".qoderwork\skills", + ".gemini\skills", ".codex\skills", ".github\skills", ".windsurf\skills", ".augment\skills", + ".cline\skills", ".amp\skills", ".kiro\skills", ".trae\skills", ".openclaw\skills", + ".hermes\skills", ".config\opencode\skills" + ) + $installed = 0; $idx = 0 + foreach ($d in $agentDirs) { + $base = Join-Path $HOME $d + $parent = Split-Path $base -Parent + if ($idx -gt 0 -and -not (Test-Path $parent)) { $idx++; continue } + $dest = Join-Path $base $SkillName + if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } + New-Item -ItemType Directory -Path $dest -Force | Out-Null + Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force + $installed++; $idx++ + } + Say "Skill dingtalk-dev -> $installed agent dir(s)" + } else { + Say "(dingtalk-dev not found in skills bundle; skipped)" + } + } catch { + Say "(skill install skipped: $($_.Exception.Message))" + } +} + +Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + +Write-Host "" +Say "Done. Next steps:" +Say " dws version" +Say " dws auth login" +Say " dws dev --help --format json" +Write-Host "" +if (($env:Path -split ';') -notcontains $InstallDir) { + Say "Note: $InstallDir is not on PATH. Add it (new terminal after):" + Say " [Environment]::SetEnvironmentVariable('Path', `"`$env:Path;$InstallDir`", 'User')" +} diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 39bd8f19..d63fd8cb 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -2,102 +2,139 @@ # Copyright 2026 Alibaba Group # Licensed under the Apache License, Version 2.0 # -# One-command installer for the DevApp preview branch. +# One-command installer for the dws Dev preview — pre-built binary, no build tools. +# Downloads the dev binary + dingtalk-dev skill from the fork's GitHub Releases. +# Requires only curl + tar (no go / make / git). # # Usage: # curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh # -# Environment variables: -# DEVAPP_REPO_URL Git repository URL. Default: https://github.com/wxianfeng/dingtalk-workspace-cli.git -# DEVAPP_BRANCH Branch to install. Default: feat/dws-devapp -# DEVAPP_SOURCE_DIR Existing source checkout to install from. -# DEVAPP_KEEP_SOURCE Set to 1 to keep the temporary source checkout. -# -# The script clones the requested branch and delegates to scripts/install.sh -# from that checkout, which builds and installs dws from local source. - +# Env (all optional): +# DEVAPP_REPO fork holding dev releases (default: wxianfeng/dingtalk-workspace-cli) +# DEVAPP_VERSION pin a dev release tag (default: latest release on the fork) +# DWS_INSTALL_DIR binary dir (default: ~/.local/bin) +# DWS_NO_SKILLS set 1 to skip the dev skill set -eu -REPO_URL="${DEVAPP_REPO_URL:-https://github.com/wxianfeng/dingtalk-workspace-cli.git}" -BRANCH="${DEVAPP_BRANCH:-feat/dws-devapp}" -SOURCE_DIR="${DEVAPP_SOURCE_DIR:-}" -KEEP_SOURCE="${DEVAPP_KEEP_SOURCE:-0}" -TMPDIR_WORK="" +DEVAPP_REPO="${DEVAPP_REPO:-wxianfeng/dingtalk-workspace-cli}" +DEVAPP_VERSION="${DEVAPP_VERSION:-}" +INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}" +NO_SKILLS="${DWS_NO_SKILLS:-0}" +SKILL_NAME="dingtalk-dev" -say() { - printf ' %s\n' "$@" +say() { printf ' %s\n' "$@"; } +err() { printf ' ❌ %s\n' "$@" >&2; exit 1; } +need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } + +need_cmd curl + +detect_os() { + case "$(uname -s)" in + Linux*) echo linux ;; + Darwin*) echo darwin ;; + MINGW*|MSYS*|CYGWIN*) echo windows ;; # Git Bash / MSYS2 / Cygwin on Windows + *) err "Unsupported OS: $(uname -s). On native Windows use install-devapp.ps1." ;; + esac } -err() { - printf ' ERROR: %s\n' "$@" >&2 - exit 1 +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo amd64 ;; + arm64|aarch64) echo arm64 ;; + *) err "Unsupported architecture: $(uname -m)" ;; + esac } -need_cmd() { - command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1" +# GitHub's /releases/latest excludes prereleases, so read the releases list +# (newest first) and take the top tag — the dev preview is published as a prerelease. +resolve_version() { + [ -n "$DEVAPP_VERSION" ] && return 0 + DEVAPP_VERSION="$(curl -fsSL "https://api.github.com/repos/${DEVAPP_REPO}/releases?per_page=1" 2>/dev/null \ + | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')" + [ -n "$DEVAPP_VERSION" ] || err "No release found on ${DEVAPP_REPO}. Push a dev tag (e.g. v1.0.39-dev.1) to trigger CI, or set DEVAPP_VERSION." } -cleanup() { - if [ -n "$TMPDIR_WORK" ] && [ "$KEEP_SOURCE" != "1" ]; then - rm -rf "$TMPDIR_WORK" - fi -} +install_skill() { + bundle="$1" # extracted skills bundle dir + src="" + for c in "$bundle/multi/$SKILL_NAME" "$bundle/skills/multi/$SKILL_NAME" "$bundle/$SKILL_NAME"; do + [ -f "$c/SKILL.md" ] && src="$c" && break + done + [ -n "$src" ] || { say " (dingtalk-dev not found in skills bundle; skipped)"; return 0; } -trap cleanup EXIT INT TERM + # cache so `dws skill setup --mode multi` can find a source later + cache="$HOME/.dws/skills/multi/$SKILL_NAME" + rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/" -print_banner() { - printf '\n' - say "DevApp preview installer" - say "Repository: ${REPO_URL}" - say "Branch: ${BRANCH}" - printf '\n' -} - -resolve_source_dir() { - if [ -n "$SOURCE_DIR" ]; then - [ -d "$SOURCE_DIR" ] || err "DEVAPP_SOURCE_DIR does not exist: ${SOURCE_DIR}" - [ -f "$SOURCE_DIR/scripts/install.sh" ] || err "install.sh not found under DEVAPP_SOURCE_DIR: ${SOURCE_DIR}" - printf '%s\n' "$SOURCE_DIR" - return 0 - fi - - need_cmd git - - TMPDIR_WORK="$(mktemp -d)" - checkout="${TMPDIR_WORK}/dingtalk-workspace-cli" - - say "Cloning DevApp preview source..." >&2 - git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$checkout" - - [ -f "$checkout/scripts/install.sh" ] || err "install.sh not found after clone" - printf '%s\n' "$checkout" + installed=0; idx=0 + for agent_dir in \ + .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ + .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ + .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ + .config/opencode/skills + do + base="$HOME/$agent_dir" + if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then idx=$((idx + 1)); continue; fi + dest="$base/$SKILL_NAME"; rm -rf "$dest"; mkdir -p "$dest"; cp -R "$src/." "$dest/" + installed=$((installed + 1)); idx=$((idx + 1)) + done + say "✅ Skill dingtalk-dev → ${installed} agent dir(s)" } main() { - print_banner + resolve_version + os="$(detect_os)"; arch="$(detect_arch)" + tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT INT TERM - if [ "${DWS_SKILLS_ONLY:-0}" != "1" ]; then - need_cmd go - need_cmd make + printf '\n' + say "dws Dev preview installer (pre-built binary)" + say "Repo: ${DEVAPP_REPO}" + say "Version: ${DEVAPP_VERSION}" + say "Target: ${os}/${arch}" + printf '\n' + + # 1) binary (already ad-hoc signed by CI; copy does not break the signature) + if [ "$os" = "windows" ]; then + asset="dws-windows-${arch}.zip"; binname="dws.exe" + else + asset="dws-${os}-${arch}.tar.gz"; binname="dws" + fi + say "⬇ Downloading ${asset} ..." + curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/${asset}" -o "$tmp/$asset" \ + || err "Binary download failed — does release ${DEVAPP_VERSION} have ${asset}?" + if [ "$os" = "windows" ]; then + need_cmd unzip; unzip -q "$tmp/$asset" -d "$tmp" + else + need_cmd tar; tar -xzf "$tmp/$asset" -C "$tmp" + fi + [ -f "$tmp/$binname" ] || err "${binname} not found inside ${asset}" + mkdir -p "$INSTALL_DIR" + cp "$tmp/$binname" "$INSTALL_DIR/$binname"; chmod +x "$INSTALL_DIR/$binname" 2>/dev/null || true + say "✅ Binary → ${INSTALL_DIR}/${binname}" + + # 2) dev skill from the release's skills bundle + if [ "$NO_SKILLS" != "1" ]; then + if curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/dws-skills.zip" -o "$tmp/skills.zip" 2>/dev/null; then + mkdir -p "$tmp/sk" + if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi + say "" + install_skill "$tmp/sk" + else + say " (no dws-skills.zip in release ${DEVAPP_VERSION}; skill skipped)" + fi fi - source_root="$(resolve_source_dir)" - - say "" - say "Installing dws from DevApp preview source..." - DWS_VERSION=latest DWS_SKILL_MODE="${DWS_SKILL_MODE:-mono}" sh "$source_root/scripts/install.sh" - - say "" - say "DevApp next steps:" + printf '\n' + say "🎉 Done. Next steps:" say " dws version" say " dws auth login" - say " dws devapp --help --format json" - say " dws devapp list --format json" - - if [ "$KEEP_SOURCE" = "1" ]; then - say "" - say "Source checkout kept at: ${source_root}" - fi + say " dws dev --help --format json" + say " dws dev app list --format json" + printf '\n' + case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) say "Note: ${INSTALL_DIR} is not on \$PATH — add it so 'dws' is found." ;; + esac } main diff --git a/skills/dingtalk-devapp/SKILL.md b/skills/dingtalk-devapp/SKILL.md deleted file mode 100644 index 5019efe1..00000000 --- a/skills/dingtalk-devapp/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: dingtalk-devapp -description: 钉钉开放平台应用管理。Use when 用户说 开发者后台应用/开放平台应用/企业内部应用/查应用/创建应用/修改应用/删除应用/停用应用/启用应用/应用成员/安全配置/IP白名单/登录重定向/端内免登/agentId/clientId/appKey/appSecret/应用权限/权限点/创建机器人/智能体机器人/机器人配置/机器人回调/应用版本/版本发布/发布审核/选审批人。Distinct from dingtalk-devdoc(开放平台文档搜索) and dingtalk-doc(钉钉云文档)。命令前缀:dws devapp(兼容别名 dws app)。 -cli_version: ">=1.0.15" -metadata: - category: product - stability: experimental - requires: - bins: - - dws ---- - -# 钉钉开放平台应用管理 Skill - -> `dws devapp ...` 是内置 helper 命令,不依赖 MCP 服务发现。执行前用 `dws devapp --help` 验证可用。 - -## 意图消歧 - -`应用` 是泛词。只有出现以下信号才路由到 devapp: - -- `开放平台应用/开发者后台应用/企业内部应用/内部应用` -- `agentId/clientId/appKey/appSecret/customKey` -- `应用权限/权限点/scopeValue/应用成员/安全配置/IP 白名单` -- `创建机器人/智能体机器人/机器人配置/机器人回调地址`(→ `robot`) -- `应用版本/版本发布/发布审核/选审批人`(→ `version`) - -不要路由到 devapp 的:接口文档→`devdoc`、钉钉文档→`doc`、工作台应用→`workbench app`、审批→`oa`。只说 `应用` 无上下文时先追问。 - -## 核心规则 - -1. 所有命令加 `--format json`。 -2. 写操作先 `--dry-run`,确认后才加 `--yes`。 -3. 应用名/appKey/customKey 命中多条时展示候选,不取第一条。 -4. 权限申请/取消只接受 `scopeValue`,不传 API 名或分组名。 -5. 主动读取密钥走 `credentials get`;任何 `devapp get`/详情返回里的 `clientSecret/appSecret` 都按敏感凭证脱敏,不向用户展开。 - -## 开放平台文档 RAG / 错误码排查 - -- 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。 -- 查询词优先保留原始 API 名、能力名、权限点、完整错误码和 message;首轮形如 `errcode `,无结果再换 `<产品/场景> <错误码>`、`<接口名> 参数`。 -- 本地 CLI 错误(如 `unknown command` / `unknown flag` / 认证 / recovery)仍按 root `dws` skill 的错误处理执行;`devdoc` 用于开放平台业务错误码和接口语义排查。 -- `devdoc` 只查钉钉开放平台开发者文档,不查业务数据;排查结论必须基于命中条目的标题、摘要或链接,不能编造错误原因或不存在的命令。 - -## 渐进式参考 - -按使用频率和复杂度分层,按需加载: - -| 层 | 参考文档 | 覆盖命令 | -|----|---------|---------| -| 基础 | [app-crud.md](references/app-crud.md) | list / get / create / update / delete / inactive / active | -| 凭证与网页应用 | [credentials-webapp.md](references/credentials-webapp.md) | credentials get / webapp get / webapp config | -| 权限管理 | [permissions.md](references/permissions.md) | permission list / permission add / permission remove | -| 成员与安全 | [member-security.md](references/member-security.md) | member list / add / remove / security config | -| 机器人 | [robot.md](references/robot.md) | robot create / submit / result / get / config / update / enable / offline | -| 版本发布 | [version.md](references/version.md) | version create / list / get / check-approval / publish / status | -| 操作流程 | [workflows.md](references/workflows.md) | 创建应用全流程 / 权限全流程 / 生命周期管理 | diff --git a/skills/dingtalk-devapp/references/app-crud.md b/skills/dingtalk-devapp/references/app-crud.md deleted file mode 100644 index 7898e729..00000000 --- a/skills/dingtalk-devapp/references/app-crud.md +++ /dev/null @@ -1,124 +0,0 @@ -# 应用基础操作 - -应用列表查询、详情、创建、修改、生命周期启停和删除。 - -## 应用定位 - -写操作前必须定位到唯一应用: - -| 优先级 | 标识 | 处理 | -|--------|------|------| -| 1 | `--unified-app-id` | 直接使用 | -| 2 | `--agent-id` / `--app-id` | 直接使用 | -| 3 | `--app-key` / `--custom-key` | 先查询,唯一命中才继续 | -| 4 | `--name` | 模糊搜索,写操作必须唯一命中 | - -## 应用列表 - -```bash -dws devapp list --format json -dws devapp list --name DemoApp --format json -dws devapp list --agent-id 123456 --format json -dws devapp list --creator 张三 --sort gmt_modified --order desc --format json -``` - -MCP tool: `list_open_dev_apps_by_condition` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--page` | `currentPage` | 1-based,默认 1 | -| `--page-size` | `pageSize` | 默认 20 | -| `--name` / `--keyword` | `appName` | 应用名搜索 | -| `--agent-id` | `agentId` | 精确定位 | -| `--app-key` | `appKey` | appKey/clientId | -| `--creator` | `creator` | 创建人关键词 | -| `--sort` | `sortType` | 如 `gmt_modified` | -| `--order` | `sortOrder` | `asc` / `desc` | - -## 应用状态字段 - -列表/详情原始字段里如果出现 `status` / `appStatus`,按应用生命周期枚举判断;不要和版本 `versionStatus` 混用。 - -| status | 枚举 | 含义 | 下一步 | -|--------|------|------|--------| -| `0` | `IN_ACTIVE` | 已停用,应用不可用 | 需要恢复时走 `active --dry-run` → 确认 → `--yes` | -| `1` | `ACTIVE` | 已激活,应用可用 | 可继续配置权限、网页应用、机器人或版本 | -| `2` | `WAIT_ACTIVE` | 待激活 | 先回读 `get/list` 确认状态;不要直接按已生效处理 | -| `3` | `EXPIRED` | 已过期 | 停止写操作,提示用户到开发者后台或管理员侧处理 | - -`create/update` 返回的 `versionStatus` 是版本状态,语义见 `version.md`;它不等同于应用启停状态。 - -## 应用详情 - -```bash -dws devapp get --unified-app-id UNIFIED_APP_ID --format json -dws devapp get --agent-id 123456 --format json -``` - -MCP tool: `get_open_dev_app_detail` - -详情主要用于定位和核验应用。若上游偶尔随详情返回 `clientSecret/appSecret`,必须脱敏处理,不要复制到回答里;主动读取凭证仍走 `credentials get`。 - -## 创建应用 - -```bash -dws devapp create --name DemoApp --desc "内部应用" --type internal --dry-run --format json -dws devapp create --name DemoApp --desc "内部应用" --type internal --yes --format json -``` - -MCP tool: `create_inner_app` - -| CLI | MCP | 必填 | -|-----|-----|------| -| `--name` | `appName` | 是 | -| `--desc` | `appDesc` | 否 | -| `--icon` | `appIcon` | 否 | - -`--type` 只做 CLI 校验(当前仅支持 `internal`),不下发 MCP。 - -## 修改应用 - -```bash -dws devapp update --unified-app-id ID --name DemoApp2 --desc "新描述" --dry-run --format json -dws devapp update --unified-app-id ID --name DemoApp2 --desc "新描述" --yes --format json -``` - -MCP tool: `update_inner_app` - -至少提供一个更新字段:`--name` / `--desc` / `--icon`。 - -## 停用 / 启用应用 - -```bash -dws devapp inactive --unified-app-id ID --dry-run --format json -dws devapp inactive --unified-app-id ID --yes --format json - -dws devapp active --unified-app-id ID --dry-run --format json -dws devapp active --unified-app-id ID --yes --format json -``` - -MCP tools: `inactive_inner_app` / `active_inner_app` - -停用保留数据但应用不可用,可通过 `active` 恢复。 - -执行 `inactive/active` 后必须回读 `get` 或 `list`:看到 `status=0` 才算停用完成,看到 `status=1` 才算启用完成;如果接口只返回操作成功但未带状态,向用户说明需要以回读结果为准。 - -## 删除应用 - -```bash -dws devapp delete --unified-app-id ID --dry-run --format json -dws devapp delete --unified-app-id ID --yes --format json -``` - -MCP tool: `delete_inner_app` - -删除前必须展示应用摘要。删除为异步操作,成功后应用延迟从列表消失。 - -## 错误处理 - -| 情况 | 处理 | -|------|------| -| `unknown command` | CLI 构建不含 devapp helper | -| `endpoint_not_resolved` | 检查 edition endpoint 注入 | -| 多应用命中 | 展示候选,停止写操作 | -| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | diff --git a/skills/dingtalk-devapp/references/credentials-webapp.md b/skills/dingtalk-devapp/references/credentials-webapp.md deleted file mode 100644 index 0f1dec02..00000000 --- a/skills/dingtalk-devapp/references/credentials-webapp.md +++ /dev/null @@ -1,55 +0,0 @@ -# 凭证读取与网页应用配置 - -## 凭证读取 - -```bash -dws devapp credentials get --unified-app-id UNIFIED_APP_ID --format json -dws devapp credentials get --agent-id 123456 --format json -``` - -MCP tool: `get_open_dev_app_credentials` - -后端 facade: `OpenInnerAppQueryFacade.getCredentials`。 - -**规则:** -- CLI 只传应用定位字段,不传 `showSecret`/`confirmSecret`。 -- 返回可能包含 `clientSecret/appSecret`,输出按敏感凭证处理。 -- 不能用 `devapp get` 代替;如果 `devapp get` 偶尔返回密钥,也只用于内部判断并脱敏,不向用户展开。 - -关键返回字段: - -| 字段 | 说明 | -|------|------| -| `clientId` / `appKey` | 非密钥标识 | -| `clientSecret` / `appSecret` | 敏感凭证 | -| `currentSecretStatus` | 当前密钥状态 | -| `pendingExpireTask` | 密钥过期任务信息 | - -## 网页应用查询 - -```bash -dws devapp webapp get --agent-id 123456 --format json -dws devapp webapp get --unified-app-id UNIFIED_APP_ID --format json -``` - -MCP tool: `get_webapp_config` - -未配置网页应用前可能只返回 `agentId`。 - -## 网页应用配置 - -```bash -dws devapp webapp config --agent-id 123456 --homepage-link https://example.com/mobile --dry-run --format json -dws devapp webapp config --agent-id 123456 --homepage-link https://example.com/mobile --pc-homepage-link https://example.com/pc --yes --format json -``` - -MCP tool: `set_webapp_config` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--h5-page-type` | `h5PageType` | 网页应用生效端 | -| `--homepage-link` | `homepageLink` | 移动端首页地址 | -| `--pc-homepage-link` | `pcHomepageLink` | PC 端首页地址 | -| `--omp-link` | `ompLink` | 管理后台地址 | - -至少提供一个配置字段。`h5PageType` 未显式传入时,不要假设固定默认值;配置后以 `webapp get` 回读为准(实跑可能返回 `mobile`)。 diff --git a/skills/dingtalk-devapp/references/member-security.md b/skills/dingtalk-devapp/references/member-security.md deleted file mode 100644 index 8c2b716c..00000000 --- a/skills/dingtalk-devapp/references/member-security.md +++ /dev/null @@ -1,51 +0,0 @@ -# 成员管理与安全配置 - -## 成员查询 - -```bash -dws devapp member list --app-id UNIFIED_APP_ID --format json -``` - -MCP tool: `list_open_dev_app_members` - -## 添加成员 - -```bash -dws devapp member add --app-id UNIFIED_APP_ID --users userId1,userId2 --member-type DEVELOPER --dry-run --format json -dws devapp member add --app-id UNIFIED_APP_ID --users userId1,userId2 --member-type DEVELOPER --yes --format json -``` - -MCP tool: `add_open_dev_app_members` - -## 移除成员 - -```bash -dws devapp member remove --app-id UNIFIED_APP_ID --users userId1 --member-type DEVELOPER --dry-run --format json -dws devapp member remove --app-id UNIFIED_APP_ID --users userId1 --member-type DEVELOPER --yes --format json -``` - -MCP tool: `remove_open_dev_app_members` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--app-id` | `unifiedAppId` | 统一应用 ID(必填) | -| `--users` | `memberUserIds` | userId 列表,逗号分隔(必填) | -| `--member-type` | `memberType` | 成员类型,如 `DEVELOPER`(必填) | - -## 安全配置 - -```bash -dws devapp security config --app-id UNIFIED_APP_ID --ip-whitelist 192.0.2.10 --dry-run --format json -dws devapp security config --app-id UNIFIED_APP_ID --redirect-url https://callback.example.invalid/callback --sso-url https://sso.example.invalid/sso --yes --format json -``` - -MCP tool: `update_app_security_config` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--app-id` | `unifiedAppId` | 统一应用 ID(必填) | -| `--ip-whitelist` | `ipWhiteList` | 出口 IP 白名单,逗号/分号分隔 | -| `--redirect-url` | `redirectUrls` | 登录重定向 URL,逗号/分号分隔 | -| `--sso-url` | `otherAuthUrls` | 端内免登 URL,逗号/分号分隔 | - -至少提供一个配置字段。只下发显式提供的字段,未提供的不覆盖。 diff --git a/skills/dingtalk-devapp/references/permissions.md b/skills/dingtalk-devapp/references/permissions.md deleted file mode 100644 index 17cc6d95..00000000 --- a/skills/dingtalk-devapp/references/permissions.md +++ /dev/null @@ -1,96 +0,0 @@ -# 权限管理 - -查询、申请、取消开放平台应用的 APP 应用权限和 SNS 个人权限。 - -## 权限列表 - -```bash -dws devapp permission list --unified-app-id ID --format json -dws devapp permission list --unified-app-id ID --keyword "机器人发消息" --status UNAUTHED --format json -dws devapp permission list --unified-app-id ID --scope-type SNS --format json -dws devapp permission list --unified-app-id ID --scope qyapi_robot_sendmsg --format json -``` - -MCP tool: `list_open_dev_app_permissions` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--unified-app-id` | `unifiedAppId` | 应用定位 | -| `--agent-id` | `agentId` | 应用定位 | -| `--keyword` | `keyword` | 权限名/API 名关键词 | -| `--status` | `authStatus` | `ALL` / `AUTHED` / `UNAUTHED` | -| `--scope-type` | `firstLevelType` | `APP` / `SNS`,为空返回两者 | -| `--scope` | `scopeValue` | 单权限详情模式 | -| `--limit` | `limit` | 每页返回数量,默认 20,建议不超过 50 | -| `--offset` | `offset` | 翻页偏移量,默认 0 | - -**状态判断:** - -`--status` 是查询过滤条件: - -| authStatus | 含义 | -|------------|------| -| `ALL` | 不按授权状态过滤 | -| `AUTHED` | 只看已授权/已开通的权限点 | -| `UNAUTHED` | 只看未授权/未开通的权限点 | - -单个权限项返回的 `status` 是内部操作态: - -| status | 枚举 | 含义 | 下一步 | -|--------|------|------|--------| -| `0` | `STATUS_OBTAINED` | 权限已获得 | 不要重复申请;如需取消,确认 `canRemove=true` 后走 `permission remove` | -| `1` | `STATUS_APPLYING` | 权限申请中 | 不要重复申请;查看 `authedStatusDesc`,通常等待审批或版本发布 | -| `2` | `STATUS_CAN_APPLY` | 权限可以申请 | 可走 `permission add --dry-run` | -| `3` | `STATUS_CAN_NOT_APPLY` | 权限不可以申请 | 停止申请,展示 `applyDisabledReason/displayMessage` | - -`authedStatusDesc` 是给用户看的细分状态:`OPENED`/`APPLIED`/`TO_BE_PUBLISHED` 表示已开通、已申请或待发布;`NOT_OPEN`/`NOT_APPLIED` 表示未开通/未申请;`AUDIT_PROCESSING` 表示审批中;`AUDIT_REFUSE` 表示审批未通过。判断能否操作仍以 `status`、`canEdit`、`canApplyDirectly`、`allowedActions` 为准。 - -**翻页:** - -权限列表支持 `--limit` + `--offset` 分页。一个应用可能有 150+ 个权限点,一次查不完时用 offset 翻页: - -```bash -dws devapp permission list --unified-app-id ID --limit 50 --format json # 第 1 页 -dws devapp permission list --unified-app-id ID --limit 50 --offset 50 --format json # 第 2 页 -dws devapp permission list --unified-app-id ID --limit 50 --offset 100 --format json # 第 3 页 -``` - -当返回条数 < limit 时表示已到末尾。 - -**规则:** -- `permission search` 和 `permission detail` 是 `list` 的 CLI 别名,不是独立 MCP tool。 -- 默认同时返回 APP 和 SNS 权限。 -- 列表模式只返回 `apiPreview`;`--scope` 详情模式返回完整 `apiList`。 - -**scopeValue 选择顺序:** - -1. 用户给了 `scopeValue` → 精确匹配 -2. 用户给了 API 名 → `keyword` 搜索,匹配 `apiPreview.name` -3. 用户给了权限名 → 匹配 `scopeName/scopeDesc` -4. 多个候选 → 展示列表让用户选择,不自动取第一条 - -## 申请权限 - -```bash -dws devapp permission add --unified-app-id ID --permissions qyapi_robot_sendmsg --dry-run --format json -dws devapp permission add --unified-app-id ID --permissions Contact.User.mobile,qyapi_robot_sendmsg --yes --format json -``` - -MCP tool: `apply_open_dev_app_permissions` - -**规则:** -- `--permissions` 传 `scopeValue`,多个逗号分隔,必须来自 `permission list` 的返回。 -- 已开通跳过,不可编辑拒绝。 -- `requiredApproval=true` 允许申请——写入版本变更,审批在版本发布时处理。 -- 不在此处选审批人。 - -## 取消权限 - -```bash -dws devapp permission remove --unified-app-id ID --permission qyapi_robot_sendmsg --dry-run --format json -dws devapp permission remove --unified-app-id ID --permission qyapi_robot_sendmsg --yes --format json -``` - -MCP tool: `remove_open_dev_app_permission` - -一次只取消一个权限点。未开通返回 `NOT_AUTHED`;不可编辑返回 no-edit 原因。 diff --git a/skills/dingtalk-devapp/references/robot.md b/skills/dingtalk-devapp/references/robot.md deleted file mode 100644 index a50f2089..00000000 --- a/skills/dingtalk-devapp/references/robot.md +++ /dev/null @@ -1,189 +0,0 @@ -# 机器人能力 - -为开放平台企业内部应用创建和配置机器人。分两类场景: - -1. **新建智能体机器人**:一次性创建一个新的 Agent 应用 + 承载机器人(`create` / `submit` / `result`)。 -2. **现有应用配置机器人**:在已存在的应用上开启/配置/停用机器人(`get` / `config` / `update` / `enable` / `offline`),通过 `--unified-app-id` 定位。 - -> `corpId` / `userId` 由 MCP 系统上下文注入,CLI 不传。所有写操作先 `--dry-run`,确认后再 `--yes`。 - -## 一、新建智能体机器人 - -### 一条命令创建(推荐) - -```bash -dws devapp robot create --app-name 我的智能体 --robot-name 小助手 --desc "处理审批问答" --dry-run --format json -dws devapp robot create --app-name 我的智能体 --robot-name 小助手 --desc "处理审批问答" --yes --format json -``` - -`create` 会自动提交异步创建任务并轮询直到完成,最终返回 `agentId / robotCode / clientId / clientSecret`(凭据按敏感信息处理)。底层走 `submit_robot_create_task` + `query_robot_create_result`(同步工具 `create_dingtalk_robot` 服务端动作已失效,见 issue #35,不再使用)。 - -| CLI | MCP | 必填 | 说明 | -|-----|-----|------|------| -| `--app-name` | `appName` | 是 | 智能体应用名称,长度 2-20,企业内唯一 | -| `--robot-name` | `robotName` | 是 | 承载机器人名称,客户端展示 | -| `--desc` | `desc` | 是 | 机器人功能描述,≤200 字 | -| `--icon` | `robotMediaId` | 否 | 图标 mediaId;空则用默认图标 | -| `--preview` | `previewMediaId` | 否 | 预览图 mediaId;空则复用图标 | - -### 异步创建 + 查询结果 - -适合创建耗时较长或需要失败重试的场景。 - -```bash -# 提交任务 -dws devapp robot submit --app-name 我的智能体 --robot-name 小助手 --desc "处理审批问答" --dry-run --format json -dws devapp robot submit --app-name 我的智能体 --robot-name 小助手 --desc "处理审批问答" --yes --format json -# → 返回 taskId - -# 轮询结果 -dws devapp robot result --task-id --format json -``` - -MCP tools: `submit_robot_create_task` / `query_robot_create_result` - -- `submit` 返回 `taskId / status / expiresIn / interval / retryCount`,提交成功后通常是 `WAITING`。 -- 失败重试:把上次的 `taskId` 通过 `--task-id` 传入 `submit`,避免重复创建。 -- `result` 返回任务状态;只有 `SUCCESS` 时才能使用返回的 `agentId / robotCode / clientId / clientSecret`。 - -异步创建任务状态: - -| status | 含义 | 下一步 | -|--------|------|--------| -| `WAITING` | 任务已提交,仍在创建中 | 按 `interval` 轮询 `robot result` | -| `SUCCESS` | 创建完成 | 保存 `robotCode/clientId/clientSecret`,凭据按敏感信息处理 | -| `APPROVAL_REQUIRED` | 创建编排返回需审批 | 不要重复建号;按返回信息或开发者后台审批后再继续 | -| `FAIL` | 创建失败 | 读取 `errorCode/errorMsg/failReason`;可带原 `taskId` 重新 `submit` | -| `EXPIRED` | `taskId` 不存在或超过有效期 | 重新 `submit`,必要时换新 `taskId` | - -## 二、现有应用的机器人配置 - -### 查询配置 - -```bash -dws devapp robot get --unified-app-id --format json -``` - -MCP tool: `get_open_dev_app_robot_config`。返回机器人基础信息、回调地址、模式、状态和技能列表。应用尚未配置机器人时后端会返回 `robot info is not exist`。 - -状态判断: - -- `status=1`:OFFLINE,机器人配置存在但处于停用/下线状态。 -- `status=2`:ONLINE,机器人配置已生效;`robotCode` 可用于加群、机器人身份发消息或后续建联。 -- `robot get` 返回 `success=true` 且包含 `robotCode` 时,说明配置已落库,不是异步等待态。 -- ONLINE 只代表开放平台机器人能力已开启。若要让机器人自动处理消息,还需要配置 `--outgoing-url` / `--event-url`,或用 `robot connect` 接到本地 Agent。 -- 未配置机器人时不会返回 `status`,而是业务错误 `robot info is not exist`;这时走 `robot config`,不是 `enable`。 - -### 创建 / 更新 / 启用配置 - -三者字段一致,区别在语义:`config`=首次创建、`update`=修改、`enable`=启用/重新启用。 - -首次 `config` 成功后必须回读 `robot get`:如果返回 `status=2`,不要再误判为“待生效”;只有 `status=1` 或需要重新上架时才调用 `enable`。 - -```bash -dws devapp robot config --unified-app-id --name 小助手 --brief 审批助手 \ - --description "处理审批相关问答" --outgoing-url https://example.com/msg \ - --event-url https://example.com/event --mode 2 --skills qa,approval --dry-run --format json - -dws devapp robot update --unified-app-id --brief "新的简介" --dry-run --format json - -dws devapp robot enable --unified-app-id --name 小助手 --dry-run --format json -``` - -MCP tools: `create_open_dev_app_robot_config` / `update_open_dev_app_robot_config` / `enable_open_dev_app_robot` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--name` | `name` | 机器人名称 | -| `--brief` | `brief` | 简介 | -| `--description` | `description` | 描述 | -| `--icon` | `iconMediaId` | 图标 mediaId | -| `--outgoing-url` | `outgoingUrl` | 消息回调地址 | -| `--event-url` | `chatBotEventUrl` | 事件回调地址 | -| `--mode` | `mode` | 机器人模式枚举(整数) | -| `--skills` | `skillList` | 技能列表,逗号分隔 | -| `--add-scope` | `isAddScope` | 自动添加机器人相关权限 | -| `--disable-ssl-verify` | `disableSSLVerify` | 回调关闭 SSL 校验 | -| `--i18n-name` | `i18nName` | 名称国际化 JSON,如 `'{"en_US":"Bot"}'` | -| `--i18n-brief` | `i18nBrief` | 简介国际化 JSON | -| `--i18n-description` | `i18nDescription` | 描述国际化 JSON | - -至少提供一个配置字段,否则 CLI 报错。 - -### 停用 - -```bash -dws devapp robot offline --unified-app-id --dry-run --format json -dws devapp robot offline --unified-app-id --yes --format json -``` - -MCP tool: `offline_open_dev_app_robot` - -### 建联(把机器人接到本地 agent) - -```bash -# 用现成机器人凭证起 Stream,接到当前渠道的本地 agent(前台运行,Ctrl-C 退出) -dws devapp robot connect --channel auto --robot-client-id --robot-client-secret - -# 用统一应用 ID,复用 credentials get 自动取凭证 -dws devapp robot connect --unified-app-id --channel qoderwork - -# Codex 渠道推荐显式覆盖 agent 命令;否则默认空白工作目录可能触发 -# "Not inside a trusted directory and --skip-git-repo-check was not specified" -CODEX_BIN="$(command -v codex || echo /Applications/Codex.app/Contents/Resources/codex)" -DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check" \ - dws devapp robot connect --unified-app-id --channel codex --format json - -# 预览建联方案不实际起连接 -dws devapp robot connect --robot-client-id --robot-client-secret --dry-run --format json -``` - -只建联、不建号:缺凭证先用 `robot create` / `robot submit` 建号拿 clientId/clientSecret。 - -Codex 渠道注意: - -- `--channel codex` 真实建联时,优先使用上面的 `DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check"` 形式,避免 Codex CLI 在默认临时目录内因非可信 Git 目录拒绝执行。 -- 如需给 Codex 固定知识/项目上下文,可把可信目录直接写进覆盖命令:`DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check -C /path/to/repo"`。路径不要包含空格。 -- 设置 `DWS_AGENT_CMD` 后,DWS 不再自动拼接 `--agent-model` / `--agent-workdir` / 会话参数;需要的参数要一起写进 `DWS_AGENT_CMD`。 - -| flag | 说明 | -|------|------| -| `--channel` | `auto`(默认,运行时信号自动识别) / openclaw / qoder / qoderwork / hermes / workbuddy / claudecode / codebuddy / codex / gemini / opencode | -| `--robot-client-id` / `--robot-client-secret` | 现成机器人凭证(clientId=AppKey, clientSecret=AppSecret)。命名带 `robot-` 前缀以避开全局 OAuth `--client-id` flag | -| `--unified-app-id` | 统一应用 ID,内部调 `get_open_dev_app_credentials` 自动取凭证。⚠️ 字段名待预发真机验证;clientSecret 仅建号时返回一次、未必可取,必要时回退手填 | -| `--agent-memory` | 按会话续聊(默认开):同一群/单聊共享 agent 会话,追问保留上下文。仅 claudecode/codebuddy/workbuddy(CLI 有 `--session-id`/`--resume`);qoder 系/codex/gemini/opencode 无寻址会话,自动保持无状态。`--agent-memory=false` 关闭 | -| `--agent-model` | 覆盖本地 agent 模型(如 claudecode 默认锁 haiku 求快,可改 `claude-sonnet-4-6` 换聪明)。env: `DWS_AGENT_MODEL` | -| `--agent-workdir` | agent 运行目录:放知识文件(如 CLAUDE.md)可给机器人企业上下文。默认空白临时目录(冷启动快 ~4s vs 大目录 ~29s,慢了会错过钉钉响应窗口)。env: `DWS_AGENT_WORKDIR` | -| `--reply-card` | 富回复(默认开):🤔Thinking/🥳Done 表态永远生效;**卡片需配 `--card-template` 才启用**(同 hermes:没配模板=纯文字回复),失败自动回退文字;env `DWS_REPLY_CARD=0` 全关 | -| `--card-template` | AI 卡片模板 ID。**模板按应用授权**:去开发者后台→你的应用→AI 卡片设置注册/获取模板 ID(同 hermes 的配置方式),可去掉公共模板的第三方角标;默认用公共模板 best-effort。env `DWS_CARD_TEMPLATE` | - -#### 建联前的依赖预检(agent 必做) - -渠道背后是本地 agent CLI,用户可能没装。先 `--dry-run` 看 `cli` 字段再决定下一步: - -```bash -dws devapp robot connect --channel --robot-client-id x --robot-client-secret y --dry-run --format json -# 输出里的 cli 字段: -# "cli": {"required":"Claude Code","installed":false,"autoInstall":true,"installHint":"npm i -g @anthropic-ai/claude-code"} -``` - -| cli 状态 | agent 应该做什么 | -|----------|----------------| -| `installed: true` | 直接建联 | -| `installed: false, autoInstall: true` | 告知用户缺哪个 CLI,说明启动建联时会自动 `npm` 安装(或先手动执行 installHint 里的命令再连);`DWS_CONNECT_NO_INSTALL=1` 可禁自动安装 | -| `installed: false, autoInstall: false` | **不要直接起连接**——桌面 App 渠道(qoder/qoderwork/workbuddy)需要用户先安装对应 App(installHint 是下载地址),装好后 CLI 随 App 自带;openclaw/hermes 引导用户走官方 onboarding | - -- **stream-bridge 渠道**:Go 原生进程内 Stream 转发器,订阅 `TOPIC_ROBOT`,每条 @机器人消息起一个无头 CLI 实例 → stdout 回钉钉,可 7×24 无人值守。 -- **会话记忆**:首条消息 `--session-id ` 建会话,后续 `--resume ` 续聊;会话状态在内存里,连接器重启后从新会话开始;某会话坏了会自愈(下条消息换新会话)。 -- **官方渠道**(openclaw/hermes):dws 不代建机器人,输出官方 onboarding 指引。 -- 环境覆盖:`DWS_AGENT_CMD`(整条命令覆盖,覆盖时不再注入模型/会话参数) / `DWS_AGENT_MODEL` / `DWS_AGENT_WORKDIR` / `DWS_CONNECT_CMD` / `DWS_CONNECT_NO_INSTALL=1` / `DWS_AGENT_TIMEOUT_MS`。 - -## 错误处理 - -| 情况 | 处理 | -|------|------| -| `robot info is not exist` | 应用未配置机器人,先用 `robot config` 创建 | -| 应用名重复 | `app-name` 企业内需唯一,换个名字 | -| 本地 Codex agent 返回 `Not inside a trusted directory and --skip-git-repo-check was not specified` | 用 `DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check"` 重启 `robot connect --channel codex` | -| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | -| 创建任务 `EXPIRED` | 任务过期,重新 `submit`(可带原 taskId 重试) | diff --git a/skills/dingtalk-devapp/references/version.md b/skills/dingtalk-devapp/references/version.md deleted file mode 100644 index 0998ec67..00000000 --- a/skills/dingtalk-devapp/references/version.md +++ /dev/null @@ -1,129 +0,0 @@ -# 版本发布 - -管理开放平台企业内部应用的版本:基于当前配置创建版本、查看版本列表/详情、预检审批、发布、查状态。 - -> `corpId` / `userId` 由 MCP 系统上下文注入,CLI 不传。所有版本通过 `--unified-app-id` 定位,单个版本再加 `--version-id`。 - -## 典型流程 - -```text -permission add(requiredApproval=true 写入版本变更) - → version create 创建版本 - → version check-approval 预检是否需要审批 / 审批人 - → version publish 发布(含高敏权限需 --confirm-sensitive) - → version status 轮询发布/审批状态 -``` - -## 创建版本 - -```bash -dws devapp version create --unified-app-id --version 1.0.1 --desc "新增机器人能力" --dry-run --format json -dws devapp version create --unified-app-id --version 1.0.1 --desc "新增机器人能力" --yes --format json -``` - -MCP tool: `create_open_dev_app_version` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--version` | `version` | 版本号,如 1.0.1 | -| `--desc` | `description` | 版本描述 | - -## 版本列表 - -```bash -dws devapp version list --unified-app-id --page 1 --page-size 20 --format json -``` - -MCP tool: `list_open_dev_app_versions`(`--page`→`currentPage`,`--page-size`→`pageSize`) - -新应用如果 `version list` 返回空,先执行 `version create`,用返回的 `versionId` 继续 `check-approval/publish`;不要因为列表为空误判无可发布内容。 - -## 版本详情 - -```bash -dws devapp version get --unified-app-id --version-id --format json -``` - -MCP tool: `get_open_dev_app_version_detail`。返回版本状态、描述、能力列表、权限点、敏感权限、审批要求和脱敏详情。 - -## 预检审批(不发布) - -```bash -dws devapp version check-approval --unified-app-id --version-id --format json -``` - -MCP tool: `publish_open_dev_app_version`,CLI 强制 `dryRun=true`,仅返回审批要求和候选审批人,**不会实际发布**。 - -## 发布 - -```bash -dws devapp version publish --unified-app-id --version-id --dry-run --format json -dws devapp version publish --unified-app-id --version-id --yes --format json - -# 含高敏权限时必须确认 -dws devapp version publish --unified-app-id --version-id --confirm-sensitive --yes --format json - -# 灰度选人模式指定审批人 -dws devapp version publish --unified-app-id --version-id --approver --yes --format json -``` - -MCP tool: `publish_open_dev_app_version`,CLI 设 `dryRun=false`。 - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--confirm-sensitive` | `confirmedSensitive` | 版本含高敏权限时必须确认 | -| `--approver` | `approverUserId` | 灰度选人模式指定审批人 userId | - -> 注意:`--dry-run` 是 CLI 层的"预览不执行"开关;服务端的"审批预检"是 `version check-approval`(对应 `dryRun=true`)。二者不同,发布前建议先 `check-approval`。 - -发布响应 `result`: - -| result | 含义 | 下一步 | -|--------|------|--------| -| `NOT_REQUIRED` | 不需要审批;预检时表示可直接发布,真实发布时表示已直接发布上线 | 真实发布后回读 `version status/get` 验证 `RELEASE` | -| `APPROVAL_REQUIRED` | 需要审批,通常出现在 `check-approval` 或未指定审批人时 | 查看 `approvalMode/approvalCandidates`,让用户选择审批人后再 `publish --approver` | -| `SUBMITTED` | 已提交审批 | 保存 `processId`,轮询 `version status` | - -审批模式 `approvalMode`: - -| approvalMode | 含义 | 下一步 | -|--------------|------|--------| -| `SELECT_APPROVER` | 灰度选人模式,需要在候选审批人中选一个 | 展示候选审批人,不自动选第一个 | -| `ENTERPRISE_SELF_BUILT` | 企业自建审核模式 | 不传 `--approver`,按企业自建审核流程等待 | - -## 版本状态 - -```bash -dws devapp version status --unified-app-id --version-id --format json -``` - -MCP tool: `get_open_dev_app_version_status`。返回版本状态、流程实例 ID、审批状态和审批意见。审批详情可能只在钉钉客户端可见。 - -版本状态字段:`version create/list/get` 返回 `status`,`version status` 返回 `versionStatus`,二者都对齐版本枚举。 - -| status/versionStatus | 含义 | 下一步 | -|----------------------|------|--------| -| `INIT` | 版本已创建或有待发布变更,尚未发布 | 可 `check-approval` / `publish` | -| `AUDIT` | 发布审核中 | 不要重复发布;即使没有返回 `processStatus`,也按审核中处理 | -| `RELEASE` | 已发布生效 | 发布完成,可继续验证权限、机器人、网页应用等能力 | -| `GRAY` | 灰度状态 | 按灰度流程处理;不要当全量已发布 | - -审批流程状态字段:`version status` 的 `processStatus` 只在存在审批流程且后端透出流程态时有值。`versionStatus=AUDIT` 但没有 `processStatus/processInstanceId` 时,不要判失败,仍表示审核中。 - -| processStatus | 含义 | 下一步 | -|---------------|------|--------| -| `UNDER_REVIEW` | 审批中 | 等待审批,必要时把 `processInstanceId` 给用户去钉钉客户端查看 | -| `PASS` | 审批通过 | 继续回读版本,确认是否进入 `RELEASE` | -| `FAIL` | 审批拒绝 | 展示 `processComment`,修改后重新创建/发布版本 | -| `WITHDRAW` / `CANCEL` | 审批撤回或取消 | 回到发布前状态,重新 `check-approval` / `publish` | -| `PUBLISH_FAILED` | 审批后发布失败 | 展示错误信息,重新检查版本状态和后端错误 | - -遇到未列出的状态值时,不要猜测语义;原样展示状态,并回读 `version get/status` 或查开放平台文档/后台详情。 - -## 错误处理 - -| 情况 | 处理 | -|------|------| -| `check-approval` 提示需审批 | 按返回选审批人,再 `publish --approver` | -| 发布报高敏权限未确认 | 加 `--confirm-sensitive` 重新发布 | -| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | diff --git a/skills/dingtalk-devapp/references/workflows.md b/skills/dingtalk-devapp/references/workflows.md deleted file mode 100644 index 9347fbee..00000000 --- a/skills/dingtalk-devapp/references/workflows.md +++ /dev/null @@ -1,177 +0,0 @@ -# 操作流程 - -端到端操作流程,组合多个命令完成完整业务场景。 - -## 创建应用并完成基础配置 - -```text -1. 创建应用 - dws devapp create --name "应用名" --desc "描述" --type internal --dry-run --format json - → 确认后加 --yes - → 记录返回的 unifiedAppId 和 agentId - -2. 确认创建成功 - dws devapp get --unified-app-id --format json - -3. 读取凭证(获取 appKey/appSecret) - dws devapp credentials get --unified-app-id --format json - -4. 配置网页应用(按需) - dws devapp webapp config --agent-id --homepage-link --dry-run --format json - → 确认后加 --yes - -5. 验证网页应用配置 - dws devapp webapp get --agent-id --format json - → 确认返回 homepageLink/pcHomepageLink 等 - -6. 申请权限(按需) - dws devapp permission list --unified-app-id --keyword "关键词" --format json - → 从返回中选择 scopeValue - dws devapp permission add --unified-app-id --permissions --dry-run --format json - → 确认后加 --yes - -7. 添加开发成员(按需) - dws devapp member add --app-id --users --member-type DEVELOPER --dry-run --format json - → 确认后加 --yes - -8. 验证成员 - dws devapp member list --app-id --format json - -9. 配置安全设置(按需) - dws devapp security config --app-id --ip-whitelist --redirect-url --dry-run --format json - → 确认后加 --yes -``` - -## 权限管理全流程 - -```text -1. 查看所有权限 - dws devapp permission list --unified-app-id --format json - -2. 按关键词搜索未开通权限 - dws devapp permission list --unified-app-id --keyword "机器人" --status UNAUTHED --format json - -3. 查看某个权限覆盖的 API - dws devapp permission list --unified-app-id --scope --format json - -4. 申请权限 - dws devapp permission add --unified-app-id --permissions --dry-run --format json - → 确认后加 --yes - → requiredApproval=true 的权限:写入版本变更,需后续走版本发布审核 - -5. 验证 - dws devapp permission list --unified-app-id --scope --format json - → 确认 authed=true - -6. 取消不需要的权限 - dws devapp permission remove --unified-app-id --permission --dry-run --format json - → 确认后加 --yes -``` - -## 应用生命周期 - -```text -停用(保留数据,暂停服务): - dws devapp get --unified-app-id --format json ← 确认目标 - dws devapp inactive --unified-app-id --dry-run --format json - → 确认后加 --yes - dws devapp get --unified-app-id --format json ← 验证状态 - -启用(恢复已停用应用): - dws devapp active --unified-app-id --dry-run --format json - → 确认后加 --yes - dws devapp get --unified-app-id --format json ← 验证状态 - -删除(不可逆,异步生效): - dws devapp get --unified-app-id --format json ← 展示摘要 - dws devapp delete --unified-app-id --dry-run --format json - → 用户明确确认后加 --yes - dws devapp list --format json ← 验证已消失 -``` - -## 修改应用信息 - -```text -1. 定位应用(若只有名称) - dws devapp list --name "应用名" --format json - → 必须唯一命中 - -2. 修改 - dws devapp update --unified-app-id --name "新名" --desc "新描述" --dry-run --format json - → 确认后加 --yes - -3. 验证 - dws devapp get --unified-app-id --format json -``` - -## 成员管理 - -```text -1. 查看当前成员 - dws devapp member list --app-id --format json - -2. 添加成员 - dws devapp member add --app-id --users --member-type DEVELOPER --dry-run --format json - → 确认后加 --yes - -3. 验证 - dws devapp member list --app-id --format json - -4. 移除成员 - dws devapp member remove --app-id --users --member-type DEVELOPER --dry-run --format json - → 确认后加 --yes - -5. 验证 - dws devapp member list --app-id --format json -``` - -## 安全配置 - -```text -1. 配置 IP 白名单 / 重定向 URL / 免登 URL - dws devapp security config --app-id \ - --ip-whitelist 192.0.2.10,192.0.2.11 \ - --redirect-url https://callback.example.invalid/callback \ - --sso-url https://sso.example.invalid/sso \ - --dry-run --format json - → 确认后加 --yes - -注意:只下发显式提供的字段,未提供的不覆盖。 -``` - -## 版本发布与审批(选审批人) - -```text -1. 创建版本(权限变更等已通过 permission add 写入版本) - dws devapp version create --unified-app-id --version 1.0.1 --desc "变更说明" --yes --format json - → 记录返回的 versionId - -2. 审批预检:获取审批要求和候选审批人列表 - dws devapp version check-approval --unified-app-id --version-id --format json - → 服务端 dryRun 预检,不实际发布 - → 返回中包含是否需要审批、候选审批人(userId + 姓名) - -3. 把候选审批人列表展示给用户,让用户选择 - ⚠️ 必须由用户拍板选哪个审批人,agent 不要自行挑选或默认取第一个 - -4. 用选中的审批人发起发布审批 - dws devapp version publish --unified-app-id --version-id --approver <用户选中的userId> --yes --format json - → 含高敏权限时需追加 --confirm-sensitive - → 审批流程会自动推送给该审批人 - -5. 跟踪审批/发布状态 - dws devapp version status --unified-app-id --version-id --format json - → 审批通过后应用才正式发布;机器人等能力需发布后才可被搜索/加群/路由消息 -``` - -## 通用规则 - -所有操作遵循: - -```text -意图消歧 → 定位唯一应用 → dry-run 预览 → 用户确认 → --yes 执行 → 透传结果 -``` - -- 多应用命中时展示候选,不自动选择 -- `ServiceResult.success=false` 原样透传 `errorCode/errorMsg` -- 待实现的 event 命令遇到时报告功能待上线 diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index b46c04be..cff58a5c 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -1,6 +1,6 @@ --- name: dws -description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/开放平台应用/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库、管理开放平台企业内部应用、应用成员、安全配置或应用权限时使用。 +description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 cli_version: ">=1.0.15" --- @@ -49,7 +49,6 @@ cli_version: ">=1.0.15" | `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) | | `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/花名册档案(学历/家庭/银行卡/合同)/离职员工查询(姓名/时间范围/部门)/部门查询(搜索/详情/子部门/成员)/角色查询(主管/管理员/财务/HR 等 label)/特别关注列表 | [contact.md](./references/products/contact.md) | | `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) | -| `devapp` | 开放平台应用:企业内部应用查询/详情/创建/修改/删除、成员管理、安全配置、应用权限 APP/SNS 列表与申请取消、凭证读取、机器人创建与配置、版本创建/发布/审批 | [devapp.md](./references/products/devapp.md) | | `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) | | `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论/文件创建/复制/移动/重命名/**删除/导出 docx/权限管理/媒体上传下载**;创建/编辑先按渐进式 doc 子文档与 JSONML 工作流决策 | [doc.md](./references/products/doc.md) | | `drive` | 钉钉云盘:文件列表/元数据/文件夹/上传(两步)/下载 | [drive.md](./references/products/drive.md) | @@ -83,13 +82,10 @@ cli_version: ">=1.0.15" ### 消歧规则 -- "应用" 是泛词,不默认等于开放平台应用。只有出现"开放平台应用/开发者后台应用/企业内部应用", - 或伴随 `agentId/clientId/appKey/appSecret/customKey/应用成员/安全配置/IP 白名单/登录重定向/应用权限/权限点/创建机器人/智能体机器人/机器人配置/事件订阅/应用版本/发布审核` - 等开发者后台标识时,才路由到 `devapp`。 -- "工作台应用/app001/appXYZ/钉钉工作台上有哪些应用" → `workbench app`,不要走 `devapp`。 -- "开放平台接口文档/API 怎么调用/错误码/字段说明" → `devdoc`,不要走 `devapp`。 -- "MCP 服务/connector/创建工具/HSF 映射/上架 MCP" → MCP 平台配置流程,不是 `dws devapp create`。 -- 只说"应用"且缺少上下文时,先结合当前对话判断;仍不明确时追问是开放平台企业内部应用、工作台应用、文档能力还是 MCP 工具。 +- "工作台应用/app001/appXYZ/钉钉工作台上有哪些应用" → `workbench app`。 +- "开放平台接口文档/API 怎么调用/错误码/字段说明" → `devdoc`。 +- "MCP 服务/connector/创建工具/HSF 映射/上架 MCP" → MCP 平台配置流程。 +- 只说"应用"且缺少上下文时,先结合当前对话判断;仍不明确时追问是工作台应用、文档能力还是 MCP 工具。 - "知识库空间/成员管理" → `wiki` 产品参考;"知识库里的文档/文件/内容" → #4 文档知识,再切 `doc`。 - "创建表格/在线电子表格/单元格" → `sheet`;"AI 表格/多维表/base/记录/字段/视图" → #6 AI 表格数据。 - "写文档/读文档/总结文档/插入图片附件/块级编辑/JSONML 保真改写" → #4 文档知识,不要停在 `doc create` 或 `doc block --help`;创建/编辑必须继续按 [doc.md](./references/products/doc.md) 加载渐进式子文档和 JSONML workflow。 @@ -104,7 +100,6 @@ cli_version: ">=1.0.15" 用户提到"群聊/建群/群成员/群管理/发消息/发图片消息/发文件消息/发 Markdown 消息/截图发钉钉/转发消息/引用回复/@我/特别关注消息/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat` 用户提到"通讯录/同事/部门/组织架构/子部门/部门多少人/离职员工/离职名单/离职花名册/花名册/员工档案/学历/家庭/银行卡/紧急联系人/合同/角色/主管角色/管理员角色/财务/HR/特别关注/星标联系人" → `contact` 用户提到"开发/API/调用错误 文档" → `devdoc` -用户提到"开放平台应用/开发者后台应用/企业内部应用/应用 agentId/clientId/appKey/appSecret/应用成员/安全配置/IP 白名单/登录重定向/应用权限/权限点/创建机器人/智能体机器人/机器人配置/事件订阅/应用版本发布/审核" → `devapp` 用户提到"DING/紧急消息/电话提醒" → `ding` 用户提到"钉钉文档/云文档/读写文档/知识库里的文档/浏览知识库内容/知识库内搜索文档/块级编辑/文档评论/文档复制移动" → `doc` 用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive` diff --git a/skills/mono/references/intent-guide.md b/skills/mono/references/intent-guide.md index bda871ab..17c4d9e2 100644 --- a/skills/mono/references/intent-guide.md +++ b/skills/mono/references/intent-guide.md @@ -7,13 +7,11 @@ | 用户说... | 真实意图 | 应该用 | 不要用 | 理由 | |-----------|----------|--------|--------|------| | "搜一下 OAuth2 接入文档" | 搜索开发文档 | `devdoc` | — | 搜索开放平台技术文档,不是钉钉内部内容 | -| "查询开放平台 API 错误码/字段说明" | 搜索开放平台接口文档 | `devdoc` | `devapp` | 查 API 用法和错误码,不是管理企业内部应用 | -| "查开放平台应用 DemoApp 的 appKey" | 查询开发者后台企业内部应用 | `devapp` | `devdoc` | 目标是应用实例和凭证/agentId,不是接口文档 | -| "给应用申请 qyapi_robot_sendmsg 权限" | 管理开放平台应用权限 | `devapp permission` | `devdoc` | 权限点申请属于应用配置变更 | -| "查工作台应用 appXYZ 的详情" | 查询钉钉工作台应用 | `workbench app` | `devapp` | `appXYZ/app001` 这类工作台 app id 不是开放平台 `unifiedAppId` | -| "帮我创建一个应用" | 应用类型不明确 | 先追问 | `devapp create` | 缺少开放平台/工作台/AI/MCP 等上下文,不能默认创建企业内部应用 | -| "做一个 AI 应用" | AI 应用/智能体需求不明确 | 先追问或转对应能力 | `devapp create` | 没有开发者后台企业内部应用标识,不能默认走 OpenDev app CRUD | -| "创建 MCP 服务并配置 HSF tool" | MCP 平台工具配置 | OpenDev MCP 平台流程 | `devapp create` | 这是 connector/tool 配置,不是创建开放平台企业内部应用 | +| "查询开放平台 API 错误码/字段说明" | 搜索开放平台接口文档 | `devdoc` | — | 查 API 用法和错误码 | +| "查工作台应用 appXYZ 的详情" | 查询钉钉工作台应用 | `workbench app` | — | `appXYZ/app001` 是工作台 app id | +| "帮我创建一个应用" | 应用类型不明确 | 先追问 | — | 缺少工作台/AI/MCP 等上下文,不能默认 | +| "做一个 AI 应用" | AI 应用/智能体需求不明确 | 先追问或转对应能力 | — | 需求不明确时不默认走任何创建命令 | +| "创建 MCP 服务并配置 HSF tool" | MCP 平台工具配置 | OpenDev MCP 平台流程 | — | 这是 connector/tool 配置 | | "帮我建一个项目跟踪表" | 创建数据表格 | `aitable` | `todo` | 涉及结构化数据/行列操作,不是个人待办 | | "帮我记一下明天要做的事" | 创建个人待办 | `todo` | `aitable` | 个人待办提醒,非数据表 | | "帮我建一个明天下午的日程" | 日历日程 | `calendar` | — | 日历日程管理(可含参与者/会议室)| @@ -47,15 +45,9 @@ --- -### 2. devapp vs devdoc vs workbench — 应用泛词消歧 +### 2. devdoc vs workbench — 应用泛词消歧 -`应用` 是泛词,不能直接等价为开放平台企业内部应用。 - -**用 `devapp` 的场景**: -- "开放平台应用/开发者后台应用/企业内部应用" — 管理开发者后台应用实例 -- "查 appKey/clientId/agentId/appSecret/clientSecret" — 应用身份和凭证 -- "应用权限/权限点/事件订阅/应用版本/发布审核" — 应用配置或发布链路 -- "创建机器人/智能体机器人/机器人配置/机器人回调" — 应用机器人能力(`devapp robot`) +`应用` 是泛词,不能直接等价为某个产品。 **用 `devdoc` 的场景**: - "开放平台 API 怎么调用" — 接口文档 @@ -63,14 +55,14 @@ **用 `workbench app` 的场景**: - "工作台应用/钉钉工作台上的应用" -- "应用 app001/appXYZ 的详情" 且没有开放平台上下文 +- "应用 app001/appXYZ 的详情" **先追问的场景**: -- "创建应用/做个应用/查应用" 但没有开放平台、工作台、文档、AI、MCP 等上下文 -- "AI 应用/智能体" 但没有说明是开发者后台企业内部应用还是其他产品能力 -- "MCP 服务/connector/HSF tool" 属于平台配置流程,不要转成 `devapp create` +- "创建应用/做个应用/查应用" 但没有工作台、文档、AI、MCP 等上下文 +- "AI 应用/智能体" 但没有说明是哪种产品能力 +- "MCP 服务/connector/HSF tool" 属于平台配置流程,不要转成任何创建命令 -**判断关键**:是否在管理一个开发者后台企业内部应用实例?是 → `devapp`。是否在查 API 文档?是 → `devdoc`。是否在查钉钉工作台入口应用?是 → `workbench app`。仍不明确 → 追问,不调用写命令。 +**判断关键**:是否在查 API 文档?是 → `devdoc`。是否在查钉钉工作台入口应用?是 → `workbench app`。仍不明确 → 追问,不调用写命令。 --- diff --git a/skills/mono/references/products/devapp.md b/skills/mono/references/products/devapp.md deleted file mode 100644 index b546ca58..00000000 --- a/skills/mono/references/products/devapp.md +++ /dev/null @@ -1,364 +0,0 @@ -# 开放平台应用 (devapp) 命令参考 - -管理钉钉开放平台企业内部应用。覆盖应用 CRUD、生命周期启停、凭证读取、权限管理、网页应用配置、成员管理和安全配置。 - -> `dws devapp ...` 是内置 helper 命令,不依赖 MCP 服务发现。`dws app ...` 是兼容别名。执行前用 `dws devapp --help` 验证可用。 - -## 核心规则 - -1. 所有命令加 `--format json`。 -2. 写操作先 `--dry-run`,确认后才加 `--yes`。 -3. 应用名/appKey/customKey 命中多条时展示候选,不取第一条。 -4. 权限申请/取消只接受 `scopeValue`,不传 API 名或分组名。 -5. 主动读取密钥走 `credentials get`;任何 `devapp get`/详情返回里的 `clientSecret/appSecret` 都按敏感凭证脱敏,不向用户展开。 - -## 应用定位 - -| 优先级 | 标识 | 处理 | -|--------|------|------| -| 1 | `--unified-app-id` | 直接使用 | -| 2 | `--agent-id` / `--app-id` | 直接使用 | -| 3 | `--app-key` / `--custom-key` | 先查询,唯一命中才继续 | -| 4 | `--name` | 模糊搜索,写操作必须唯一命中 | - ---- - -## 一、应用基础操作 - -### 列表 - -```bash -dws devapp list --format json -dws devapp list --name DemoApp --format json -dws devapp list --agent-id 123456 --format json -``` - -MCP tool: `list_open_dev_apps_by_condition` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--page` | `currentPage` | 1-based,默认 1 | -| `--page-size` | `pageSize` | 默认 20 | -| `--name` / `--keyword` | `appName` | 应用名搜索 | -| `--agent-id` | `agentId` | 精确定位 | -| `--app-key` | `appKey` | appKey/clientId | -| `--creator` | `creator` | 创建人关键词 | -| `--sort` | `sortType` | 如 `gmt_modified` | -| `--order` | `sortOrder` | `asc` / `desc` | - -应用状态字段:列表/详情原始字段里如果出现 `status` / `appStatus`,`0=IN_ACTIVE` 已停用、`1=ACTIVE` 已激活、`2=WAIT_ACTIVE` 待激活、`3=EXPIRED` 已过期。执行 `inactive/active` 后必须回读 `get` 或 `list`:看到 `status=0` 才算停用完成,看到 `status=1` 才算启用完成。`versionStatus` 是版本状态,不要和应用启停状态混用。 - -### 详情 - -```bash -dws devapp get --unified-app-id UNIFIED_APP_ID --format json -``` - -MCP tool: `get_open_dev_app_detail` - -详情主要用于定位和核验应用。若上游偶尔随详情返回 `clientSecret/appSecret`,必须脱敏处理,不要复制到回答里;主动读取凭证仍走 `credentials get`。 - -### 创建 - -```bash -dws devapp create --name DemoApp --desc "内部应用" --type internal --dry-run --format json -dws devapp create --name DemoApp --desc "内部应用" --type internal --yes --format json -``` - -MCP tool: `create_inner_app`。`--type` 只做 CLI 校验,当前仅支持 `internal`。 - -### 修改 - -```bash -dws devapp update --unified-app-id ID --name DemoApp2 --desc "新描述" --dry-run --format json -``` - -MCP tool: `update_inner_app`。至少一个更新字段:`--name` / `--desc` / `--icon`。 - -### 停用 / 启用 - -```bash -dws devapp inactive --unified-app-id ID --dry-run --format json -dws devapp active --unified-app-id ID --dry-run --format json -``` - -MCP tools: `inactive_inner_app` / `active_inner_app` - -### 删除 - -```bash -dws devapp delete --unified-app-id ID --dry-run --format json -``` - -MCP tool: `delete_inner_app`。删除前必须展示应用摘要,异步生效。 - ---- - -## 二、凭证与网页应用 - -### 凭证读取 - -```bash -dws devapp credentials get --unified-app-id UNIFIED_APP_ID --format json -``` - -MCP tool: `get_open_dev_app_credentials`。返回含 `clientSecret/appSecret`,按敏感凭证处理。不能用 `devapp get` 代替;如果 `devapp get` 偶尔返回密钥,也只用于内部判断并脱敏。 - -### 网页应用 - -```bash -dws devapp webapp get --agent-id AGENT_ID --format json -dws devapp webapp config --agent-id AGENT_ID --homepage-link https://example.com --dry-run --format json -``` - -MCP tools: `get_webapp_config` / `set_webapp_config` - -`h5PageType` 未显式传入时,不要假设固定默认值;配置后以 `webapp get` 回读为准(实跑可能返回 `mobile`)。 - ---- - -## 三、权限管理 - -### 权限列表 - -```bash -dws devapp permission list --unified-app-id ID --format json -dws devapp permission list --unified-app-id ID --keyword "机器人" --status UNAUTHED --format json -dws devapp permission list --unified-app-id ID --scope qyapi_robot_sendmsg --format json -``` - -MCP tool: `list_open_dev_app_permissions` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--keyword` | `keyword` | 关键词搜索 | -| `--status` | `authStatus` | `ALL/AUTHED/UNAUTHED` | -| `--scope-type` | `firstLevelType` | `APP/SNS`,空返回两者 | -| `--scope` | `scopeValue` | 单权限详情模式 | -| `--limit` | `limit` | 每页数量,默认 20,建议不超过 50 | -| `--offset` | `offset` | 翻页偏移量,默认 0 | - -权限状态判断: - -- `--status` 是查询过滤条件:`ALL` 不过滤,`AUTHED` 只看已授权/已开通,`UNAUTHED` 只看未授权/未开通。 -- 单个权限项的 `status` 是内部操作态:`0` 已获得,`1` 申请中,`2` 可以申请,`3` 不可以申请。 -- `status=0` 不要重复申请;如需取消,确认 `canRemove=true` 后走 `permission remove`。 -- `status=1` 不要重复申请;查看 `authedStatusDesc`,通常等待审批或版本发布。 -- `status=2` 可走 `permission add --dry-run`。 -- `status=3` 停止申请,展示 `applyDisabledReason/displayMessage`。 -- `authedStatusDesc` 细分展示:`OPENED`/`APPLIED`/`TO_BE_PUBLISHED` 表示已开通、已申请或待发布;`NOT_OPEN`/`NOT_APPLIED` 表示未开通/未申请;`AUDIT_PROCESSING` 表示审批中;`AUDIT_REFUSE` 表示审批未通过。能否操作仍以 `status`、`canEdit`、`canApplyDirectly`、`allowedActions` 为准。 - -翻页:`--limit 50 --offset 0`(第1页)、`--offset 50`(第2页)、`--offset 100`(第3页),返回条数 < limit 表示末尾。 - -### 申请权限 - -```bash -dws devapp permission add --unified-app-id ID --permissions qyapi_robot_sendmsg --dry-run --format json -``` - -MCP tool: `apply_open_dev_app_permissions`。`requiredApproval=true` 允许申请,写入版本变更。 - -### 取消权限 - -```bash -dws devapp permission remove --unified-app-id ID --permission qyapi_robot_sendmsg --dry-run --format json -``` - -MCP tool: `remove_open_dev_app_permission`。一次只取消一个。 - ---- - -## 四、成员与安全 - -### 成员管理 - -```bash -dws devapp member list --app-id UNIFIED_APP_ID --format json -dws devapp member add --app-id UNIFIED_APP_ID --users userId1,userId2 --member-type DEVELOPER --dry-run --format json -dws devapp member remove --app-id UNIFIED_APP_ID --users userId1 --member-type DEVELOPER --dry-run --format json -``` - -MCP tools: `list_open_dev_app_members` / `add_open_dev_app_members` / `remove_open_dev_app_members` - -### 安全配置 - -```bash -dws devapp security config --app-id UNIFIED_APP_ID --ip-whitelist 192.0.2.10 --redirect-url https://callback.example.invalid/callback --dry-run --format json -``` - -MCP tool: `update_app_security_config` - ---- - -## 五、机器人 - -### 新建智能体机器人 - -```bash -# 一条命令创建(自动提交+轮询,返回 agentId/robotCode/clientId/clientSecret) -dws devapp robot create --app-name 我的智能体 --robot-name 小助手 --desc "处理审批问答" --dry-run --format json - -# 手动异步创建 + 查询(需要自己控制重试节奏时用) -dws devapp robot submit --app-name 我的智能体 --robot-name 小助手 --desc "处理审批问答" --dry-run --format json -dws devapp robot result --task-id TASK_ID --format json -``` - -MCP tools: `submit_robot_create_task` / `query_robot_create_result`。`create` 内部即走这两个工具(自动轮询直到终态);`submit` 失败可带原 `--task-id` 重试。同步工具 `create_dingtalk_robot` 服务端动作已失效(issue #35),不再使用。 - -异步创建任务状态: - -| status | 含义 | 下一步 | -|--------|------|--------| -| `WAITING` | 任务已提交,仍在创建中 | 按 `interval` 轮询 `robot result` | -| `SUCCESS` | 创建完成 | 保存 `robotCode/clientId/clientSecret`,凭据按敏感信息处理 | -| `APPROVAL_REQUIRED` | 创建编排返回需审批 | 不要重复建号;按返回信息或开发者后台审批后再继续 | -| `FAIL` | 创建失败 | 读取 `errorCode/errorMsg/failReason`;可带原 `taskId` 重新 `submit` | -| `EXPIRED` | `taskId` 不存在或超过有效期 | 重新 `submit`,必要时换新 `taskId` | - -### 现有应用配置机器人 - -```bash -dws devapp robot get --unified-app-id ID --format json -dws devapp robot config --unified-app-id ID --name 小助手 --brief 审批助手 --outgoing-url URL --mode 2 --skills qa,approval --dry-run --format json -dws devapp robot update --unified-app-id ID --brief "新简介" --dry-run --format json -dws devapp robot enable --unified-app-id ID --name 小助手 --dry-run --format json -dws devapp robot offline --unified-app-id ID --dry-run --format json -``` - -MCP tools: `get_open_dev_app_robot_config` / `create_open_dev_app_robot_config` / `update_open_dev_app_robot_config` / `enable_open_dev_app_robot` / `offline_open_dev_app_robot`。 - -配置字段:`--name/--brief/--description/--icon/--outgoing-url(outgoingUrl)/--event-url(chatBotEventUrl)/--mode/--skills(skillList)/--add-scope/--disable-ssl-verify/--i18n-name/--i18n-brief/--i18n-description`。应用未配机器人时 `get` 返回 `robot info is not exist`。 - -状态判断: - -- `robot get` 返回 `success=true` 且包含 `robotCode` 时,说明机器人配置已落库,不是异步等待态。 -- `status=1`:OFFLINE,机器人配置存在但处于停用/下线状态。 -- `status=2`:ONLINE,机器人配置已生效;`robotCode` 可用于加群、机器人身份发消息或后续建联。 -- ONLINE 只代表开放平台机器人能力已开启。若要让机器人自动处理消息,还需要配置 `--outgoing-url` / `--event-url`,或用 `robot connect` 接到本地 Agent。 -- 首次 `config` 成功后必须回读 `robot get`:如果返回 `status=2`,不要再误判为“待生效”;只有 `status=1` 或需要重新上架时才调用 `enable`。 -- 未配置机器人时不会返回 `status`,而是业务错误 `robot info is not exist`;这时走 `robot config`,不是 `enable`。 - -### 建联(把机器人接到本地 agent) - -```bash -# 用现成机器人凭证起 Stream,接到当前渠道的本地 agent(前台运行,Ctrl-C 退出) -dws devapp robot connect --channel auto --robot-client-id CLIENT_ID --robot-client-secret CLIENT_SECRET - -# 用统一应用 ID,复用 credentials get 自动取凭证(省得手填) -dws devapp robot connect --unified-app-id UNIFIED_APP_ID --channel qoderwork - -# Codex 渠道推荐显式覆盖 agent 命令;否则默认临时目录可能触发 -# "Not inside a trusted directory and --skip-git-repo-check was not specified" -CODEX_BIN="$(command -v codex || echo /Applications/Codex.app/Contents/Resources/codex)" -DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check" \ - dws devapp robot connect --unified-app-id UNIFIED_APP_ID --channel codex --format json - -# 预览建联方案不实际起连接 -dws devapp robot connect --robot-client-id CLIENT_ID --robot-client-secret CLIENT_SECRET --dry-run --format json -``` - -- **不建号**:connect 只建联。缺凭证先用 `robot create/submit` 建号拿 clientId/clientSecret。 -- **凭证用 `--robot-client-id/--robot-client-secret`**(不叫 `--client-id`,那是全局 OAuth 客户端覆盖 flag,会撞名)。 -- **两种来源**:① 直接 `--robot-client-id/--robot-client-secret`;② `--unified-app-id`(内部调 `get_open_dev_app_credentials` 自动取)。⚠️ 来源②**字段名待预发真机验证**,且 clientSecret 一般仅建号时返回一次、该接口未必能返回,取不到时回退手填。 -- **渠道 `--channel`**:`auto`(默认,按运行时信号自动识别当前宿主) | `openclaw` | `qoder` | `qoderwork` | `hermes` | `workbuddy` | `claudecode` | `codebuddy` | `codex` | `gemini` | `opencode`。 -- **会话记忆 `--agent-memory`**(默认开):同一群/单聊共享 agent 会话,追问保留上下文。仅 claudecode/codebuddy/workbuddy 支持(CLI 有 `--session-id/--resume`);其余渠道自动无状态。`--agent-memory=false` 关闭。 -- **模型覆盖 `--agent-model`**:claudecode 默认锁 haiku 求快,可改 sonnet/opus 换聪明(env `DWS_AGENT_MODEL`)。 -- **运行目录 `--agent-workdir`**:放知识文件给机器人企业上下文;默认空白临时目录求快(env `DWS_AGENT_WORKDIR`)。 -- **富回复 `--reply-card`**(默认开):Thinking/Done 表态永远生效;**卡片需配 `--card-template` 才启用**(同 hermes 语义:没配模板=纯文字回复+表态),失败自动回退文字(env `DWS_REPLY_CARD=0` 全关)。 -- **卡片模板 `--card-template`**:模板按应用授权,建议在开发者后台为自己应用注册 AI 卡片模板并填其 ID(env `DWS_CARD_TEMPLATE`);默认公共模板 best-effort。 -- **依赖预检(agent 必做)**:建联前先 `--dry-run` 看输出的 `cli` 字段——`installed:false` 且 `autoInstall:true`(npm 系)告知用户会自动安装;`autoInstall:false`(桌面 App/官方渠道)**先引导用户按 `installHint` 安装**,不要直接起连接。 -- **Codex 渠道**:真实建联优先用 `DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check"`,避免 Codex CLI 在默认临时目录内拒绝执行。需要固定上下文时,把可信目录写进覆盖命令:`DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check -C /path/to/repo"`(路径不要包含空格)。设置 `DWS_AGENT_CMD` 后,DWS 不再自动拼接 `--agent-model/--agent-workdir/会话参数`。 -- **stream-bridge 渠道**(qoder/qoderwork/claudecode/workbuddy/codex/gemini/opencode):Go 原生进程内 Stream 转发器,订阅 `TOPIC_ROBOT`,每条 @机器人消息起一个无头 CLI 实例(如 `claude -p`)→ stdout 回钉钉,可 7×24 无人值守。 -- **官方渠道**(openclaw/hermes):dws 不代建机器人,输出官方 onboarding 指引(连接器自带建号 + AI 卡片回复)。 -- 覆盖项:`DWS_AGENT_CMD`(指定 agent 可执行命令) / `DWS_CONNECT_CMD`(指定外部连接器) / `DWS_CONNECT_NO_INSTALL=1`(关闭缺包自动安装) / `DWS_AGENT_TIMEOUT_MS`。 - ---- - -## 六、版本发布 - -```bash -dws devapp version create --unified-app-id ID --version 1.0.1 --desc "新增机器人能力" --dry-run --format json -dws devapp version list --unified-app-id ID --page 1 --page-size 20 --format json -dws devapp version get --unified-app-id ID --version-id VERSION_ID --format json -dws devapp version check-approval --unified-app-id ID --version-id VERSION_ID --format json -dws devapp version publish --unified-app-id ID --version-id VERSION_ID --confirm-sensitive --dry-run --format json -dws devapp version status --unified-app-id ID --version-id VERSION_ID --format json -``` - -MCP tools: `create_open_dev_app_version` / `list_open_dev_app_versions` / `get_open_dev_app_version_detail` / `publish_open_dev_app_version` / `get_open_dev_app_version_status`。 - -- `check-approval` = `publish_open_dev_app_version` 的 `dryRun=true` 预检模式,不实际发布。 -- `publish` 设 `dryRun=false`;含高敏权限需 `--confirm-sensitive`,灰度选人模式用 `--approver USER_ID`。 -- 流程:`permission add`(requiredApproval 写入版本变更)→ `version create` → `check-approval` → `publish` → `status`。 -- 新应用如果 `version list` 返回空,先执行 `version create`,用返回的 `versionId` 继续 `check-approval/publish`;不要因为列表为空误判无可发布内容。 -- **审批人选择**:`check-approval` 返回候选审批人列表(userId+姓名)→ **展示给用户让其选择(不要自行挑选或默认第一个)** → `publish --approver <选中的userId>` 自动向该审批人发起审批 → `version status` 跟踪。机器人等能力需审批通过、应用发布后才可被搜索/加群/路由消息。 - -发布响应 `result`: - -| result | 含义 | 下一步 | -|--------|------|--------| -| `NOT_REQUIRED` | 不需要审批;预检时表示可直接发布,真实发布时表示已直接发布上线 | 真实发布后回读 `version status/get` 验证 `RELEASE` | -| `APPROVAL_REQUIRED` | 需要审批,通常出现在 `check-approval` 或未指定审批人时 | 查看 `approvalMode/approvalCandidates`,让用户选择审批人后再 `publish --approver` | -| `SUBMITTED` | 已提交审批 | 保存 `processId`,轮询 `version status` | - -审批模式 `approvalMode`:`SELECT_APPROVER` 表示灰度选人模式,需要展示候选审批人并让用户选择;`ENTERPRISE_SELF_BUILT` 表示企业自建审核模式,不传 `--approver`,按企业自建审核流程等待。 - -版本状态字段:`version create/list/get` 返回 `status`,`version status` 返回 `versionStatus`,二者都对齐版本枚举。 - -| status/versionStatus | 含义 | 下一步 | -|----------------------|------|--------| -| `INIT` | 版本已创建或有待发布变更,尚未发布 | 可 `check-approval` / `publish` | -| `AUDIT` | 发布审核中 | 不要重复发布;即使没有返回 `processStatus`,也按审核中处理 | -| `RELEASE` | 已发布生效 | 发布完成,可继续验证权限、机器人、网页应用等能力 | -| `GRAY` | 灰度状态 | 按灰度流程处理;不要当全量已发布 | - -审批流程状态字段:`version status` 的 `processStatus` 只在存在审批流程且后端透出流程态时有值。`versionStatus=AUDIT` 但没有 `processStatus/processInstanceId` 时,不要判失败,仍表示审核中。 - -| processStatus | 含义 | 下一步 | -|---------------|------|--------| -| `UNDER_REVIEW` | 审批中 | 等待审批,必要时把 `processInstanceId` 给用户去钉钉客户端查看 | -| `PASS` | 审批通过 | 继续回读版本,确认是否进入 `RELEASE` | -| `FAIL` | 审批拒绝 | 展示 `processComment`,修改后重新创建/发布版本 | -| `WITHDRAW` / `CANCEL` | 审批撤回或取消 | 回到发布前状态,重新 `check-approval` / `publish` | -| `PUBLISH_FAILED` | 审批后发布失败 | 展示错误信息,重新检查版本状态和后端错误 | - -遇到未列出的状态值时,不要猜测语义;原样展示状态,并回读 `version get/status` 或查开放平台文档/后台详情。 - ---- - -## 七、操作流程 - -### 创建应用全流程 - -```text -create --dry-run → 确认 → create --yes → get 确认 → credentials get → webapp config → permission add → member add -``` - -### 权限管理全流程 - -```text -permission list → permission list --keyword → permission list --scope → permission add --dry-run → 确认 → --yes → permission list 验证 -``` - -### 生命周期 - -```text -停用: get → inactive --dry-run → --yes → get 验证 -启用: active --dry-run → --yes → get 验证 -删除: get 展示 → delete --dry-run → 确认 → --yes → list 验证消失 -``` - ---- - -## 错误处理 - -| 情况 | 处理 | -|------|------| -| `unknown command` | CLI 构建不含 devapp helper | -| `endpoint_not_resolved` | 检查 edition endpoint 注入 | -| 多应用命中 | 展示候选,停止写操作 | -| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | - -## 待实现能力 - -- `dws devapp event list/config` — 事件订阅(待后端发布) diff --git a/skills/mono/references/products/devdoc.md b/skills/mono/references/products/devdoc.md index e4116d9b..b7300418 100644 --- a/skills/mono/references/products/devdoc.md +++ b/skills/mono/references/products/devdoc.md @@ -13,21 +13,45 @@ Example: dws devdoc article search --query "OAuth2 接入" dws devdoc article search --keyword "机器人" --size 10 dws devdoc article search --query "消息卡片" --page 2 --size 5 + dws devdoc article search --query "消息卡片" --cursor "" --size 5 Flags: --query string 搜索关键词 (必填) --keyword string 搜索关键词 (--query 的别名) --page int 分页页码 (从 1 开始,默认 1) + --cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page --size int 分页大小 (默认 10) ``` +### 排查调用错误 +``` +Usage: + dws devdoc error diagnose [flags] +Example: + dws devdoc error diagnose --error-code "40014" --query "获取用户信息 access_token" + dws devdoc error diagnose --request-id "" --query "接口调用失败" + dws devdoc error troubleshoot --error-message "token is illegal" --api "获取用户信息" +Flags: + --query string 原始排查问题 + --error-code string 错误码 + --error-message string 错误描述,会合并进原始问题 + --api string API 名称,会合并进原始问题 + --context string 额外排查上下文 + --request-id string 开放平台 requestId + --page int 分页页码 (从 1 开始,默认 1) + --cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page + --size int 分页大小 (默认 10) +``` + ## 意图判断 用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节: - 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query` +- 用户已经给出错误码、错误描述或 requestId: +- 先走 `devdoc error diagnose`;若后端返回 `PARAM_ERROR - 未找到指定工具` / `unknown tool`,降级 `devdoc article search`,同时把该结果标记为 `needs_gateway_tool_registration` 关键区分: - devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容) -- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览 +- devdoc 只做搜索/诊断,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览 ## 核心工作流 @@ -41,13 +65,38 @@ dws devdoc article search "MCP" --format json # 命中结果多时翻页 dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json +# 推荐动态翻页:优先使用上一页返回的 nextCursor +dws devdoc article search --query "消息卡片" --cursor "" --size 5 --format json + # 查错误码 / 字段含义 dws devdoc article search --query "errcode 40078" --format json + +# 有明确错误码时优先走错误诊断 +dws devdoc error diagnose --error-code "40014" --query "获取用户信息 access_token" --format json + +# 有 requestId 时尝试链路排查;不是所有接口都会返回 requestId +dws devdoc error diagnose --request-id "" --query "接口调用失败" --format json + +# 错误排查结果继续翻页 +dws devdoc error diagnose --error-code "40014" --query "access_token" --cursor "" --format json ``` +## 上线/注入验收 + +```bash +dws devdoc --help +dws devdoc article search --query "OAuth2 接入" --dry-run --format json +dws devdoc error diagnose --error-code "40014" --query "access_token" --dry-run --format json +dws schema devdoc.search_open_platform_docs_rag --format json || true +dws schema devdoc.search_open_error_code_rag --format json || true +``` + +- `--help` / `--dry-run` 只能证明 CLI 命令面和映射存在,不能证明后端工具已注册 +- 真实调用若返回 `PARAM_ERROR - 未找到指定工具`,不是用户参数问题;记录为网关/工具注册待闭环,并降级到 article search 或本地已挂载 Markdown + ## 注意事项 - 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写 -- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词 +- 返回按相关性排序,默认 `--size 10`;响应有 `hasMore=true` 时,用 `nextCursor` 传给 `--cursor` 继续翻页,不要手写猜测下一页 - 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考 - 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档** diff --git a/skills/mono/references/products/simple.md b/skills/mono/references/products/simple.md index cdaa9e27..05849460 100644 --- a/skills/mono/references/products/simple.md +++ b/skills/mono/references/products/simple.md @@ -15,9 +15,27 @@ Example: Flags: --query string 搜索关键词 (必填) --page string 页码 (默认 1) + --cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page --size string 每页数量 (默认 10) ``` +### 排查开放平台调用错误 +``` +Usage: + dws devdoc error diagnose [flags] +Example: + dws devdoc error diagnose --error-code "40014" --query "获取用户信息 access_token" --format json + dws devdoc error diagnose --request-id "" --query "接口调用失败" --format json +Flags: + --query string 原始排查问题 + --error-code string 错误码 + --error-message string 错误描述 + --api string API 名称 + --context string 额外排查上下文 + --request-id string 开放平台 requestId + --cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page +``` + --- ## oa — 审批 @@ -99,6 +117,7 @@ Example: ## 意图判断 - 用户说"开发文档/API 文档/接口文档" → `devdoc article search` +- 用户说"调用报错/错误码/requestId" → `devdoc error diagnose`;若返回 `PARAM_ERROR - 未找到指定工具`,降级 `devdoc article search` 并标记后端工具注册待闭环 - 用户说"审批/请假/报销/出差" → `oa approval` - 用户说"同意审批/批准" → `oa approval approve` - 用户说"拒绝审批/驳回" → `oa approval reject` @@ -110,7 +129,8 @@ Example: | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `devdoc article search` | 文档链接 | 直接展示给用户 | +| `devdoc article search` | 文档链接、nextCursor | 直接展示给用户;hasMore=true 时用 --cursor 翻页 | +| `devdoc error diagnose` | 诊断材料/文档链接、nextCursor | 排查错误码;hasMore=true 时用 --cursor 翻页;不可用时退回文档搜索 | | `oa approval list-forms` | processCode | detail / records 等 | | `oa approval tasks` | taskId, instanceId | approve / reject | | `oa approval list-pending` | instanceId | detail / approve / reject | diff --git a/skills/multi/dingtalk-dev/SKILL.md b/skills/multi/dingtalk-dev/SKILL.md new file mode 100644 index 00000000..1b65b337 --- /dev/null +++ b/skills/multi/dingtalk-dev/SKILL.md @@ -0,0 +1,112 @@ +--- +name: dingtalk-dev +description: 钉钉开放平台企业内部应用管理:应用查询/创建/修改/删除/停启、凭证、权限点、成员、安全配置、网页应用、机器人、版本发布。Use when 用户说 开发者后台应用/开放平台应用/企业内部应用/agentId/clientId/appKey/appSecret/customKey/应用权限/权限点/scopeValue/创建机器人/智能体机器人/机器人配置/机器人回调/IP白名单/登录重定向/端内免登/应用版本/版本发布/发布审核/选审批人/把机器人接到本地调试/connect。 +cli_version: "1.0.37+" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉开放平台应用管理 Skill +## MUST DO + +每次执行 dev 命令前,先查清楚再敲,别凭记忆或猜: + +1. `--help` 看命令树(一个组下有哪些子命令、flag),例 `dws dev app --help` +2. `dws schema dev..` 看某命令参数(类型 / 必填 / 默认),按它构造、别自己拼——flag 名 = schema 参数名,例 `dws schema dev.app.robot.config`(schema 用点号,命令行用空格) +3. 全部命令带 `--format json` +4. 写操作:`--dry-run` 看 `invocation.params` 确认无误,再换 `--yes`(`dev connect` 例外见 [connect.md](references/connect.md)) +5. 写完回读确认(`get` / `robot get` / `version status`) +6. `clientSecret/appSecret` 不写进回答(脱敏) + +## 概念地图 +先建立领域模型,再看命令——所有命令都是对这张图上某个节点的操作,用户的模糊意图先映射到节点再选命令。 + +### 应用是什么 +钉钉开放平台的「企业内部应用」是企业自建的扩展程序。一个应用是一个容器: + +``` +企业内部应用(主键 unifiedAppId) +├── 凭证 appKey/appSecret —— 应用调 OpenAPI 的身份(credentials) +├── 权限 权限点 scopeValue,每个权限点授权一组 OpenAPI(permission) +├── 成员 DEVELOPER 等角色,决定谁能改这个应用(member) +├── 安全配置 IP 白名单 / 登录重定向 / 端内免登 URL(security) +├── 能力扩展 应用对用户「长什么样」,可同时挂多种: +│ ├── 网页应用 钉钉内打开的 H5,配移动端/PC 首页地址(webapp) +│ └── 机器人 群聊/单聊收发消息,走回调 URL 或接本地 agent(robot) +└── 版本 配置改动的生效通道(version) +``` +映射示例:「想做个钉钉里打开的网页」就是 创建应用,再配 webapp,再发版本;「做个答疑机器人」就是 robot submit/result 异步建号(或现有应用 robot config),发版本后本地调试用 dev connect。 + +### ID 体系 +| 标识 | 是什么 | 用在哪 | +|------|--------|--------| +| `unifiedAppId` | 统一应用 ID,全树主键 | 唯一全树定位标识,所有单应用命令都用 `--unified-app-id` | +| `appKey` = `clientId` | 应用身份标识,同一个标识的两个名字,非密钥 | OpenAPI 调用、建联;也可作 `--app-key` 列表过滤(不能定位单应用) | +| `appSecret` = `clientSecret` | 应用密钥,敏感 | 同上,按敏感凭证处理 | +| `agentId` | 应用 ID,仅出现在返回数据里 | 不能用于定位,拿到它先查出 unifiedAppId | +| `robotCode` | 机器人编号 | 加群、机器人发消息、建联 | + +应用定位统一只用 `--unified-app-id`(--app-key/--name 仅作 list 过滤,不能定位单应用)。agentId 只是返回字段,不能用于定位。appKey 与 clientId 是同一标识的两个名字,无需追问区别。 + +### 生效模型 +- 改配置不等于线上生效,需审批的变更(如 `requiredApproval=true` 的权限点)先累积在开发态,必须走版本通道才上线: +``` +配置变更(permission add / robot config / webapp config ...) + → version create → check-approval(预检审批要求+候选审批人) + → publish(需审批时由用户选审批人)→ versionStatus=RELEASE 才生效 +``` +- 机器人等能力需版本发布后才能被搜索、加群、路由消息。 +- 用户问「为什么没生效 / 机器人搜不到 / 权限加了还报错」时,先查 `version status`。 +- 两套状态别混:应用 appStatus(字符串,取值如 normal / published)是应用开关;版本 versionStatus(INIT / AUDIT / RELEASE / GRAY)是变更走到哪了。app list 不回 appStatus(恒 null),看应用状态以 app get 为准。 + +### 边界与角色 +- 本 skill 只管企业内部应用。接口文档用 `dingtalk-devdoc`;钉钉云文档用 `dingtalk-doc`;工作台入口的「应用」用 `workbench app`;群里发消息用的机器人用 `dingtalk-chat`;审批流用 `dingtalk-oa`。 +- 角色:开发者(member DEVELOPER)改配置;管理员管启停;审批人批版本发布。 + +## 核心规则 +1. `应用`、`机器人` 是泛词:用户只说这两个词、无开放平台上下文时,先追问确认是不是开发者后台的企业内部应用,不要猜——很可能是工作台应用或群消息机器人(转出口见上方「边界与角色」)。 +2. 应用名/appKey 命中多条时展示候选、停止写操作,不取第一条。 +3. 权限申请/取消只接受 `scopeValue`,不传 API 名或分组名——权限点才是授权单元,API 名与权限点是多对一。 +4. 主动读取密钥走 `credentials get`(secret 的脱敏要求见 MUST DO);例外:connect 流程内部把 secret 作为参数传给 `dev connect` 是必要用途。 +5. 审批人必须用户拍板,agent 不代选、不默认取第一个。 + +### 通用出参约定(跨所有命令) +- 游标分页(list / permission list / version list / event list / doc search):首次不传 `--cursor`,出参带 `nextCursor`(空=到底)原样回传续翻;`hasMore == nextCursor 非空`。cursor 是上游不透明令牌,不要自己解析或构造,也不要跨命令复用。 +- 批量聚合:`permission remove` 出参是 `{removed, removedScopeValues, rejectedScopeValues, success, message}`,逐条看 `removedScopeValues`/`rejectedScopeValues` 判断每个权限点成败。 +- pretty:`--format pretty` 会在应用/版本状态字段旁附 `*Text` 可读标签(如 `appStatusText`);JSON 格式不附,以原始字段为准。 +- 失败:`ServiceResult.success=false` 原样透传 `errorCode/errorMsg`,不编造解释,解读走下方文档 RAG。 + +## 开放平台文档 RAG / 错误码排查 +- dev 命令执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws dev doc search --keyword "<关键词>" --format json` 做官方文档 RAG。 +- 业务错误(`ServiceResult.success=false`)原样透传 `errorCode/errorMsg`,不要编造解释;需要解读错误含义时走 devdoc RAG。 +- 查询词优先保留原始 API 名、能力名、权限点、完整错误码和 message;首轮形如 `errcode `,无结果再换 `<产品/场景> <错误码>`、`<接口名> 参数`。 +- 本地 CLI 错误(如 `unknown command` / `unknown flag` / 认证 / recovery)仍按 root `dws` / `dws-shared` 的错误处理执行;`devdoc` 用于开放平台业务错误码和接口语义排查。 +- `devdoc` 只查钉钉开放平台开发者文档,不查业务数据;排查结论必须基于命中条目的标题、摘要或链接,不能编造错误原因或不存在的命令。 + +## 典型任务 +端到端任务都是「定位应用,改容器某节点,按审批需要走版本生效,最后回读验证」。完整链路(建网页应用 / 权限到生效 / 建机器人接本地调试 / 排查没生效)见 [recipes.md](references/recipes.md)。 + +## 产品索引 + +按命令组直达(一命令组一文件): + +| 命令组 | 参考文档 | 覆盖命令 | +|--------|---------|---------| +| 应用 | [app.md](references/app.md) | list / get / create / update / delete / disable / enable | +| 凭证 | [credentials.md](references/credentials.md) | credentials get | +| 网页应用 | [webapp.md](references/webapp.md) | webapp get / config | +| 权限 | [permission.md](references/permission.md) | permission list / add / remove | +| 成员 | [member.md](references/member.md) | member list / add / remove | +| 安全配置 | [security.md](references/security.md) | security config | +| 机器人 | [robot.md](references/robot.md) | robot submit / result / get / config / enable / disable | +| 本地建联 | [connect.md](references/connect.md) | dev connect(渠道预检 / agent 模型工作目录 / 会话记忆 / AI 卡片) | +| 版本发布 | [version.md](references/version.md) | version create / list / get / check-approval / publish / status | +| 事件订阅 | [event.md](references/event.md) | event list / subscribe / unsubscribe | + +## Gotchas +- 新应用 `version list` 返回空不等于无可发布内容:先 `version create`,用返回的 `versionId` 继续 check-approval/publish。 +- `robotStatus=UNCONFIGURED` 是「应用还没配过机器人」,走 `robot config` 首次创建,不是 `enable`。 diff --git a/skills/multi/dingtalk-dev/references/app.md b/skills/multi/dingtalk-dev/references/app.md new file mode 100644 index 00000000..e398b5cd --- /dev/null +++ b/skills/multi/dingtalk-dev/references/app.md @@ -0,0 +1,28 @@ +# 应用基础操作 + +> 操作的是「应用」容器本体(见 SKILL.md 概念地图);启停/删除改的是应用 appStatus,不是版本 versionStatus。 + +应用列表查询、详情、创建、修改、生命周期启停和删除。参数查 `dws schema dev.app.`(list / get / create / update / delete / disable / enable)。 + +## 应用定位 + +所有单应用命令统一只用 `--unified-app-id`(全树主键)定位。`--app-key`/`--name` 只在 `dev app list` 里作列表过滤,不能定位单应用。拿到 appKey/agentId 时,先用 `dev app list` 查出 unifiedAppId 再操作。 + +## 应用状态 appStatus + +`app get` 的 `appStatus` 是字符串,取值如 `normal`、`published`。`app list` 不回这个字段(恒 `null`),看应用状态以 `app get` 为准。应用状态 `appStatus` 和版本状态 `versionStatus` 是两套,别混。遇到没见过的 `appStatus` 值原样展示。 + +`app create`、`app update` 不返回状态字段;版本状态由 `version create` 返回的 `status`(值如 INIT)表达,见 version.md。 + +## 要点 + +- `get` 主要用于定位核验;若返回里带 `appSecret`,脱敏处理,不复制到回答;主动读凭证走 `credentials get`。 +- `disable/enable` 成功返回 `{disabled:true}` / `{enabled:true}` + `message`,不回 `appStatus`;以这个布尔判操作成败。要确认最终生效态再 `get` 看 `appStatus` 字符串值。 +- `delete` 前必须展示应用摘要;删除是异步,成功后延迟从列表消失。 + +## 错误处理 + +| 情况 | 处理 | +|------|------| +| 多应用命中 | 展示候选,停止写操作 | +| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | diff --git a/skills/multi/dingtalk-dev/references/connect.md b/skills/multi/dingtalk-dev/references/connect.md new file mode 100644 index 00000000..14272c90 --- /dev/null +++ b/skills/multi/dingtalk-dev/references/connect.md @@ -0,0 +1,79 @@ +# 本地建联(把机器人接到本地 agent) + +> `dws dev connect` 是 dev 顶层命令,把一个现成机器人接到当前渠道的本地 agent CLI 做调试/值守——只建联、不建号。缺机器人先走 robot.md 的 `robot submit`/`result` 建号拿 clientId/clientSecret。 + +## 起连接 + +```bash +# 用现成机器人凭证起 Stream,接到当前渠道的本地 agent(前台运行,Ctrl-C 退出) +dws dev connect --channel auto --robot-client-id --robot-client-secret + +# 用统一应用 ID,复用 credentials get 自动取凭证 +dws dev connect --unified-app-id --channel qoderwork + +# 预览建联方案不实际起连接 +dws dev connect --robot-client-id --robot-client-secret --dry-run --format json +``` + +正式 connect 是前台长驻进程:在对话里跑必须后台运行并告诉用户如何停止,或引导用户自己开终端跑。 + +| flag | 说明 | +|------|------| +| `--channel` | `auto`(默认,运行时信号自动识别) / openclaw / qoder / qoderwork / hermes / workbuddy / claudecode / codebuddy / codex / gemini / opencode | +| `--robot-client-id` / `--robot-client-secret` | 现成机器人凭证(clientId=AppKey, clientSecret=AppSecret)。命名带 `robot-` 前缀以避开全局 OAuth `--client-id` flag | +| `--unified-app-id` | 统一应用 ID,内部复用 `credentials get` 自动取凭证,替代手填 robot-client-id/secret。注意 clientSecret 仅建号时返回一次、未必可取,取不到时回退手填 | +| `--agent-memory` | 按会话续聊(默认开):同一群/单聊共享 agent 会话,追问保留上下文。仅 claudecode/codebuddy/workbuddy(CLI 有 `--session-id`/`--resume`);qoder 系/codex/gemini/opencode 无寻址会话,自动保持无状态。`--agent-memory=false` 关闭 | +| `--agent-model` | 覆盖本地 agent 模型(如 claudecode 默认锁 haiku 求快,可改 `claude-sonnet-4-6` 换聪明)。env: `DWS_AGENT_MODEL` | +| `--agent-workdir` | agent 运行目录:放知识文件(如 CLAUDE.md)可给机器人企业上下文。默认空白临时目录(冷启动快 ~4s vs 大目录 ~29s,慢了会错过钉钉响应窗口)。env: `DWS_AGENT_WORKDIR` | +| `--reply-card` | 富回复(默认开):🤔Thinking/🥳Done 表态永远生效;**卡片需配 `--card-template` 才启用**(同 hermes:没配模板=纯文字回复),失败自动回退文字;env `DWS_REPLY_CARD=0` 全关 | +| `--card-template` | AI 卡片模板 ID。**模板按应用授权**:去开发者后台→你的应用→AI 卡片设置注册/获取模板 ID,可去掉公共模板的第三方角标;默认用公共模板 best-effort。env `DWS_CARD_TEMPLATE` | +| `--allowed-groups` | 群白名单 openConversationId(逗号分隔),配置后只有名单内的群能触发机器人。env `DWS_ALLOWED_GROUPS` | +| `--allowed-users` | 用户白名单 staffId(逗号分隔),配置后只有名单内的用户能触发。env `DWS_ALLOWED_USERS` | +| `--knowledge-dir` | 答疑知识目录(.md/.txt):每条消息本地检索 top-k 片段拼进 prompt,agent 仍在空目录跑、不拖慢回复。env `DWS_KNOWLEDGE_DIR` | +| `--user-rate-limit` | 单用户每分钟消息上限(防刷,每条消息都是一次 LLM 调用),0 关闭,默认 20。env `DWS_USER_RATE_LIMIT` | + +## 建联前的依赖预检(agent 必做) + +渠道背后是本地 agent CLI,用户可能没装。先 `--dry-run` 看出参 `cli` 字段再决定下一步: + +```bash +dws dev connect --channel --robot-client-id x --robot-client-secret y --dry-run --format json +# 输出里的 cli 字段: +# "cli": {"required":"Claude Code","installed":false,"autoInstall":true,"installHint":"npm i -g @anthropic-ai/claude-code"} +``` + +| cli 状态 | agent 应该做什么 | +|----------|----------------| +| `installed: true` | 直接建联 | +| `installed: false, autoInstall: true` | 告知用户缺哪个 CLI,说明启动建联时会自动 `npm` 安装(或先手动执行 installHint 里的命令再连);`DWS_CONNECT_NO_INSTALL=1` 可禁自动安装 | +| `installed: false, autoInstall: false` | **不要直接起连接**——桌面 App 渠道(qoder/qoderwork/workbuddy)需要用户先安装对应 App(installHint 是下载地址),装好后 CLI 随 App 自带;openclaw/hermes 引导用户走官方 onboarding | + +dry-run 出参的完整建联预检结构(channel/detectedBy/credentialSource/agent/cli/connect)见 SKILL.md「通用出参约定」。 + +## Codex 渠道注意 + +```bash +# Codex 默认空白工作目录会触发 "Not inside a trusted directory and --skip-git-repo-check was not specified" +CODEX_BIN="$(command -v codex || echo /Applications/Codex.app/Contents/Resources/codex)" +DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check" \ + dws dev connect --unified-app-id --channel codex --format json +``` + +- `--channel codex` 真实建联时优先用上面的 `DWS_AGENT_CMD` 形式,避免 Codex CLI 在默认临时目录内因非可信 Git 目录拒绝执行。 +- 给 Codex 固定知识/项目上下文:`DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check -C /path/to/repo"`(路径不要含空格)。 +- 设置 `DWS_AGENT_CMD` 后 DWS 不再自动拼接 `--agent-model`/`--agent-workdir`/会话参数,需要的要一起写进 `DWS_AGENT_CMD`。 + +## 机制与环境覆盖 + +- **stream-bridge 渠道**:Go 原生进程内 Stream 转发器,订阅 `TOPIC_ROBOT`,每条 @机器人消息起一个无头 CLI 实例 → stdout 回钉钉,可 7×24 无人值守。 +- **会话记忆**:首条消息 `--session-id ` 建会话,后续 `--resume ` 续聊;会话状态在内存里,连接器重启后从新会话开始;某会话坏了会自愈(下条消息换新会话)。 +- **官方渠道**(openclaw/hermes):dws 不代建机器人,输出官方 onboarding 指引。 +- 环境覆盖:`DWS_AGENT_CMD`(整条命令覆盖,覆盖时不再注入模型/会话参数) / `DWS_AGENT_MODEL` / `DWS_AGENT_WORKDIR` / `DWS_CONNECT_CMD` / `DWS_CONNECT_NO_INSTALL=1` / `DWS_AGENT_TIMEOUT_MS`。 + +## 错误处理 + +| 情况 | 处理 | +|------|------| +| 缺凭证 | 先用 `robot submit`/`robot result` 建号拿 clientId/clientSecret(见 robot.md) | +| Codex agent 返回 `Not inside a trusted directory and --skip-git-repo-check was not specified` | 用 `DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check"` 重启 `connect --channel codex` | +| 桌面 App 渠道 `installed:false, autoInstall:false` | 引导用户先装对应 App(installHint 是下载地址),不要直接起连接 | diff --git a/skills/multi/dingtalk-dev/references/credentials.md b/skills/multi/dingtalk-dev/references/credentials.md new file mode 100644 index 00000000..e88ae727 --- /dev/null +++ b/skills/multi/dingtalk-dev/references/credentials.md @@ -0,0 +1,12 @@ +# 应用凭证读取 + +> 凭证=应用调 OpenAPI 的身份(appKey=clientId / appSecret=clientSecret);见 SKILL.md 概念地图。 + +`dws dev app credentials get --unified-app-id ` 读取应用凭证。参数查 `dws schema dev.app.credentials.get`。 + +返回字段:`clientId`/`appKey`(同值)、`clientSecret`/`appSecret`(同值)、`currentSecretStatus`、`hasPendingExpireTask`、`unifiedAppId` 等。 + +规则: +- 该命令只需 `--unified-app-id`。 +- 返回里 `clientSecret/appSecret` 是明文密钥,按敏感凭证处理,不写进回答文本。 +- 不能用 `dev app get` 代替;`dev app get` 也会带密钥,同样只用于内部判断并脱敏,不向用户展开。 diff --git a/skills/multi/dingtalk-dev/references/event.md b/skills/multi/dingtalk-dev/references/event.md new file mode 100644 index 00000000..9c37eecc --- /dev/null +++ b/skills/multi/dingtalk-dev/references/event.md @@ -0,0 +1,30 @@ +# 事件订阅 + +> 把应用关心的事件推到回调地址;见 SKILL.md 概念地图。 + +`dws dev app event list/subscribe/unsubscribe`,按 `--unified-app-id` 定位,订阅/退订用 `--event-codes`(逗号分隔,一次多个)。参数查 `dws schema dev.app.event.`。 + +规则: +- 写操作先 `--dry-run` 预览,确认后 `--yes`。 +- 一次可订阅多个事件码,共用同一回调。 +- 事件码取值以开放平台文档为准;不确定走 `dws dev doc search`。 +- 退订前先 `event list` 确认当前订阅,避免退不存在的。 +- `event list` 支持 `--keyword/--cursor/--page-size`;`--keyword` 按事件码或事件名称搜索;返回 `events/hasMore/nextCursor/pageSize`;翻页继续传 `nextCursor`。 +- 返回看 `events[].subscribed` 和 `pushType=STREAM`(事件走 Stream 长连推送;connect 与事件订阅的关系见下方「Stream 长连」)。 +- `subscribe/unsubscribe` 的 `--event-codes` 必填,返回 `success/operation/unifiedAppId/eventCodes/needsPublish/versionRequiredAction`;失败时补 `errorCode/errorMsg/reason/retryable/action`。 +- 如果服务端返回 `errorCode=STREAM_NOT_CONNECTED`、`reason=STREAM_NOT_CONNECTED`、`retryable=false`、`action=run connect`,先执行 `dev connect` 建联,再重试订阅。 + +## Stream 长连:connect 与事件订阅的关系 + +钉钉一个应用就一条 Stream 长连(WebSocket),上面同时承载机器人消息、事件、卡片回调等多种 topic。connect 和 event 在这条长连上分工不同: + +- `dev connect`:用应用凭证把这条长连建起来并保活,但只注册了机器人消息处理(收 @机器人 → 转发本地 agent),**不消费事件**。 +- `event subscribe/unsubscribe`:只是**配置**操作(配应用订阅哪些事件码),自己不建长连、不收事件;服务端要求应用的 Stream 长连已在线,否则返回 `STREAM_NOT_CONNECTED`。 +- 两者唯一关联:先 `dev connect` 把长连建在线,`event subscribe` 才能成功——connect 负责「让长连在线」,subscribe 负责「配置订阅」。 +- 当前限制:connect 的长连不处理事件,dws 也没有「收/消费事件」的运行时命令,event 只到「配置订阅」为止。订阅成功后真正的事件推送 dws 暂不消费——要消费得用注册了事件 handler 的 SDK 自接(事件结构走 `dev doc search` 查)。 + +## Stream 长连怎么建的 + +`dev connect` 内部用 dingtalk-stream-sdk-go,凭 `clientId/clientSecret` 建一条 WebSocket 长连并保活——底层网关握手、ticket、加解密都由 SDK 封装,agent 跑 `dev connect` 即可,不用碰这些。 + +dws 当前只消费机器人消息、不消费事件。要自己写事件消费程序补这个 gap 时,SDK 用法以官方文档为准(走 `dev doc search`,或看 github.com/open-dingtalk/dingtalk-stream-sdk-go)——注意事件用 `RegisterAllEventHandler`、connect 用的机器人是 `RegisterChatBotCallbackRouter`,两套别混;版本/接口会变,不在这里固化。 diff --git a/skills/multi/dingtalk-dev/references/member.md b/skills/multi/dingtalk-dev/references/member.md new file mode 100644 index 00000000..87a29fd8 --- /dev/null +++ b/skills/multi/dingtalk-dev/references/member.md @@ -0,0 +1,5 @@ +# 成员管理 + +> 成员=谁能改这个应用(DEVELOPER 等角色);见 SKILL.md 概念地图。 + +`dws dev app member list/add/remove` 管理应用成员。参数查 `dws schema dev.app.member.`(add/remove 需 `--user-ids` 列表 + `--member-type`,如 DEVELOPER;remove 也必须传 memberType,因为同一用户可能有多个成员身份)。 diff --git a/skills/multi/dingtalk-dev/references/permission.md b/skills/multi/dingtalk-dev/references/permission.md new file mode 100644 index 00000000..1cc4a845 --- /dev/null +++ b/skills/multi/dingtalk-dev/references/permission.md @@ -0,0 +1,42 @@ +# 权限管理 + +> 权限点 scopeValue 是授权单元,一个权限点授权一组 OpenAPI;requiredApproval=true 的变更走版本通道生效(见 SKILL.md 生效模型)。 + +查询、申请、取消开放平台应用的 APP 应用权限和 SNS 个人权限。参数查 `dws schema dev.app.permission.`。 + +## 权限列表 + +`--scope-value` 传入即进单权限详情模式;`--scope-type` 取 `APP`/`SNS`,留空返回两者;一个应用可能 150+ 权限点,游标分页续翻、`--page-size` 不超过 50。 + +`--auth-status` 是查询过滤条件: + +| authStatus | 含义 | +|------------|------| +| `ALL` | 不按授权状态过滤 | +| `AUTHED` | 只看已授权/已开通 | +| `UNAUTHED` | 只看未授权/未开通 | + +单个权限项的状态看这几个字段: + +- `authed`(布尔):是否已授权/已开通。true=已开通,不要重复申请。 +- `allowedActions`(数组):本权限点当前允许的动作,如 `["view","detail","apply"]`。含 `apply` 才能申请,含 `remove` 才能取消。 +- `authedStatusDesc`(中文文案):状态的中文说明,如"已开通"/"未开通",直接展示给用户。 +- `apiStatus`:权限点本身的开放状态,如 `FULLY_OPEN`。 +- `requiredApproval`(布尔):申请是否需审批。true 的变更走版本通道,审批在版本发布时处理。 +- `displayMessage`(中文文案):服务端给的提示语,能否申请的原因看它。 + +list 默认同时返回 APP 和 SNS 权限;列表模式和 `--scope-value` 详情模式,权限项里的 API 信息字段都叫 `apiPreview`。`permission search` 是 `list` 的别名。 + +scopeValue 选择顺序: +1. 用户给了 `scopeValue`,精确匹配 +2. 给了 API 名,用 `keyword` 搜,匹配 `apiPreview.name` +3. 给了权限名,匹配 `scopeName/scopeDesc` +4. 多个候选,展示列表让用户选,不自动取第一条 + +## 申请权限 + +`--scope-values` 传 `scopeValue`,多个逗号分隔,必须来自 `permission list` 返回。已开通跳过、不可编辑拒绝。`requiredApproval=true` 允许申请——写入版本变更,审批在版本发布时处理。不在此处选审批人。 + +## 取消权限 + +`--scope-values` 多个逗号分隔。返回:`removed`(布尔,整体成败)、`removedScopeValues`(成功取消的)、`rejectedScopeValues`(被拒的)、`message`。逐条看 `removedScopeValues`/`rejectedScopeValues` 判断每个权限点的结果。 diff --git a/skills/multi/dingtalk-dev/references/recipes.md b/skills/multi/dingtalk-dev/references/recipes.md new file mode 100644 index 00000000..6d8d18dc --- /dev/null +++ b/skills/multi/dingtalk-dev/references/recipes.md @@ -0,0 +1,29 @@ +# 端到端链路(recipes) + +dev 的端到端任务都是「定位应用,改容器某节点,按审批需要走版本生效,最后回读验证」。每步先 `--dry-run` 确认再 `--yes`,参数查 `dws schema`,细节进对应 reference。 + +## 建一个钉钉里打开的网页应用 + +1. `dev app create --name <名>` 建应用,拿 unifiedAppId +2. `dev app webapp config` 配移动端/PC 首页(见 webapp.md) +3. `dev app version create` 建版本 +4. `dev app version check-approval` 预检是否需审批 +5. `dev app version publish` 发布(需审批时让用户选审批人) +6. 回读 `dev app version status` 到 `RELEASE` 才算生效 + +## 权限从申请到生效 + +1. `dev app permission list` 选 `scopeValue`(选择顺序见 permission.md) +2. `dev app permission add --scope-values <值>` 申请 +3. 若是 `requiredApproval` 的权限,走版本:`version create`,再 `check-approval`,再 `publish --approver-user-id <用户选的>`,最后 `version status` +4. 免审权限直接开通,不必发版本 + +## 做一个答疑机器人并接到本地调试 + +1. 新建:`dev app robot submit` 提交异步建号任务,再 `dev app robot result` 轮询,`SUCCESS` 拿 clientId/clientSecret(见 robot.md);或在现有应用上 `dev app robot config` 配置 +2. 机器人能力需发版本后才能被搜索/加群/路由消息 +3. 本地调试/值守:`dev connect` 把机器人接到本地 agent(见 connect.md);注意订阅事件前要先建联长连(见 event.md) + +## 查「为什么没生效 / 机器人搜不到 / 权限加了还报错」 + +先 `dev app version status`——改配置不等于生效,未发到 `RELEASE` 就不生效。 diff --git a/skills/multi/dingtalk-dev/references/robot.md b/skills/multi/dingtalk-dev/references/robot.md new file mode 100644 index 00000000..399c9fd9 --- /dev/null +++ b/skills/multi/dingtalk-dev/references/robot.md @@ -0,0 +1,49 @@ +# 机器人能力 + +> 机器人是应用的能力扩展之一;建号/配置在此,接到本地 agent 调试用 `dws dev connect`(见 connect.md)。 + +为开放平台企业内部应用创建和配置机器人。参数查 `dws schema dev.app.robot.`。分两类场景: + +1. 新建智能体机器人:异步创建一个新的 Agent 应用 + 承载机器人(`submit` / `result`)。 +2. 现有应用配置机器人:在已存在的应用上配置/启用/停用机器人(`get` / `config`(upsert) / `enable` / `disable`),用 `--unified-app-id` 定位。 + +> `corpId` / `userId` 由系统上下文自动注入,CLI 不传。所有写操作先 `--dry-run`,确认后再 `--yes`。 + +## 一、新建智能体机器人(异步建号) + +`submit` 提交任务拿 `taskId`,`result --task-id ` 轮询。`submit` 返回 `taskId/status/expiresInSeconds/intervalSeconds/retryCount/bindsUnifiedApp`,提交成功通常是 `WAITING`,且 `bindsUnifiedApp=false` 表示异步建号任务不挂到现有应用。失败重试:把上次 `taskId` 通过 `--task-id` 传回 `submit`,避免重复创建。只有 `result` 返回 `SUCCESS` 才能用返回的 `agentId/robotCode/clientId/clientSecret`。 + +异步任务状态: + +| status | 含义 | 下一步 | +|--------|------|--------| +| `WAITING` | 创建中 | 按 `intervalSeconds` 轮询 `robot result` | +| `SUCCESS` | 创建完成 | 保存 `robotCode/clientId/clientSecret`,凭据按敏感处理 | +| `APPROVAL_REQUIRED` | 创建编排需审批 | 不要重复建号;按返回信息或后台审批后再继续 | +| `FAIL` | 创建失败 | 读 `errorCode/errorMsg/failReason`;可带原 `taskId` 重新 `submit` | +| `EXPIRED` | `taskId` 不存在或过期 | 重新 `submit` | + +## 二、现有应用的机器人配置 + +`robot get` 返回机器人基础信息、回调、模式、状态、技能列表;应用尚未配置机器人时返回空态 `robotStatus=UNCONFIGURED`,不是业务错误。 + +状态判断: +- `robotStatus=UNCONFIGURED`:应用未配置机器人,走 `robot config`。 +- `robotStatus=OFFLINE`:配置存在但停用/下线,可走 `robot enable`。 +- `robotStatus=ONLINE`:配置已启用;`robotCode` 可用于加群、机器人身份发消息或后续建联。 +- `mode` 是字符串枚举:`HTTPS` / `STREAM` / `AISKILL`。 +- `robot get` 正常返回是平铺字段(`configured`/`mode`/`robotStatus`/`robotCode`/`name`/`brief`/`desc`),没有 `success` 字段;拿到这组字段就是配置已落库,不是异步等待态。 +- ONLINE 只代表能力已开启。要让机器人自动处理消息,还需配 `--outgoing-url`/`--event-callback-url`,或用 `dev connect` 接本地 Agent(见 connect.md)。 + +`config` 是 upsert:建或改都用它,不存在则建、存在则改,至少给一个配置字段。国际化字段(`--i18n-name` 等)传 JSON,如 `'{"en_US":"Bot"}'`。`enable` 是纯启用:只开启能力,不带配置字段(只传 `--unified-app-id`)。`config/enable/disable` 成功统一返回 `success/operation/unifiedAppId/robotCode/robotStatus/configured`;回读 `robot get` 看到 `robotStatus=ONLINE` 就别再误判"待生效"。 + +## 错误处理 + +| 情况 | 处理 | +|------|------| +| `robotStatus=UNCONFIGURED` | 应用未配置机器人,先用 `robot config` 创建 | +| 应用名重复 | `app-name` 企业内需唯一,换名 | +| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | +| 创建任务 `EXPIRED` | 任务过期,重新 `submit`(可带原 taskId) | + +> 把机器人接到本地 agent 调试/值守见 [connect.md](connect.md)。 diff --git a/skills/multi/dingtalk-dev/references/security.md b/skills/multi/dingtalk-dev/references/security.md new file mode 100644 index 00000000..a376a933 --- /dev/null +++ b/skills/multi/dingtalk-dev/references/security.md @@ -0,0 +1,7 @@ +# 安全配置 + +> 安全配置=应用的 IP 白名单 / 登录重定向 / 端内免登 URL;见 SKILL.md 概念地图。 + +`dws dev app security config` 配 IP 白名单(`--ip-whitelist`)、登录重定向(`--redirect-urls`)、端内免登(`--sso-urls`)。参数查 `dws schema dev.app.security.config`,至少给一个配置字段。 + +覆盖语义:未提供的字段不动;显式提供的列表是整组覆盖(传入即全量替换该项,不是追加)——要保留旧值就把旧值一起带上。 diff --git a/skills/multi/dingtalk-dev/references/version.md b/skills/multi/dingtalk-dev/references/version.md new file mode 100644 index 00000000..af1c23b2 --- /dev/null +++ b/skills/multi/dingtalk-dev/references/version.md @@ -0,0 +1,77 @@ +# 版本发布 + +> 版本是配置变更生效的唯一通道——改配置不等于上线,发布到 RELEASE 才生效(见 SKILL.md 生效模型)。 + +管理应用版本:基于当前配置建版本、查列表/详情、预检审批、发布、查状态。参数查 `dws schema dev.app.version.`。版本用 `--unified-app-id` 定位,单个版本再加 `--version-id`;`corpId`/`userId` 系统注入,CLI 不传。 + +## 典型流程 + +```text +permission add(requiredApproval=true 写入版本变更) + → version create 创建版本 + → version check-approval 预检是否需审批 / 审批人 + → version publish 发布(含高敏权限需 --confirmed-sensitive) + → version status 轮询发布/审批状态 +``` + +新应用如果 `version list` 返回空,先 `version create`,用返回的 `versionId` 继续 check-approval/publish;不要因列表空误判无可发布内容。 + +## 创建版本 + +默认不要传 `--version`,不传时服务端基于最新已发布版本自动递增。只有用户明确要指定时才填 `--version`,且必须大于最新 `RELEASE` 版本,否则服务端返回 `62018`(版本号需高于上个版本号)。 + +创建成功后,后续 `get`/`check-approval`/`publish` 必须用 `create` 返回的 `versionId`;不要通过 `list` 猜最新版本。如果创建没返回 `versionId`,停止并报错。 + +## check-approval 与 publish + +`check-approval` 只查审批要求和候选审批人,不发布。`publish` 是真实发布;含高敏权限要加 `--confirmed-sensitive`,灰度选人模式用 `--approver-user-id` 指定审批人。 + +区分两个"预检":`--dry-run` 是 CLI 层的预览不调上游;`check-approval` 是服务端查审批要求不发布。发布前建议先 `check-approval`。 + +发布/预检不返回动作枚举 `result`,看结构化字段判断下一步: + +| 字段 | 含义 | 下一步 | +|------|------|--------| +| `requiresApproval=false` + `publishable=true` | 不需审批,`check-approval` 通过 | 可以执行 `version publish` | +| `requiresApproval=true` + `approvalMode=SELECT_APPROVER` | 需从候选人里选审批人 | 展示 `approvalCandidates`,让用户选后再 `publish --approver-user-id` | +| `requiresApproval=true` + `approvalMode=ENTERPRISE_SELF_BUILT` | 企业自建审核 | 不传 `--approver-user-id`,直接 `publish` 提交审批 | +| `published=true` | 本次 `publish` 已直接发布 | 回读 `version status/get` 验证 `versionStatus=RELEASE` | +| `approvalSubmitted=true` | 本次 `publish` 已提交审批 | 保存 `processId`,轮询 `version status` | + +审批模式 `approvalMode`: + +| approvalMode | 含义 | 下一步 | +|--------------|------|--------| +| `SELECT_APPROVER` | 灰度选人,需在候选审批人里选一个 | 展示候选,不自动取第一个 | +| `ENTERPRISE_SELF_BUILT` | 企业自建审核 | 不传 `--approver-user-id`,按企业自建流程等待 | + +## 版本状态 + +`version create/list/get/status` 统一返回 `versionStatus`: + +| versionStatus | 含义 | 下一步 | +|---------------|------|--------| +| `INIT` | 已创建或有待发布变更,未发布 | 可 `check-approval`/`publish` | +| `AUDIT` | 发布审核中 | 不要重复发布;即使没返回 `processStatus` 也按审核中处理 | +| `RELEASE` | 已发布生效 | 完成,可验证权限/机器人/网页等能力 | +| `GRAY` | 灰度 | 按灰度流程,不要当全量已发布 | + +`version status` 的 `processStatus` 只在存在审批流程且后端透出时有值。`versionStatus=AUDIT` 但没 `processStatus/processInstanceId` 时不要判失败,仍是审核中。 + +| processStatus | 含义 | 下一步 | +|---------------|------|--------| +| `UNDER_REVIEW` | 审批中 | 等待,必要时把 `processInstanceId` 给用户去钉钉客户端看 | +| `PASS` | 审批通过 | 继续回读,确认是否进 `RELEASE` | +| `FAIL` | 审批拒绝 | 展示 `processComment`,改后重新建/发版本 | +| `WITHDRAW` / `CANCEL` | 撤回或取消 | 回到发布前,重新 `check-approval`/`publish` | +| `PUBLISH_FAILED` | 审批后发布失败 | 展示错误,重查版本状态和后端错误 | + +遇到未列出的状态值,不要猜语义;原样展示,回读 `version get/status` 或查文档/后台。 + +## 错误处理 + +| 情况 | 处理 | +|------|------| +| `check-approval` 提示需审批 | 按返回选审批人,再 `publish --approver-user-id` | +| 发布报高敏权限未确认 | 加 `--confirmed-sensitive` 重新发布 | +| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | diff --git a/skills/multi/dingtalk-dev/references/webapp.md b/skills/multi/dingtalk-dev/references/webapp.md new file mode 100644 index 00000000..147aad80 --- /dev/null +++ b/skills/multi/dingtalk-dev/references/webapp.md @@ -0,0 +1,8 @@ +# 网页应用配置 + +> 网页应用=应用的能力扩展之一,钉钉内打开的 H5;见 SKILL.md 概念地图。 + +`dws dev app webapp get` 查配置,`webapp config` 配移动端/PC 首页和管理后台地址。参数查 `dws schema dev.app.webapp.`,config 至少给一个配置字段。 + +- 未配置网页应用前,`get` 返回空对象 `{}`。拿到 `{}` 就是还没配过,走 `webapp config` 首次配置。 +- `h5PageType` 未显式传入时不要假设固定默认值;配置后以 `webapp get` 回读为准。 diff --git a/skills/multi/dingtalk-devapp/SKILL.md b/skills/multi/dingtalk-devapp/SKILL.md deleted file mode 100644 index b3fc6945..00000000 --- a/skills/multi/dingtalk-devapp/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: dingtalk-devapp -description: 钉钉开放平台应用管理。Use when 用户说 开发者后台应用/开放平台应用/企业内部应用/查应用/创建应用/修改应用/删除应用/停用应用/启用应用/应用成员/安全配置/IP白名单/登录重定向/端内免登/agentId/clientId/appKey/appSecret/应用权限/权限点/创建机器人/智能体机器人/机器人配置/机器人回调/应用版本/版本发布/发布审核/选审批人。Distinct from dingtalk-devdoc(开放平台文档搜索) and dingtalk-doc(钉钉云文档)。命令前缀:dws devapp(兼容别名 dws app)。 -cli_version: ">=1.0.15" -metadata: - category: product - stability: experimental - requires: - bins: - - dws ---- - -# 钉钉开放平台应用管理 Skill - -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。接口、命名与跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 - -> **PREREQUISITE:** Read the root `dws` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. - - - -> `dws devapp ...` 是内置 helper 命令,不依赖 MCP 服务发现。执行前用 `dws devapp --help` 验证可用。 - -## 意图消歧 - -`应用` 是泛词。只有出现以下信号才路由到 devapp: - -- `开放平台应用/开发者后台应用/企业内部应用/内部应用` -- `agentId/clientId/appKey/appSecret/customKey` -- `应用权限/权限点/scopeValue/应用成员/安全配置/IP 白名单` -- `创建机器人/智能体机器人/机器人配置/机器人回调地址`(→ `robot`) -- `应用版本/版本发布/发布审核/选审批人`(→ `version`) - -不要路由到 devapp 的:接口文档→`dingtalk-devdoc`、钉钉文档→`dingtalk-doc`、工作台应用→`workbench app`、审批→`dingtalk-oa`。只说 `应用` 无上下文时先追问。 - -## 核心规则 - -1. 所有命令加 `--format json`。 -2. 写操作先 `--dry-run`,确认后才加 `--yes`。 -3. 应用名/appKey/customKey 命中多条时展示候选,不取第一条。 -4. 权限申请/取消只接受 `scopeValue`,不传 API 名或分组名。 -5. 主动读取密钥走 `credentials get`;任何 `devapp get`/详情返回里的 `clientSecret/appSecret` 都按敏感凭证脱敏,不向用户展开。 - -## 开放平台文档 RAG / 错误码排查 - -- 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。 -- 查询词优先保留原始 API 名、能力名、权限点、完整错误码和 message;首轮形如 `errcode `,无结果再换 `<产品/场景> <错误码>`、`<接口名> 参数`。 -- 本地 CLI 错误(如 `unknown command` / `unknown flag` / 认证 / recovery)仍按 root `dws` / `dws-shared` 的错误处理执行;`devdoc` 用于开放平台业务错误码和接口语义排查。 -- `devdoc` 只查钉钉开放平台开发者文档,不查业务数据;排查结论必须基于命中条目的标题、摘要或链接,不能编造错误原因或不存在的命令。 - -## 渐进式参考 - -按使用频率和复杂度分层,按需加载: - -| 层 | 参考文档 | 覆盖命令 | -|----|---------|---------| -| 基础 | [app-crud.md](references/app-crud.md) | list / get / create / update / delete / inactive / active | -| 凭证与网页应用 | [credentials-webapp.md](references/credentials-webapp.md) | credentials get / webapp get / webapp config | -| 权限管理 | [permissions.md](references/permissions.md) | permission list / permission add / permission remove | -| 成员与安全 | [member-security.md](references/member-security.md) | member list / add / remove / security config | -| 机器人 | [robot.md](references/robot.md) | robot create / submit / result / get / config / update / enable / offline | -| 版本发布 | [version.md](references/version.md) | version create / list / get / check-approval / publish / status | -| 操作流程 | [workflows.md](references/workflows.md) | 创建应用全流程 / 权限全流程 / 生命周期管理 | diff --git a/skills/multi/dingtalk-devapp/references/app-crud.md b/skills/multi/dingtalk-devapp/references/app-crud.md deleted file mode 100644 index 7898e729..00000000 --- a/skills/multi/dingtalk-devapp/references/app-crud.md +++ /dev/null @@ -1,124 +0,0 @@ -# 应用基础操作 - -应用列表查询、详情、创建、修改、生命周期启停和删除。 - -## 应用定位 - -写操作前必须定位到唯一应用: - -| 优先级 | 标识 | 处理 | -|--------|------|------| -| 1 | `--unified-app-id` | 直接使用 | -| 2 | `--agent-id` / `--app-id` | 直接使用 | -| 3 | `--app-key` / `--custom-key` | 先查询,唯一命中才继续 | -| 4 | `--name` | 模糊搜索,写操作必须唯一命中 | - -## 应用列表 - -```bash -dws devapp list --format json -dws devapp list --name DemoApp --format json -dws devapp list --agent-id 123456 --format json -dws devapp list --creator 张三 --sort gmt_modified --order desc --format json -``` - -MCP tool: `list_open_dev_apps_by_condition` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--page` | `currentPage` | 1-based,默认 1 | -| `--page-size` | `pageSize` | 默认 20 | -| `--name` / `--keyword` | `appName` | 应用名搜索 | -| `--agent-id` | `agentId` | 精确定位 | -| `--app-key` | `appKey` | appKey/clientId | -| `--creator` | `creator` | 创建人关键词 | -| `--sort` | `sortType` | 如 `gmt_modified` | -| `--order` | `sortOrder` | `asc` / `desc` | - -## 应用状态字段 - -列表/详情原始字段里如果出现 `status` / `appStatus`,按应用生命周期枚举判断;不要和版本 `versionStatus` 混用。 - -| status | 枚举 | 含义 | 下一步 | -|--------|------|------|--------| -| `0` | `IN_ACTIVE` | 已停用,应用不可用 | 需要恢复时走 `active --dry-run` → 确认 → `--yes` | -| `1` | `ACTIVE` | 已激活,应用可用 | 可继续配置权限、网页应用、机器人或版本 | -| `2` | `WAIT_ACTIVE` | 待激活 | 先回读 `get/list` 确认状态;不要直接按已生效处理 | -| `3` | `EXPIRED` | 已过期 | 停止写操作,提示用户到开发者后台或管理员侧处理 | - -`create/update` 返回的 `versionStatus` 是版本状态,语义见 `version.md`;它不等同于应用启停状态。 - -## 应用详情 - -```bash -dws devapp get --unified-app-id UNIFIED_APP_ID --format json -dws devapp get --agent-id 123456 --format json -``` - -MCP tool: `get_open_dev_app_detail` - -详情主要用于定位和核验应用。若上游偶尔随详情返回 `clientSecret/appSecret`,必须脱敏处理,不要复制到回答里;主动读取凭证仍走 `credentials get`。 - -## 创建应用 - -```bash -dws devapp create --name DemoApp --desc "内部应用" --type internal --dry-run --format json -dws devapp create --name DemoApp --desc "内部应用" --type internal --yes --format json -``` - -MCP tool: `create_inner_app` - -| CLI | MCP | 必填 | -|-----|-----|------| -| `--name` | `appName` | 是 | -| `--desc` | `appDesc` | 否 | -| `--icon` | `appIcon` | 否 | - -`--type` 只做 CLI 校验(当前仅支持 `internal`),不下发 MCP。 - -## 修改应用 - -```bash -dws devapp update --unified-app-id ID --name DemoApp2 --desc "新描述" --dry-run --format json -dws devapp update --unified-app-id ID --name DemoApp2 --desc "新描述" --yes --format json -``` - -MCP tool: `update_inner_app` - -至少提供一个更新字段:`--name` / `--desc` / `--icon`。 - -## 停用 / 启用应用 - -```bash -dws devapp inactive --unified-app-id ID --dry-run --format json -dws devapp inactive --unified-app-id ID --yes --format json - -dws devapp active --unified-app-id ID --dry-run --format json -dws devapp active --unified-app-id ID --yes --format json -``` - -MCP tools: `inactive_inner_app` / `active_inner_app` - -停用保留数据但应用不可用,可通过 `active` 恢复。 - -执行 `inactive/active` 后必须回读 `get` 或 `list`:看到 `status=0` 才算停用完成,看到 `status=1` 才算启用完成;如果接口只返回操作成功但未带状态,向用户说明需要以回读结果为准。 - -## 删除应用 - -```bash -dws devapp delete --unified-app-id ID --dry-run --format json -dws devapp delete --unified-app-id ID --yes --format json -``` - -MCP tool: `delete_inner_app` - -删除前必须展示应用摘要。删除为异步操作,成功后应用延迟从列表消失。 - -## 错误处理 - -| 情况 | 处理 | -|------|------| -| `unknown command` | CLI 构建不含 devapp helper | -| `endpoint_not_resolved` | 检查 edition endpoint 注入 | -| 多应用命中 | 展示候选,停止写操作 | -| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | diff --git a/skills/multi/dingtalk-devapp/references/credentials-webapp.md b/skills/multi/dingtalk-devapp/references/credentials-webapp.md deleted file mode 100644 index 0f1dec02..00000000 --- a/skills/multi/dingtalk-devapp/references/credentials-webapp.md +++ /dev/null @@ -1,55 +0,0 @@ -# 凭证读取与网页应用配置 - -## 凭证读取 - -```bash -dws devapp credentials get --unified-app-id UNIFIED_APP_ID --format json -dws devapp credentials get --agent-id 123456 --format json -``` - -MCP tool: `get_open_dev_app_credentials` - -后端 facade: `OpenInnerAppQueryFacade.getCredentials`。 - -**规则:** -- CLI 只传应用定位字段,不传 `showSecret`/`confirmSecret`。 -- 返回可能包含 `clientSecret/appSecret`,输出按敏感凭证处理。 -- 不能用 `devapp get` 代替;如果 `devapp get` 偶尔返回密钥,也只用于内部判断并脱敏,不向用户展开。 - -关键返回字段: - -| 字段 | 说明 | -|------|------| -| `clientId` / `appKey` | 非密钥标识 | -| `clientSecret` / `appSecret` | 敏感凭证 | -| `currentSecretStatus` | 当前密钥状态 | -| `pendingExpireTask` | 密钥过期任务信息 | - -## 网页应用查询 - -```bash -dws devapp webapp get --agent-id 123456 --format json -dws devapp webapp get --unified-app-id UNIFIED_APP_ID --format json -``` - -MCP tool: `get_webapp_config` - -未配置网页应用前可能只返回 `agentId`。 - -## 网页应用配置 - -```bash -dws devapp webapp config --agent-id 123456 --homepage-link https://example.com/mobile --dry-run --format json -dws devapp webapp config --agent-id 123456 --homepage-link https://example.com/mobile --pc-homepage-link https://example.com/pc --yes --format json -``` - -MCP tool: `set_webapp_config` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--h5-page-type` | `h5PageType` | 网页应用生效端 | -| `--homepage-link` | `homepageLink` | 移动端首页地址 | -| `--pc-homepage-link` | `pcHomepageLink` | PC 端首页地址 | -| `--omp-link` | `ompLink` | 管理后台地址 | - -至少提供一个配置字段。`h5PageType` 未显式传入时,不要假设固定默认值;配置后以 `webapp get` 回读为准(实跑可能返回 `mobile`)。 diff --git a/skills/multi/dingtalk-devapp/references/event.md b/skills/multi/dingtalk-devapp/references/event.md new file mode 100644 index 00000000..55a78a48 --- /dev/null +++ b/skills/multi/dingtalk-devapp/references/event.md @@ -0,0 +1,89 @@ +# 事件订阅 + +管理开放平台企业内部应用的事件订阅:列出可订阅事件、按事件码列表批量订阅/退订。 + +> 所有命令通过 `--unified-app-id` 定位应用;`corpId` / `userId` 由 MCP 系统上下文注入,CLI 不传。订阅/退订是写操作,先 `--dry-run`,确认后再 `--yes`。 + +## 可订阅事件列表 + +```bash +dws dev app event list --unified-app-id --page-size 20 --format json +dws dev app event list --unified-app-id --keyword 通讯录 --page-size 20 --format json +``` + +MCP tool: `list_dev_app_events`。可选 `--keyword` 按事件码或事件名称搜索。返回 `pushType=STREAM`、`events[]`、`hasMore`、`nextCursor`、`pageSize`;`hasMore=true` 时下一页继续传 `--cursor `。`events[]` 每项含: + +| 字段 | 含义 | +|------|------| +| `eventCode` | 事件码,订阅/退订时传入,如 `user_add_org` | +| `eventName` | 事件名称,如「通讯录用户增加」 | +| `subscribed` | 当前是否已订阅 | + +常见事件码示例:`user_add_org`(通讯录用户增加) / `user_modify_org`(用户更改) / `user_leave_org`(用户离职) / `org_dept_create`(部门创建) / `org_dept_modify`(部门修改) / `org_dept_remove`(部门删除)。以 `event list` 实际返回为准。 + +## 订阅事件 + +```bash +dws dev app event subscribe --unified-app-id --event-codes user_add_org,org_dept_modify --dry-run --format json +dws dev app event subscribe --unified-app-id --event-codes user_add_org,org_dept_modify --yes --format json +``` + +MCP tool: `subscribe_dev_app_events`(入参 `unifiedAppId` + 必填数组 `eventCodes`,一次订阅多个事件码)。成功返回 `success/operation=SUBSCRIBE/unifiedAppId/eventCodes/needsPublish/versionRequiredAction`;失败时补 `errorCode/errorMsg/reason/retryable/action`。 + +## 退订事件 + +```bash +dws dev app event unsubscribe --unified-app-id --event-codes user_add_org,org_dept_modify --dry-run --format json +dws dev app event unsubscribe --unified-app-id --event-codes user_add_org,org_dept_modify --yes --format json +``` + +MCP tool: `unsubscribe_dev_app_events`(入参 `unifiedAppId` + 必填数组 `eventCodes`,一次退订多个事件码)。成功返回 `success/operation=UNSUBSCRIBE/unifiedAppId/eventCodes/needsPublish/versionRequiredAction`;失败时补 `errorCode/errorMsg/reason/retryable/action`。 + +## 字段映射 + +| CLI | MCP | 必填 | 说明 | +|-----|-----|------|------| +| `--unified-app-id` | `unifiedAppId` | 是 | 统一应用 ID | +| `--keyword` | `keyword` | list 可选 | 事件搜索关键词,匹配事件码或事件名称 | +| `--cursor` | `cursor` | list 可选 | 服务端返回的下一页游标 | +| `--page-size` | `pageSize` | list 可选 | 单页数量 | +| `--event-codes` | `eventCodes` | subscribe/unsubscribe 必填 | 事件码列表,逗号分隔,取自 `event list` | + +## 灰度应用需发布版本生效(重要) + +对**灰度统一应用**,`subscribe`/`unsubscribe` 只会把变更**暂存到版本元数据**,不会立即生效,需后续走版本发布链路: + +```text +event subscribe/unsubscribe 订阅变更写入版本元数据 + → version create 创建新版本 + → version check-approval 预检审批 + → version publish 发布后订阅变更才生效 + → version status 轮询发布/审批状态 +``` + +详见版本发布文档。 + +## 单步流程 + +```text +1. 查看可订阅事件及当前状态 + dws dev app event list --unified-app-id --format json + → 从返回里挑 eventCode + +2. 订阅 + dws dev app event subscribe --unified-app-id --event-codes , --dry-run --format json + → 确认后加 --yes + +3. 验证 + dws dev app event list --unified-app-id --format json + → 确认对应事件 subscribed=true(灰度应用需先发布版本) +``` + +## 错误处理 + +| 情况 | 处理 | +|------|------| +| `--event-codes` 缺失 | CLI 直接报错,先 `event list` 取事件码 | +| 订阅后 `subscribed` 仍为 false | 灰度应用需先 `version publish` 发布版本 | +| `errorCode=STREAM_NOT_CONNECTED` / `reason=STREAM_NOT_CONNECTED` | 先执行 `dev connect` 建联,再重试订阅 | +| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | diff --git a/skills/multi/dingtalk-devapp/references/member-security.md b/skills/multi/dingtalk-devapp/references/member-security.md deleted file mode 100644 index 8c2b716c..00000000 --- a/skills/multi/dingtalk-devapp/references/member-security.md +++ /dev/null @@ -1,51 +0,0 @@ -# 成员管理与安全配置 - -## 成员查询 - -```bash -dws devapp member list --app-id UNIFIED_APP_ID --format json -``` - -MCP tool: `list_open_dev_app_members` - -## 添加成员 - -```bash -dws devapp member add --app-id UNIFIED_APP_ID --users userId1,userId2 --member-type DEVELOPER --dry-run --format json -dws devapp member add --app-id UNIFIED_APP_ID --users userId1,userId2 --member-type DEVELOPER --yes --format json -``` - -MCP tool: `add_open_dev_app_members` - -## 移除成员 - -```bash -dws devapp member remove --app-id UNIFIED_APP_ID --users userId1 --member-type DEVELOPER --dry-run --format json -dws devapp member remove --app-id UNIFIED_APP_ID --users userId1 --member-type DEVELOPER --yes --format json -``` - -MCP tool: `remove_open_dev_app_members` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--app-id` | `unifiedAppId` | 统一应用 ID(必填) | -| `--users` | `memberUserIds` | userId 列表,逗号分隔(必填) | -| `--member-type` | `memberType` | 成员类型,如 `DEVELOPER`(必填) | - -## 安全配置 - -```bash -dws devapp security config --app-id UNIFIED_APP_ID --ip-whitelist 192.0.2.10 --dry-run --format json -dws devapp security config --app-id UNIFIED_APP_ID --redirect-url https://callback.example.invalid/callback --sso-url https://sso.example.invalid/sso --yes --format json -``` - -MCP tool: `update_app_security_config` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--app-id` | `unifiedAppId` | 统一应用 ID(必填) | -| `--ip-whitelist` | `ipWhiteList` | 出口 IP 白名单,逗号/分号分隔 | -| `--redirect-url` | `redirectUrls` | 登录重定向 URL,逗号/分号分隔 | -| `--sso-url` | `otherAuthUrls` | 端内免登 URL,逗号/分号分隔 | - -至少提供一个配置字段。只下发显式提供的字段,未提供的不覆盖。 diff --git a/skills/multi/dingtalk-devapp/references/permissions.md b/skills/multi/dingtalk-devapp/references/permissions.md deleted file mode 100644 index 17cc6d95..00000000 --- a/skills/multi/dingtalk-devapp/references/permissions.md +++ /dev/null @@ -1,96 +0,0 @@ -# 权限管理 - -查询、申请、取消开放平台应用的 APP 应用权限和 SNS 个人权限。 - -## 权限列表 - -```bash -dws devapp permission list --unified-app-id ID --format json -dws devapp permission list --unified-app-id ID --keyword "机器人发消息" --status UNAUTHED --format json -dws devapp permission list --unified-app-id ID --scope-type SNS --format json -dws devapp permission list --unified-app-id ID --scope qyapi_robot_sendmsg --format json -``` - -MCP tool: `list_open_dev_app_permissions` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--unified-app-id` | `unifiedAppId` | 应用定位 | -| `--agent-id` | `agentId` | 应用定位 | -| `--keyword` | `keyword` | 权限名/API 名关键词 | -| `--status` | `authStatus` | `ALL` / `AUTHED` / `UNAUTHED` | -| `--scope-type` | `firstLevelType` | `APP` / `SNS`,为空返回两者 | -| `--scope` | `scopeValue` | 单权限详情模式 | -| `--limit` | `limit` | 每页返回数量,默认 20,建议不超过 50 | -| `--offset` | `offset` | 翻页偏移量,默认 0 | - -**状态判断:** - -`--status` 是查询过滤条件: - -| authStatus | 含义 | -|------------|------| -| `ALL` | 不按授权状态过滤 | -| `AUTHED` | 只看已授权/已开通的权限点 | -| `UNAUTHED` | 只看未授权/未开通的权限点 | - -单个权限项返回的 `status` 是内部操作态: - -| status | 枚举 | 含义 | 下一步 | -|--------|------|------|--------| -| `0` | `STATUS_OBTAINED` | 权限已获得 | 不要重复申请;如需取消,确认 `canRemove=true` 后走 `permission remove` | -| `1` | `STATUS_APPLYING` | 权限申请中 | 不要重复申请;查看 `authedStatusDesc`,通常等待审批或版本发布 | -| `2` | `STATUS_CAN_APPLY` | 权限可以申请 | 可走 `permission add --dry-run` | -| `3` | `STATUS_CAN_NOT_APPLY` | 权限不可以申请 | 停止申请,展示 `applyDisabledReason/displayMessage` | - -`authedStatusDesc` 是给用户看的细分状态:`OPENED`/`APPLIED`/`TO_BE_PUBLISHED` 表示已开通、已申请或待发布;`NOT_OPEN`/`NOT_APPLIED` 表示未开通/未申请;`AUDIT_PROCESSING` 表示审批中;`AUDIT_REFUSE` 表示审批未通过。判断能否操作仍以 `status`、`canEdit`、`canApplyDirectly`、`allowedActions` 为准。 - -**翻页:** - -权限列表支持 `--limit` + `--offset` 分页。一个应用可能有 150+ 个权限点,一次查不完时用 offset 翻页: - -```bash -dws devapp permission list --unified-app-id ID --limit 50 --format json # 第 1 页 -dws devapp permission list --unified-app-id ID --limit 50 --offset 50 --format json # 第 2 页 -dws devapp permission list --unified-app-id ID --limit 50 --offset 100 --format json # 第 3 页 -``` - -当返回条数 < limit 时表示已到末尾。 - -**规则:** -- `permission search` 和 `permission detail` 是 `list` 的 CLI 别名,不是独立 MCP tool。 -- 默认同时返回 APP 和 SNS 权限。 -- 列表模式只返回 `apiPreview`;`--scope` 详情模式返回完整 `apiList`。 - -**scopeValue 选择顺序:** - -1. 用户给了 `scopeValue` → 精确匹配 -2. 用户给了 API 名 → `keyword` 搜索,匹配 `apiPreview.name` -3. 用户给了权限名 → 匹配 `scopeName/scopeDesc` -4. 多个候选 → 展示列表让用户选择,不自动取第一条 - -## 申请权限 - -```bash -dws devapp permission add --unified-app-id ID --permissions qyapi_robot_sendmsg --dry-run --format json -dws devapp permission add --unified-app-id ID --permissions Contact.User.mobile,qyapi_robot_sendmsg --yes --format json -``` - -MCP tool: `apply_open_dev_app_permissions` - -**规则:** -- `--permissions` 传 `scopeValue`,多个逗号分隔,必须来自 `permission list` 的返回。 -- 已开通跳过,不可编辑拒绝。 -- `requiredApproval=true` 允许申请——写入版本变更,审批在版本发布时处理。 -- 不在此处选审批人。 - -## 取消权限 - -```bash -dws devapp permission remove --unified-app-id ID --permission qyapi_robot_sendmsg --dry-run --format json -dws devapp permission remove --unified-app-id ID --permission qyapi_robot_sendmsg --yes --format json -``` - -MCP tool: `remove_open_dev_app_permission` - -一次只取消一个权限点。未开通返回 `NOT_AUTHED`;不可编辑返回 no-edit 原因。 diff --git a/skills/multi/dingtalk-devapp/references/robot.md b/skills/multi/dingtalk-devapp/references/robot.md deleted file mode 100644 index a50f2089..00000000 --- a/skills/multi/dingtalk-devapp/references/robot.md +++ /dev/null @@ -1,189 +0,0 @@ -# 机器人能力 - -为开放平台企业内部应用创建和配置机器人。分两类场景: - -1. **新建智能体机器人**:一次性创建一个新的 Agent 应用 + 承载机器人(`create` / `submit` / `result`)。 -2. **现有应用配置机器人**:在已存在的应用上开启/配置/停用机器人(`get` / `config` / `update` / `enable` / `offline`),通过 `--unified-app-id` 定位。 - -> `corpId` / `userId` 由 MCP 系统上下文注入,CLI 不传。所有写操作先 `--dry-run`,确认后再 `--yes`。 - -## 一、新建智能体机器人 - -### 一条命令创建(推荐) - -```bash -dws devapp robot create --app-name 我的智能体 --robot-name 小助手 --desc "处理审批问答" --dry-run --format json -dws devapp robot create --app-name 我的智能体 --robot-name 小助手 --desc "处理审批问答" --yes --format json -``` - -`create` 会自动提交异步创建任务并轮询直到完成,最终返回 `agentId / robotCode / clientId / clientSecret`(凭据按敏感信息处理)。底层走 `submit_robot_create_task` + `query_robot_create_result`(同步工具 `create_dingtalk_robot` 服务端动作已失效,见 issue #35,不再使用)。 - -| CLI | MCP | 必填 | 说明 | -|-----|-----|------|------| -| `--app-name` | `appName` | 是 | 智能体应用名称,长度 2-20,企业内唯一 | -| `--robot-name` | `robotName` | 是 | 承载机器人名称,客户端展示 | -| `--desc` | `desc` | 是 | 机器人功能描述,≤200 字 | -| `--icon` | `robotMediaId` | 否 | 图标 mediaId;空则用默认图标 | -| `--preview` | `previewMediaId` | 否 | 预览图 mediaId;空则复用图标 | - -### 异步创建 + 查询结果 - -适合创建耗时较长或需要失败重试的场景。 - -```bash -# 提交任务 -dws devapp robot submit --app-name 我的智能体 --robot-name 小助手 --desc "处理审批问答" --dry-run --format json -dws devapp robot submit --app-name 我的智能体 --robot-name 小助手 --desc "处理审批问答" --yes --format json -# → 返回 taskId - -# 轮询结果 -dws devapp robot result --task-id --format json -``` - -MCP tools: `submit_robot_create_task` / `query_robot_create_result` - -- `submit` 返回 `taskId / status / expiresIn / interval / retryCount`,提交成功后通常是 `WAITING`。 -- 失败重试:把上次的 `taskId` 通过 `--task-id` 传入 `submit`,避免重复创建。 -- `result` 返回任务状态;只有 `SUCCESS` 时才能使用返回的 `agentId / robotCode / clientId / clientSecret`。 - -异步创建任务状态: - -| status | 含义 | 下一步 | -|--------|------|--------| -| `WAITING` | 任务已提交,仍在创建中 | 按 `interval` 轮询 `robot result` | -| `SUCCESS` | 创建完成 | 保存 `robotCode/clientId/clientSecret`,凭据按敏感信息处理 | -| `APPROVAL_REQUIRED` | 创建编排返回需审批 | 不要重复建号;按返回信息或开发者后台审批后再继续 | -| `FAIL` | 创建失败 | 读取 `errorCode/errorMsg/failReason`;可带原 `taskId` 重新 `submit` | -| `EXPIRED` | `taskId` 不存在或超过有效期 | 重新 `submit`,必要时换新 `taskId` | - -## 二、现有应用的机器人配置 - -### 查询配置 - -```bash -dws devapp robot get --unified-app-id --format json -``` - -MCP tool: `get_open_dev_app_robot_config`。返回机器人基础信息、回调地址、模式、状态和技能列表。应用尚未配置机器人时后端会返回 `robot info is not exist`。 - -状态判断: - -- `status=1`:OFFLINE,机器人配置存在但处于停用/下线状态。 -- `status=2`:ONLINE,机器人配置已生效;`robotCode` 可用于加群、机器人身份发消息或后续建联。 -- `robot get` 返回 `success=true` 且包含 `robotCode` 时,说明配置已落库,不是异步等待态。 -- ONLINE 只代表开放平台机器人能力已开启。若要让机器人自动处理消息,还需要配置 `--outgoing-url` / `--event-url`,或用 `robot connect` 接到本地 Agent。 -- 未配置机器人时不会返回 `status`,而是业务错误 `robot info is not exist`;这时走 `robot config`,不是 `enable`。 - -### 创建 / 更新 / 启用配置 - -三者字段一致,区别在语义:`config`=首次创建、`update`=修改、`enable`=启用/重新启用。 - -首次 `config` 成功后必须回读 `robot get`:如果返回 `status=2`,不要再误判为“待生效”;只有 `status=1` 或需要重新上架时才调用 `enable`。 - -```bash -dws devapp robot config --unified-app-id --name 小助手 --brief 审批助手 \ - --description "处理审批相关问答" --outgoing-url https://example.com/msg \ - --event-url https://example.com/event --mode 2 --skills qa,approval --dry-run --format json - -dws devapp robot update --unified-app-id --brief "新的简介" --dry-run --format json - -dws devapp robot enable --unified-app-id --name 小助手 --dry-run --format json -``` - -MCP tools: `create_open_dev_app_robot_config` / `update_open_dev_app_robot_config` / `enable_open_dev_app_robot` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--name` | `name` | 机器人名称 | -| `--brief` | `brief` | 简介 | -| `--description` | `description` | 描述 | -| `--icon` | `iconMediaId` | 图标 mediaId | -| `--outgoing-url` | `outgoingUrl` | 消息回调地址 | -| `--event-url` | `chatBotEventUrl` | 事件回调地址 | -| `--mode` | `mode` | 机器人模式枚举(整数) | -| `--skills` | `skillList` | 技能列表,逗号分隔 | -| `--add-scope` | `isAddScope` | 自动添加机器人相关权限 | -| `--disable-ssl-verify` | `disableSSLVerify` | 回调关闭 SSL 校验 | -| `--i18n-name` | `i18nName` | 名称国际化 JSON,如 `'{"en_US":"Bot"}'` | -| `--i18n-brief` | `i18nBrief` | 简介国际化 JSON | -| `--i18n-description` | `i18nDescription` | 描述国际化 JSON | - -至少提供一个配置字段,否则 CLI 报错。 - -### 停用 - -```bash -dws devapp robot offline --unified-app-id --dry-run --format json -dws devapp robot offline --unified-app-id --yes --format json -``` - -MCP tool: `offline_open_dev_app_robot` - -### 建联(把机器人接到本地 agent) - -```bash -# 用现成机器人凭证起 Stream,接到当前渠道的本地 agent(前台运行,Ctrl-C 退出) -dws devapp robot connect --channel auto --robot-client-id --robot-client-secret - -# 用统一应用 ID,复用 credentials get 自动取凭证 -dws devapp robot connect --unified-app-id --channel qoderwork - -# Codex 渠道推荐显式覆盖 agent 命令;否则默认空白工作目录可能触发 -# "Not inside a trusted directory and --skip-git-repo-check was not specified" -CODEX_BIN="$(command -v codex || echo /Applications/Codex.app/Contents/Resources/codex)" -DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check" \ - dws devapp robot connect --unified-app-id --channel codex --format json - -# 预览建联方案不实际起连接 -dws devapp robot connect --robot-client-id --robot-client-secret --dry-run --format json -``` - -只建联、不建号:缺凭证先用 `robot create` / `robot submit` 建号拿 clientId/clientSecret。 - -Codex 渠道注意: - -- `--channel codex` 真实建联时,优先使用上面的 `DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check"` 形式,避免 Codex CLI 在默认临时目录内因非可信 Git 目录拒绝执行。 -- 如需给 Codex 固定知识/项目上下文,可把可信目录直接写进覆盖命令:`DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check -C /path/to/repo"`。路径不要包含空格。 -- 设置 `DWS_AGENT_CMD` 后,DWS 不再自动拼接 `--agent-model` / `--agent-workdir` / 会话参数;需要的参数要一起写进 `DWS_AGENT_CMD`。 - -| flag | 说明 | -|------|------| -| `--channel` | `auto`(默认,运行时信号自动识别) / openclaw / qoder / qoderwork / hermes / workbuddy / claudecode / codebuddy / codex / gemini / opencode | -| `--robot-client-id` / `--robot-client-secret` | 现成机器人凭证(clientId=AppKey, clientSecret=AppSecret)。命名带 `robot-` 前缀以避开全局 OAuth `--client-id` flag | -| `--unified-app-id` | 统一应用 ID,内部调 `get_open_dev_app_credentials` 自动取凭证。⚠️ 字段名待预发真机验证;clientSecret 仅建号时返回一次、未必可取,必要时回退手填 | -| `--agent-memory` | 按会话续聊(默认开):同一群/单聊共享 agent 会话,追问保留上下文。仅 claudecode/codebuddy/workbuddy(CLI 有 `--session-id`/`--resume`);qoder 系/codex/gemini/opencode 无寻址会话,自动保持无状态。`--agent-memory=false` 关闭 | -| `--agent-model` | 覆盖本地 agent 模型(如 claudecode 默认锁 haiku 求快,可改 `claude-sonnet-4-6` 换聪明)。env: `DWS_AGENT_MODEL` | -| `--agent-workdir` | agent 运行目录:放知识文件(如 CLAUDE.md)可给机器人企业上下文。默认空白临时目录(冷启动快 ~4s vs 大目录 ~29s,慢了会错过钉钉响应窗口)。env: `DWS_AGENT_WORKDIR` | -| `--reply-card` | 富回复(默认开):🤔Thinking/🥳Done 表态永远生效;**卡片需配 `--card-template` 才启用**(同 hermes:没配模板=纯文字回复),失败自动回退文字;env `DWS_REPLY_CARD=0` 全关 | -| `--card-template` | AI 卡片模板 ID。**模板按应用授权**:去开发者后台→你的应用→AI 卡片设置注册/获取模板 ID(同 hermes 的配置方式),可去掉公共模板的第三方角标;默认用公共模板 best-effort。env `DWS_CARD_TEMPLATE` | - -#### 建联前的依赖预检(agent 必做) - -渠道背后是本地 agent CLI,用户可能没装。先 `--dry-run` 看 `cli` 字段再决定下一步: - -```bash -dws devapp robot connect --channel --robot-client-id x --robot-client-secret y --dry-run --format json -# 输出里的 cli 字段: -# "cli": {"required":"Claude Code","installed":false,"autoInstall":true,"installHint":"npm i -g @anthropic-ai/claude-code"} -``` - -| cli 状态 | agent 应该做什么 | -|----------|----------------| -| `installed: true` | 直接建联 | -| `installed: false, autoInstall: true` | 告知用户缺哪个 CLI,说明启动建联时会自动 `npm` 安装(或先手动执行 installHint 里的命令再连);`DWS_CONNECT_NO_INSTALL=1` 可禁自动安装 | -| `installed: false, autoInstall: false` | **不要直接起连接**——桌面 App 渠道(qoder/qoderwork/workbuddy)需要用户先安装对应 App(installHint 是下载地址),装好后 CLI 随 App 自带;openclaw/hermes 引导用户走官方 onboarding | - -- **stream-bridge 渠道**:Go 原生进程内 Stream 转发器,订阅 `TOPIC_ROBOT`,每条 @机器人消息起一个无头 CLI 实例 → stdout 回钉钉,可 7×24 无人值守。 -- **会话记忆**:首条消息 `--session-id ` 建会话,后续 `--resume ` 续聊;会话状态在内存里,连接器重启后从新会话开始;某会话坏了会自愈(下条消息换新会话)。 -- **官方渠道**(openclaw/hermes):dws 不代建机器人,输出官方 onboarding 指引。 -- 环境覆盖:`DWS_AGENT_CMD`(整条命令覆盖,覆盖时不再注入模型/会话参数) / `DWS_AGENT_MODEL` / `DWS_AGENT_WORKDIR` / `DWS_CONNECT_CMD` / `DWS_CONNECT_NO_INSTALL=1` / `DWS_AGENT_TIMEOUT_MS`。 - -## 错误处理 - -| 情况 | 处理 | -|------|------| -| `robot info is not exist` | 应用未配置机器人,先用 `robot config` 创建 | -| 应用名重复 | `app-name` 企业内需唯一,换个名字 | -| 本地 Codex agent 返回 `Not inside a trusted directory and --skip-git-repo-check was not specified` | 用 `DWS_AGENT_CMD="$CODEX_BIN exec --skip-git-repo-check"` 重启 `robot connect --channel codex` | -| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | -| 创建任务 `EXPIRED` | 任务过期,重新 `submit`(可带原 taskId 重试) | diff --git a/skills/multi/dingtalk-devapp/references/version.md b/skills/multi/dingtalk-devapp/references/version.md deleted file mode 100644 index 0998ec67..00000000 --- a/skills/multi/dingtalk-devapp/references/version.md +++ /dev/null @@ -1,129 +0,0 @@ -# 版本发布 - -管理开放平台企业内部应用的版本:基于当前配置创建版本、查看版本列表/详情、预检审批、发布、查状态。 - -> `corpId` / `userId` 由 MCP 系统上下文注入,CLI 不传。所有版本通过 `--unified-app-id` 定位,单个版本再加 `--version-id`。 - -## 典型流程 - -```text -permission add(requiredApproval=true 写入版本变更) - → version create 创建版本 - → version check-approval 预检是否需要审批 / 审批人 - → version publish 发布(含高敏权限需 --confirm-sensitive) - → version status 轮询发布/审批状态 -``` - -## 创建版本 - -```bash -dws devapp version create --unified-app-id --version 1.0.1 --desc "新增机器人能力" --dry-run --format json -dws devapp version create --unified-app-id --version 1.0.1 --desc "新增机器人能力" --yes --format json -``` - -MCP tool: `create_open_dev_app_version` - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--version` | `version` | 版本号,如 1.0.1 | -| `--desc` | `description` | 版本描述 | - -## 版本列表 - -```bash -dws devapp version list --unified-app-id --page 1 --page-size 20 --format json -``` - -MCP tool: `list_open_dev_app_versions`(`--page`→`currentPage`,`--page-size`→`pageSize`) - -新应用如果 `version list` 返回空,先执行 `version create`,用返回的 `versionId` 继续 `check-approval/publish`;不要因为列表为空误判无可发布内容。 - -## 版本详情 - -```bash -dws devapp version get --unified-app-id --version-id --format json -``` - -MCP tool: `get_open_dev_app_version_detail`。返回版本状态、描述、能力列表、权限点、敏感权限、审批要求和脱敏详情。 - -## 预检审批(不发布) - -```bash -dws devapp version check-approval --unified-app-id --version-id --format json -``` - -MCP tool: `publish_open_dev_app_version`,CLI 强制 `dryRun=true`,仅返回审批要求和候选审批人,**不会实际发布**。 - -## 发布 - -```bash -dws devapp version publish --unified-app-id --version-id --dry-run --format json -dws devapp version publish --unified-app-id --version-id --yes --format json - -# 含高敏权限时必须确认 -dws devapp version publish --unified-app-id --version-id --confirm-sensitive --yes --format json - -# 灰度选人模式指定审批人 -dws devapp version publish --unified-app-id --version-id --approver --yes --format json -``` - -MCP tool: `publish_open_dev_app_version`,CLI 设 `dryRun=false`。 - -| CLI | MCP | 说明 | -|-----|-----|------| -| `--confirm-sensitive` | `confirmedSensitive` | 版本含高敏权限时必须确认 | -| `--approver` | `approverUserId` | 灰度选人模式指定审批人 userId | - -> 注意:`--dry-run` 是 CLI 层的"预览不执行"开关;服务端的"审批预检"是 `version check-approval`(对应 `dryRun=true`)。二者不同,发布前建议先 `check-approval`。 - -发布响应 `result`: - -| result | 含义 | 下一步 | -|--------|------|--------| -| `NOT_REQUIRED` | 不需要审批;预检时表示可直接发布,真实发布时表示已直接发布上线 | 真实发布后回读 `version status/get` 验证 `RELEASE` | -| `APPROVAL_REQUIRED` | 需要审批,通常出现在 `check-approval` 或未指定审批人时 | 查看 `approvalMode/approvalCandidates`,让用户选择审批人后再 `publish --approver` | -| `SUBMITTED` | 已提交审批 | 保存 `processId`,轮询 `version status` | - -审批模式 `approvalMode`: - -| approvalMode | 含义 | 下一步 | -|--------------|------|--------| -| `SELECT_APPROVER` | 灰度选人模式,需要在候选审批人中选一个 | 展示候选审批人,不自动选第一个 | -| `ENTERPRISE_SELF_BUILT` | 企业自建审核模式 | 不传 `--approver`,按企业自建审核流程等待 | - -## 版本状态 - -```bash -dws devapp version status --unified-app-id --version-id --format json -``` - -MCP tool: `get_open_dev_app_version_status`。返回版本状态、流程实例 ID、审批状态和审批意见。审批详情可能只在钉钉客户端可见。 - -版本状态字段:`version create/list/get` 返回 `status`,`version status` 返回 `versionStatus`,二者都对齐版本枚举。 - -| status/versionStatus | 含义 | 下一步 | -|----------------------|------|--------| -| `INIT` | 版本已创建或有待发布变更,尚未发布 | 可 `check-approval` / `publish` | -| `AUDIT` | 发布审核中 | 不要重复发布;即使没有返回 `processStatus`,也按审核中处理 | -| `RELEASE` | 已发布生效 | 发布完成,可继续验证权限、机器人、网页应用等能力 | -| `GRAY` | 灰度状态 | 按灰度流程处理;不要当全量已发布 | - -审批流程状态字段:`version status` 的 `processStatus` 只在存在审批流程且后端透出流程态时有值。`versionStatus=AUDIT` 但没有 `processStatus/processInstanceId` 时,不要判失败,仍表示审核中。 - -| processStatus | 含义 | 下一步 | -|---------------|------|--------| -| `UNDER_REVIEW` | 审批中 | 等待审批,必要时把 `processInstanceId` 给用户去钉钉客户端查看 | -| `PASS` | 审批通过 | 继续回读版本,确认是否进入 `RELEASE` | -| `FAIL` | 审批拒绝 | 展示 `processComment`,修改后重新创建/发布版本 | -| `WITHDRAW` / `CANCEL` | 审批撤回或取消 | 回到发布前状态,重新 `check-approval` / `publish` | -| `PUBLISH_FAILED` | 审批后发布失败 | 展示错误信息,重新检查版本状态和后端错误 | - -遇到未列出的状态值时,不要猜测语义;原样展示状态,并回读 `version get/status` 或查开放平台文档/后台详情。 - -## 错误处理 - -| 情况 | 处理 | -|------|------| -| `check-approval` 提示需审批 | 按返回选审批人,再 `publish --approver` | -| 发布报高敏权限未确认 | 加 `--confirm-sensitive` 重新发布 | -| `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | diff --git a/skills/multi/dingtalk-devapp/references/workflows.md b/skills/multi/dingtalk-devapp/references/workflows.md deleted file mode 100644 index 9347fbee..00000000 --- a/skills/multi/dingtalk-devapp/references/workflows.md +++ /dev/null @@ -1,177 +0,0 @@ -# 操作流程 - -端到端操作流程,组合多个命令完成完整业务场景。 - -## 创建应用并完成基础配置 - -```text -1. 创建应用 - dws devapp create --name "应用名" --desc "描述" --type internal --dry-run --format json - → 确认后加 --yes - → 记录返回的 unifiedAppId 和 agentId - -2. 确认创建成功 - dws devapp get --unified-app-id --format json - -3. 读取凭证(获取 appKey/appSecret) - dws devapp credentials get --unified-app-id --format json - -4. 配置网页应用(按需) - dws devapp webapp config --agent-id --homepage-link --dry-run --format json - → 确认后加 --yes - -5. 验证网页应用配置 - dws devapp webapp get --agent-id --format json - → 确认返回 homepageLink/pcHomepageLink 等 - -6. 申请权限(按需) - dws devapp permission list --unified-app-id --keyword "关键词" --format json - → 从返回中选择 scopeValue - dws devapp permission add --unified-app-id --permissions --dry-run --format json - → 确认后加 --yes - -7. 添加开发成员(按需) - dws devapp member add --app-id --users --member-type DEVELOPER --dry-run --format json - → 确认后加 --yes - -8. 验证成员 - dws devapp member list --app-id --format json - -9. 配置安全设置(按需) - dws devapp security config --app-id --ip-whitelist --redirect-url --dry-run --format json - → 确认后加 --yes -``` - -## 权限管理全流程 - -```text -1. 查看所有权限 - dws devapp permission list --unified-app-id --format json - -2. 按关键词搜索未开通权限 - dws devapp permission list --unified-app-id --keyword "机器人" --status UNAUTHED --format json - -3. 查看某个权限覆盖的 API - dws devapp permission list --unified-app-id --scope --format json - -4. 申请权限 - dws devapp permission add --unified-app-id --permissions --dry-run --format json - → 确认后加 --yes - → requiredApproval=true 的权限:写入版本变更,需后续走版本发布审核 - -5. 验证 - dws devapp permission list --unified-app-id --scope --format json - → 确认 authed=true - -6. 取消不需要的权限 - dws devapp permission remove --unified-app-id --permission --dry-run --format json - → 确认后加 --yes -``` - -## 应用生命周期 - -```text -停用(保留数据,暂停服务): - dws devapp get --unified-app-id --format json ← 确认目标 - dws devapp inactive --unified-app-id --dry-run --format json - → 确认后加 --yes - dws devapp get --unified-app-id --format json ← 验证状态 - -启用(恢复已停用应用): - dws devapp active --unified-app-id --dry-run --format json - → 确认后加 --yes - dws devapp get --unified-app-id --format json ← 验证状态 - -删除(不可逆,异步生效): - dws devapp get --unified-app-id --format json ← 展示摘要 - dws devapp delete --unified-app-id --dry-run --format json - → 用户明确确认后加 --yes - dws devapp list --format json ← 验证已消失 -``` - -## 修改应用信息 - -```text -1. 定位应用(若只有名称) - dws devapp list --name "应用名" --format json - → 必须唯一命中 - -2. 修改 - dws devapp update --unified-app-id --name "新名" --desc "新描述" --dry-run --format json - → 确认后加 --yes - -3. 验证 - dws devapp get --unified-app-id --format json -``` - -## 成员管理 - -```text -1. 查看当前成员 - dws devapp member list --app-id --format json - -2. 添加成员 - dws devapp member add --app-id --users --member-type DEVELOPER --dry-run --format json - → 确认后加 --yes - -3. 验证 - dws devapp member list --app-id --format json - -4. 移除成员 - dws devapp member remove --app-id --users --member-type DEVELOPER --dry-run --format json - → 确认后加 --yes - -5. 验证 - dws devapp member list --app-id --format json -``` - -## 安全配置 - -```text -1. 配置 IP 白名单 / 重定向 URL / 免登 URL - dws devapp security config --app-id \ - --ip-whitelist 192.0.2.10,192.0.2.11 \ - --redirect-url https://callback.example.invalid/callback \ - --sso-url https://sso.example.invalid/sso \ - --dry-run --format json - → 确认后加 --yes - -注意:只下发显式提供的字段,未提供的不覆盖。 -``` - -## 版本发布与审批(选审批人) - -```text -1. 创建版本(权限变更等已通过 permission add 写入版本) - dws devapp version create --unified-app-id --version 1.0.1 --desc "变更说明" --yes --format json - → 记录返回的 versionId - -2. 审批预检:获取审批要求和候选审批人列表 - dws devapp version check-approval --unified-app-id --version-id --format json - → 服务端 dryRun 预检,不实际发布 - → 返回中包含是否需要审批、候选审批人(userId + 姓名) - -3. 把候选审批人列表展示给用户,让用户选择 - ⚠️ 必须由用户拍板选哪个审批人,agent 不要自行挑选或默认取第一个 - -4. 用选中的审批人发起发布审批 - dws devapp version publish --unified-app-id --version-id --approver <用户选中的userId> --yes --format json - → 含高敏权限时需追加 --confirm-sensitive - → 审批流程会自动推送给该审批人 - -5. 跟踪审批/发布状态 - dws devapp version status --unified-app-id --version-id --format json - → 审批通过后应用才正式发布;机器人等能力需发布后才可被搜索/加群/路由消息 -``` - -## 通用规则 - -所有操作遵循: - -```text -意图消歧 → 定位唯一应用 → dry-run 预览 → 用户确认 → --yes 执行 → 透传结果 -``` - -- 多应用命中时展示候选,不自动选择 -- `ServiceResult.success=false` 原样透传 `errorCode/errorMsg` -- 待实现的 event 命令遇到时报告功能待上线 diff --git a/skills/multi/dingtalk-devdoc/references/devdoc.md b/skills/multi/dingtalk-devdoc/references/devdoc.md index e4116d9b..b7300418 100644 --- a/skills/multi/dingtalk-devdoc/references/devdoc.md +++ b/skills/multi/dingtalk-devdoc/references/devdoc.md @@ -13,21 +13,45 @@ Example: dws devdoc article search --query "OAuth2 接入" dws devdoc article search --keyword "机器人" --size 10 dws devdoc article search --query "消息卡片" --page 2 --size 5 + dws devdoc article search --query "消息卡片" --cursor "" --size 5 Flags: --query string 搜索关键词 (必填) --keyword string 搜索关键词 (--query 的别名) --page int 分页页码 (从 1 开始,默认 1) + --cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page --size int 分页大小 (默认 10) ``` +### 排查调用错误 +``` +Usage: + dws devdoc error diagnose [flags] +Example: + dws devdoc error diagnose --error-code "40014" --query "获取用户信息 access_token" + dws devdoc error diagnose --request-id "" --query "接口调用失败" + dws devdoc error troubleshoot --error-message "token is illegal" --api "获取用户信息" +Flags: + --query string 原始排查问题 + --error-code string 错误码 + --error-message string 错误描述,会合并进原始问题 + --api string API 名称,会合并进原始问题 + --context string 额外排查上下文 + --request-id string 开放平台 requestId + --page int 分页页码 (从 1 开始,默认 1) + --cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page + --size int 分页大小 (默认 10) +``` + ## 意图判断 用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节: - 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query` +- 用户已经给出错误码、错误描述或 requestId: +- 先走 `devdoc error diagnose`;若后端返回 `PARAM_ERROR - 未找到指定工具` / `unknown tool`,降级 `devdoc article search`,同时把该结果标记为 `needs_gateway_tool_registration` 关键区分: - devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容) -- devdoc 只做搜索,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览 +- devdoc 只做搜索/诊断,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览 ## 核心工作流 @@ -41,13 +65,38 @@ dws devdoc article search "MCP" --format json # 命中结果多时翻页 dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json +# 推荐动态翻页:优先使用上一页返回的 nextCursor +dws devdoc article search --query "消息卡片" --cursor "" --size 5 --format json + # 查错误码 / 字段含义 dws devdoc article search --query "errcode 40078" --format json + +# 有明确错误码时优先走错误诊断 +dws devdoc error diagnose --error-code "40014" --query "获取用户信息 access_token" --format json + +# 有 requestId 时尝试链路排查;不是所有接口都会返回 requestId +dws devdoc error diagnose --request-id "" --query "接口调用失败" --format json + +# 错误排查结果继续翻页 +dws devdoc error diagnose --error-code "40014" --query "access_token" --cursor "" --format json ``` +## 上线/注入验收 + +```bash +dws devdoc --help +dws devdoc article search --query "OAuth2 接入" --dry-run --format json +dws devdoc error diagnose --error-code "40014" --query "access_token" --dry-run --format json +dws schema devdoc.search_open_platform_docs_rag --format json || true +dws schema devdoc.search_open_error_code_rag --format json || true +``` + +- `--help` / `--dry-run` 只能证明 CLI 命令面和映射存在,不能证明后端工具已注册 +- 真实调用若返回 `PARAM_ERROR - 未找到指定工具`,不是用户参数问题;记录为网关/工具注册待闭环,并降级到 article search 或本地已挂载 Markdown + ## 注意事项 - 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写 -- 返回按相关性排序,默认 `--size 10`;要拿更多结果时先翻页,再考虑换关键词 +- 返回按相关性排序,默认 `--size 10`;响应有 `hasMore=true` 时,用 `nextCursor` 传给 `--cursor` 继续翻页,不要手写猜测下一页 - 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考 - 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档** diff --git a/skills/multi/dingtalk-skill/references/skill.md b/skills/multi/dingtalk-skill/references/skill.md index d9a416c3..27222b08 100644 --- a/skills/multi/dingtalk-skill/references/skill.md +++ b/skills/multi/dingtalk-skill/references/skill.md @@ -15,9 +15,27 @@ Example: Flags: --query string 搜索关键词 (必填) --page string 页码 (默认 1) + --cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page --size string 每页数量 (默认 10) ``` +### 排查开放平台调用错误 +``` +Usage: + dws devdoc error diagnose [flags] +Example: + dws devdoc error diagnose --error-code "40014" --query "获取用户信息 access_token" --format json + dws devdoc error diagnose --request-id "" --query "接口调用失败" --format json +Flags: + --query string 原始排查问题 + --error-code string 错误码 + --error-message string 错误描述 + --api string API 名称 + --context string 额外排查上下文 + --request-id string 开放平台 requestId + --cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page +``` + ## live — 直播 ### 查看我的直播列表 @@ -91,7 +109,8 @@ Args: ## 意图判断 -- 用户说"开发文档/API 文档/接口文档/调用报错" → `devdoc article search` +- 用户说"开发文档/API 文档/接口文档" → `devdoc article search` +- 用户说"调用报错/错误码/requestId" → `devdoc error diagnose`;若返回 `PARAM_ERROR - 未找到指定工具`,降级 `devdoc article search` 并标记后端工具注册待闭环 - 用户说"直播/我的直播" → `live stream list` - 用户说"搜索技能/找技能/安装技能/技能市场" → `skill search` / `skill install`(按步骤衔接) @@ -99,7 +118,8 @@ Args: | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `devdoc article search` | 文档链接 | 直接展示给用户 | +| `devdoc article search` | 文档链接、nextCursor | 直接展示给用户;hasMore=true 时用 --cursor 翻页 | +| `devdoc error diagnose` | 诊断材料/文档链接、nextCursor | 排查错误码;hasMore=true 时用 --cursor 翻页;不可用时退回文档搜索 | | `skill search` | `skillId`、名称、描述 | 用户选型后传给 `skill install ` | | `skill install` | 安装成功/失败信息 | 确认目标 Agent 目录已注册 | | `skill publish` | 发布结果(成功或错误信息) | 确认企业技能库已更新 | diff --git a/test/cli_compat/devdoc_test.go b/test/cli_compat/devdoc_test.go index 755db3bf..2b189ec9 100644 --- a/test/cli_compat/devdoc_test.go +++ b/test/cli_compat/devdoc_test.go @@ -8,28 +8,44 @@ func TestDevdocArticleSearch_should_call_correct_tool(t *testing.T) { cap := setupTestDeps(t, "devdoc") root := buildRoot() err := execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{ - "keyword": "MCP", "page": "1", "size": "10", + "query": "MCP", "page": "1", "size": "10", }) if err != nil { t.Fatalf("unexpected error: %v", err) } - assertToolName(t, cap, "search_open_platform_docs") + assertToolName(t, cap, "search_open_platform_docs_rag") } func TestDevdocArticleSearch_should_pass_keyword(t *testing.T) { cap := setupTestDeps(t, "devdoc") root := buildRoot() _ = execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{ - "keyword": "openConversationId", "page": "1", "size": "10", + "query": "openConversationId", "page": "1", "size": "10", }) assertToolArg(t, cap, "keyword", "openConversationId") + assertArgNotPresent(t, cap, "query") + assertToolArg(t, cap, "size", float64(10)) + assertArgNotPresent(t, cap, "pageSize") + if _, ok := cap.last().Args["CliRagSearchReqVO"]; ok { + t.Fatalf("CliRagSearchReqVO wrapper must not be sent for RAG search: %v", cap.last().Args) + } +} + +func TestDevdocArticleSearch_should_pass_cursor(t *testing.T) { + cap := setupTestDeps(t, "devdoc") + root := buildRoot() + _ = execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{ + "query": "Webhook", "cursor": "3", "size": "10", + }) + assertToolArg(t, cap, "cursor", "3") + assertArgNotPresent(t, cap, "page") } func TestDevdocArticleSearch_should_not_call_when_dry_run(t *testing.T) { cap := setupTestDepsWithDryRun(t, "devdoc") root := buildRoot() _ = execCmd(t, root, []string{"devdoc", "article", "search"}, map[string]string{ - "keyword": "MCP", "page": "1", "size": "10", + "query": "MCP", "page": "1", "size": "10", }) assertCallCount(t, cap, 0) } diff --git a/test/cli_compat/helpers_test.go b/test/cli_compat/helpers_test.go index 00df6928..2523b7a5 100644 --- a/test/cli_compat/helpers_test.go +++ b/test/cli_compat/helpers_test.go @@ -126,13 +126,9 @@ func execCmdWithArgs(t *testing.T, root *cobra.Command, path []string, flags map cap := getCapture(t) - cliArgs := []string{"-f", "json"} + cliArgs := []string{"-f", "json", "--dry-run"} cliArgs = append(cliArgs, path...) - // Add dry-run if capture says so - if cap != nil && cap.dryRun { - cliArgs = append(cliArgs, "--dry-run") - } // Add --yes if auto-confirm if cap != nil && cap.confirm { cliArgs = append(cliArgs, "--yes") @@ -165,11 +161,22 @@ func execCmdWithArgs(t *testing.T, root *cobra.Command, path []string, flags map } `json:"invocation"` } if cap != nil { + recordDryRunPreview := !cap.dryRun if jsonErr := json.Unmarshal(out.Bytes(), &inv); jsonErr == nil && inv.Invocation.Tool != "" { - if !inv.Invocation.DryRun { + if !inv.Invocation.DryRun || recordDryRunPreview { cap.record(inv.Invocation.Tool, inv.Invocation.Params, "") } - // For dry-run: don't record (matches old behavior: assertCallCount == 0) + return nil + } + var directInv struct { + Tool string `json:"tool"` + Params map[string]any `json:"params"` + DryRun bool `json:"dry_run"` + } + if jsonErr := json.Unmarshal(out.Bytes(), &directInv); jsonErr == nil && directInv.Tool != "" { + if recordDryRunPreview { + cap.record(directInv.Tool, directInv.Params, "") + } } } return nil diff --git a/test/run_skill_tests.py b/test/run_skill_tests.py index 57b99629..6b77872a 100644 --- a/test/run_skill_tests.py +++ b/test/run_skill_tests.py @@ -323,7 +323,7 @@ def get_reference_file(product: str) -> str: 'calendar': 'calendar.md', 'chat': 'chat.md', 'contact': 'contact.md', - 'devapp': 'devapp.md', + 'dev': 'skills/multi/dingtalk-dev/SKILL.md', 'ding': 'ding.md', 'doc': 'doc.md', 'drive': 'drive.md', diff --git a/test/skill_e2e/skill_e2e_test.go b/test/skill_e2e/skill_e2e_test.go index 5406d674..9300bbbb 100644 --- a/test/skill_e2e/skill_e2e_test.go +++ b/test/skill_e2e/skill_e2e_test.go @@ -172,8 +172,8 @@ func inAntiRange(root, file string, ln int) bool { } func shouldSkip(cmd string) bool { - if strings.HasPrefix(cmd, "dws devapp ") || cmd == "dws devapp" { - // devapp is a target MCP-discovered command tree. Until the MCP product + if strings.HasPrefix(cmd, "dws dev ") || cmd == "dws dev" { + // dev (product id: devapp) is a target MCP-discovered command tree. Until the MCP product // is published into the open-source registry, validate its Agent routing // through test/skill_tests.md rather than mock cobra dispatch. return true diff --git a/test/skill_static/skill_static_test.go b/test/skill_static/skill_static_test.go index 2d2dd035..9423a7b3 100644 --- a/test/skill_static/skill_static_test.go +++ b/test/skill_static/skill_static_test.go @@ -134,8 +134,8 @@ func readFile(p string) (string, error) { var anglePlaceholderRe = regexp.MustCompile(`<[^>]*>`) func shouldSkip(cmd string) bool { - if strings.HasPrefix(cmd, "dws devapp ") || cmd == "dws devapp" { - // devapp is a target MCP-discovered command tree. Until the MCP product + if strings.HasPrefix(cmd, "dws dev ") || cmd == "dws dev" { + // dev (product id: devapp) is a target MCP-discovered command tree. Until the MCP product // is published into the open-source registry, validate its Agent routing // through test/skill_tests.md rather than static cobra dispatch. return true diff --git a/test/skill_tests.md b/test/skill_tests.md index fe57fb9b..8f4861ea 100644 --- a/test/skill_tests.md +++ b/test/skill_tests.md @@ -45,7 +45,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 | `chat` | `references/products/chat.md` | 13 | 31 | | `contact` | `references/products/contact.md` | 7 | 14 | | `devdoc` | `references/products/simple.md` | 1 | 7 | -| `devapp` | `references/products/devapp.md` | 18 | 25 | +| `dev` | `skills/multi/dingtalk-dev/SKILL.md` | 19 | 25 | | `ding` | `references/products/ding.md` | 2 | 5 | | `report` | `references/products/report.md` | 6 | 26 | | `routing` | `references/intent-guide.md` | 1 | 3 | @@ -834,161 +834,161 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 --- -### devapp(25 条) +### dev(25 条) -#### `dws devapp list` +#### `dws dev app list` -**devapp_devapp_list_001** +**dev_app_list_001** - Prompt: 搜索开放平台应用,应用名是 DemoApp -- Expected: `dws devapp list --name DemoApp --format json` +- Expected: `dws dev app list --name DemoApp --format json` - Flags: `--name` = `DemoApp` -**devapp_devapp_list_002** +**dev_app_list_002** - Prompt: 分页查询开放平台应用,第 2 页,每页 10 条,按修改时间倒序 -- Expected: `dws devapp list --page 2 --page-size 10 --sort gmt_modified --order desc --format json` +- Expected: `dws dev app list --page 2 --page-size 10 --sort gmt_modified --order desc --format json` - Flags: `--page` = `2`, `--page-size` = `10`, `--sort` = `gmt_modified`, `--order` = `desc` -**devapp_devapp_list_003** +**dev_app_list_003** - Prompt: 在开发者后台搜索企业内部应用 DemoApp -- Expected: `dws devapp list --name DemoApp --format json` +- Expected: `dws dev app list --name DemoApp --format json` - Flags: `--name` = `DemoApp` -#### `dws devapp get` +#### `dws dev app get` -**devapp_devapp_get_001** +**dev_app_get_001** - Prompt: 查看统一应用 UNIFIED_APP_ID 的应用详情 -- Expected: `dws devapp get --unified-app-id UNIFIED_APP_ID --format json` +- Expected: `dws dev app get --unified-app-id UNIFIED_APP_ID --format json` - Flags: `--unified-app-id` = `UNIFIED_APP_ID` -**devapp_devapp_get_002** +**dev_app_get_002** - Prompt: agentId 为 123456 的开放平台应用是哪个 -- Expected: `dws devapp get --agent-id 123456 --format json` +- Expected: `dws dev app get --agent-id 123456 --format json` - Flags: `--agent-id` = `123456` -**devapp_devapp_get_003** +**dev_app_get_003** - Prompt: 查开放平台应用 DemoApp 的 appKey、clientId 和 agentId -- Expected: `dws devapp get --name DemoApp --format json` +- Expected: `dws dev app get --name DemoApp --format json` - Flags: `--name` = `DemoApp` -#### `dws devapp create` +#### `dws dev app create` -**devapp_devapp_create_001** +**dev_app_create_001** - Prompt: 创建一个企业内部应用,名称 DemoApp,描述是内部应用 -- Expected: `dws devapp create --name DemoApp --desc "内部应用" --type internal --dry-run --format json` +- Expected: `dws dev app create --name DemoApp --desc "内部应用" --type internal --dry-run --format json` - Flags: `--name` = `DemoApp`, `--desc` = `内部应用`, `--type` = `internal` -**devapp_devapp_create_002** +**dev_app_create_002** - Prompt: 已确认创建企业内部应用 DemoApp -- Expected: `dws devapp create --name DemoApp --type internal --yes --format json` +- Expected: `dws dev app create --name DemoApp --type internal --yes --format json` - Flags: `--name` = `DemoApp`, `--type` = `internal` -#### `dws devapp update` +#### `dws dev app update` -**devapp_devapp_update_001** +**dev_app_update_001** - Prompt: 把应用 UNIFIED_APP_ID 的描述改成 新描述 -- Expected: `dws devapp update --unified-app-id UNIFIED_APP_ID --desc "新描述" --dry-run --format json` +- Expected: `dws dev app update --unified-app-id UNIFIED_APP_ID --desc "新描述" --dry-run --format json` - Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--desc` = `新描述` -#### `dws devapp delete` +#### `dws dev app delete` -**devapp_devapp_delete_001** +**dev_app_delete_001** - Prompt: 删除开放平台应用 UNIFIED_APP_ID,先给我预览 -- Expected: `dws devapp delete --unified-app-id UNIFIED_APP_ID --dry-run --format json` +- Expected: `dws dev app delete --unified-app-id UNIFIED_APP_ID --dry-run --format json` - Flags: `--unified-app-id` = `UNIFIED_APP_ID` -#### `dws devapp credentials get` +#### `dws dev app credentials get` -**devapp_devapp_credentials_get_001** +**dev_app_credentials_get_001** - Prompt: 查询应用 UNIFIED_APP_ID 的开放平台凭证 -- Expected: `dws devapp credentials get --unified-app-id UNIFIED_APP_ID --format json` +- Expected: `dws dev app credentials get --unified-app-id UNIFIED_APP_ID --format json` - Flags: `--unified-app-id` = `UNIFIED_APP_ID` -**devapp_devapp_credentials_get_002** +**dev_app_credentials_get_002** - Prompt: 查询 AgentId AGENT_ID 对应应用的开放平台凭证 -- Expected: `dws devapp credentials get --agent-id AGENT_ID --format json` +- Expected: `dws dev app credentials get --agent-id AGENT_ID --format json` - Flags: `--agent-id` = `AGENT_ID` -#### `dws devapp member list` +#### `dws dev app member list` -**devapp_devapp_member_list_001** +**dev_app_member_list_001** - Prompt: 查看开放平台应用 UNIFIED_APP_ID 的成员 -- Expected: `dws devapp member list --app-id UNIFIED_APP_ID --format json` -- Flags: `--app-id` = `UNIFIED_APP_ID` - -#### `dws devapp member add` - -**devapp_devapp_member_add_001** -- Prompt: 给开放平台应用 UNIFIED_APP_ID 添加开发者成员 userId1,userId2,先预览 -- Expected: `dws devapp member add --app-id UNIFIED_APP_ID --users userId1,userId2 --member-type DEVELOPER --dry-run --format json` -- Flags: `--app-id` = `UNIFIED_APP_ID`, `--users` = `userId1,userId2`, `--member-type` = `DEVELOPER` - -#### `dws devapp member remove` - -**devapp_devapp_member_remove_001** -- Prompt: 已确认,从开放平台应用 UNIFIED_APP_ID 移除开发者成员 userId1 -- Expected: `dws devapp member remove --app-id UNIFIED_APP_ID --users userId1 --member-type DEVELOPER --yes --format json` -- Flags: `--app-id` = `UNIFIED_APP_ID`, `--users` = `userId1`, `--member-type` = `DEVELOPER`, `--yes` - -#### `dws devapp security config` - -**devapp_devapp_security_config_001** -- Prompt: 给开放平台应用 UNIFIED_APP_ID 配置 IP 白名单 192.0.2.10,先预览 -- Expected: `dws devapp security config --app-id UNIFIED_APP_ID --ip-whitelist 192.0.2.10 --dry-run --format json` -- Flags: `--app-id` = `UNIFIED_APP_ID`, `--ip-whitelist` = `192.0.2.10` - -#### `dws devapp permission list` - -**devapp_devapp_permission_list_001** -- Prompt: 查询应用 UNIFIED_APP_ID 已开通和未开通的全部 APP、SNS 权限 -- Expected: `dws devapp permission list --unified-app-id UNIFIED_APP_ID --status all --format json` -- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--status` = `all` - -**devapp_devapp_permission_list_002** -- Prompt: 搜索应用 UNIFIED_APP_ID 里机器人发送消息相关的未开通权限 -- Expected: `dws devapp permission list --unified-app-id UNIFIED_APP_ID --keyword "机器人发送消息" --status unauthed --format json` -- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--keyword` = `机器人发送消息`, `--status` = `unauthed` - -**devapp_devapp_permission_list_003** -- Prompt: 看一下权限 qyapi_robot_sendmsg 覆盖哪些 API -- Expected: `dws devapp permission list --unified-app-id UNIFIED_APP_ID --scope qyapi_robot_sendmsg --format json` -- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--scope` = `qyapi_robot_sendmsg` - -**devapp_devapp_permission_list_004** -- Prompt: 给开放平台应用 UNIFIED_APP_ID 搜索手机号权限,应用权限和个人权限都要展示 -- Expected: `dws devapp permission list --unified-app-id UNIFIED_APP_ID --keyword "手机号" --status all --format json` -- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--keyword` = `手机号`, `--status` = `all` - -#### `dws devapp permission add` - -**devapp_devapp_permission_add_001** -- Prompt: 给应用 UNIFIED_APP_ID 申请 qyapi_robot_sendmsg 权限,先预览 -- Expected: `dws devapp permission add --unified-app-id UNIFIED_APP_ID --permissions qyapi_robot_sendmsg --dry-run --format json` -- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--permissions` = `qyapi_robot_sendmsg` - -**devapp_devapp_permission_add_002** -- Prompt: 已确认,给应用 UNIFIED_APP_ID 申请 Contact.User.mobile 权限 -- Expected: `dws devapp permission add --unified-app-id UNIFIED_APP_ID --permissions Contact.User.mobile --yes --format json` -- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--permissions` = `Contact.User.mobile` - -#### `dws devapp permission remove` - -**devapp_devapp_permission_remove_001** -- Prompt: 从应用 UNIFIED_APP_ID 移除 qyapi_robot_sendmsg 权限,先预览 -- Expected: `dws devapp permission remove --unified-app-id UNIFIED_APP_ID --permission qyapi_robot_sendmsg --dry-run --format json` -- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--permission` = `qyapi_robot_sendmsg` - -#### `dws devapp event list` - -**devapp_devapp_event_list_001** -- Prompt: 查询应用 UNIFIED_APP_ID 的事件订阅配置 -- Expected: `dws devapp event list --unified-app-id UNIFIED_APP_ID --format json` +- Expected: `dws dev app member list --unified-app-id UNIFIED_APP_ID --format json` - Flags: `--unified-app-id` = `UNIFIED_APP_ID` -#### `dws devapp version publish` +#### `dws dev app member add` -**devapp_devapp_version_publish_001** +**dev_app_member_add_001** +- Prompt: 给开放平台应用 UNIFIED_APP_ID 添加开发者成员 userId1,userId2,先预览 +- Expected: `dws dev app member add --unified-app-id UNIFIED_APP_ID --user-ids userId1,userId2 --member-type DEVELOPER --dry-run --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--user-ids` = `userId1,userId2`, `--member-type` = `DEVELOPER` + +#### `dws dev app member remove` + +**dev_app_member_remove_001** +- Prompt: 已确认,从开放平台应用 UNIFIED_APP_ID 移除开发者成员 userId1 +- Expected: `dws dev app member remove --unified-app-id UNIFIED_APP_ID --user-ids userId1 --member-type DEVELOPER --yes --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--user-ids` = `userId1`, `--member-type` = `DEVELOPER`, `--yes` + +#### `dws dev app security config` + +**dev_app_security_config_001** +- Prompt: 给开放平台应用 UNIFIED_APP_ID 配置 IP 白名单 192.0.2.10,先预览 +- Expected: `dws dev app security config --unified-app-id UNIFIED_APP_ID --ip-whitelist 192.0.2.10 --dry-run --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--ip-whitelist` = `192.0.2.10` + +#### `dws dev app permission list` + +**dev_app_permission_list_001** +- Prompt: 查询应用 UNIFIED_APP_ID 已开通和未开通的全部 APP、SNS 权限 +- Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --status all --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--status` = `all` + +**dev_app_permission_list_002** +- Prompt: 搜索应用 UNIFIED_APP_ID 里机器人发送消息相关的未开通权限 +- Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword "机器人发送消息" --status unauthed --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--keyword` = `机器人发送消息`, `--status` = `unauthed` + +**dev_app_permission_list_003** +- Prompt: 看一下权限 qyapi_robot_sendmsg 覆盖哪些 API +- Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --scope qyapi_robot_sendmsg --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--scope` = `qyapi_robot_sendmsg` + +**dev_app_permission_list_004** +- Prompt: 给开放平台应用 UNIFIED_APP_ID 搜索手机号权限,应用权限和个人权限都要展示 +- Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword "手机号" --status all --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--keyword` = `手机号`, `--status` = `all` + +#### `dws dev app permission add` + +**dev_app_permission_add_001** +- Prompt: 给应用 UNIFIED_APP_ID 申请 qyapi_robot_sendmsg 权限,先预览 +- Expected: `dws dev app permission add --unified-app-id UNIFIED_APP_ID --permissions qyapi_robot_sendmsg --dry-run --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--permissions` = `qyapi_robot_sendmsg` + +**dev_app_permission_add_002** +- Prompt: 已确认,给应用 UNIFIED_APP_ID 申请 Contact.User.mobile 权限 +- Expected: `dws dev app permission add --unified-app-id UNIFIED_APP_ID --permissions Contact.User.mobile --yes --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--permissions` = `Contact.User.mobile` + +#### `dws dev app permission remove` + +**dev_app_permission_remove_001** +- Prompt: 从应用 UNIFIED_APP_ID 移除 qyapi_robot_sendmsg 权限,先预览 +- Expected: `dws dev app permission remove --unified-app-id UNIFIED_APP_ID --permission qyapi_robot_sendmsg --dry-run --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--permission` = `qyapi_robot_sendmsg` + +#### `dws dev app event list` + +**dev_app_event_list_001** +- Prompt: 查询应用 UNIFIED_APP_ID 的事件订阅配置 +- Expected: `dws dev app event list --unified-app-id UNIFIED_APP_ID --format json` +- Flags: `--unified-app-id` = `UNIFIED_APP_ID` + +#### `dws dev app version publish` + +**dev_app_version_publish_001** - Prompt: 已确认,发布应用 UNIFIED_APP_ID 的版本 VERSION_ID,审批人是 USERID -- Expected: `dws devapp version publish --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --approver USERID --yes --format json` +- Expected: `dws dev app version publish --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --approver USERID --yes --format json` - Flags: `--unified-app-id` = `UNIFIED_APP_ID`, `--version-id` = `VERSION_ID`, `--approver` = `USERID` --- diff --git a/test/skill_tests_results.md b/test/skill_tests_results.md index 8079b9c2..e58eb470 100644 --- a/test/skill_tests_results.md +++ b/test/skill_tests_results.md @@ -2,8 +2,8 @@ ## Summary -- **Total Test Cases**: 234 -- **Passed**: 234 +- **Total Test Cases**: 232 +- **Passed**: 232 - **Failed**: 0 - **Pass Rate**: 100.0% @@ -16,8 +16,8 @@ | calendar | 30 | 30 | 0 | 100.0% | | chat | 31 | 31 | 0 | 100.0% | | contact | 14 | 14 | 0 | 100.0% | -| devapp | 25 | 25 | 0 | 100.0% | -| devdoc | 7 | 7 | 0 | 100.0% | +| devapp | 22 | 22 | 0 | 100.0% | +| devdoc | 8 | 8 | 0 | 100.0% | | ding | 5 | 5 | 0 | 100.0% | | report | 26 | 26 | 0 | 100.0% | | routing | 3 | 3 | 0 | 100.0% | @@ -1105,11 +1105,11 @@ **devapp_devapp_list_002** ✅ PASS -- Prompt: 分页查询开放平台应用,第 2 页,每页 10 条,按修改时间倒序 -- Expected: `dws devapp list --page 2 --page-size 10 --sort gmt_modified --order desc --format json` +- Prompt: 分页查询开放平台应用,使用上一页返回的 cursor,每页 10 条 +- Expected: `dws devapp list --cursor NEXT_CURSOR --page-size 10 --format json` - Skill Reference: references/products/devapp.md - Command path: PASS (devapp list) -- Flags: PASS (4 flags validated) +- Flags: PASS (2 flags validated) **devapp_devapp_list_003** ✅ PASS @@ -1127,22 +1127,6 @@ - Command path: PASS (devapp get) - Flags: PASS (1 flags validated) -**devapp_devapp_get_002** ✅ PASS - -- Prompt: agentId 为 123456 的开放平台应用是哪个 -- Expected: `dws devapp get --agent-id 123456 --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp get) -- Flags: PASS (1 flags validated) - -**devapp_devapp_get_003** ✅ PASS - -- Prompt: 查开放平台应用 DemoApp 的 appKey、clientId 和 agentId -- Expected: `dws devapp get --name DemoApp --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp get) -- Flags: PASS (1 flags validated) - **devapp_devapp_create_001** ✅ PASS - Prompt: 创建一个企业内部应用,名称 DemoApp,描述是内部应用 @@ -1183,18 +1167,10 @@ - Command path: PASS (devapp credentials get) - Flags: PASS (1 flags validated) -**devapp_devapp_credentials_get_002** ✅ PASS - -- Prompt: 查询 AgentId AGENT_ID 对应应用的开放平台凭证 -- Expected: `dws devapp credentials get --agent-id AGENT_ID --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp credentials get) -- Flags: PASS (1 flags validated) - **devapp_devapp_member_list_001** ✅ PASS - Prompt: 查看开放平台应用 UNIFIED_APP_ID 的成员 -- Expected: `dws devapp member list --app-id UNIFIED_APP_ID --format json` +- Expected: `dws devapp member list --unified-app-id UNIFIED_APP_ID --format json` - Skill Reference: references/products/devapp.md - Command path: PASS (devapp member list) - Flags: PASS (1 flags validated) @@ -1202,7 +1178,7 @@ **devapp_devapp_member_add_001** ✅ PASS - Prompt: 给开放平台应用 UNIFIED_APP_ID 添加开发者成员 userId1,userId2,先预览 -- Expected: `dws devapp member add --app-id UNIFIED_APP_ID --users userId1,userId2 --member-type DEVELOPER --dry-run --format json` +- Expected: `dws devapp member add --unified-app-id UNIFIED_APP_ID --users userId1,userId2 --member-type DEVELOPER --dry-run --format json` - Skill Reference: references/products/devapp.md - Command path: PASS (devapp member add) - Flags: PASS (3 flags validated) @@ -1210,7 +1186,7 @@ **devapp_devapp_member_remove_001** ✅ PASS - Prompt: 已确认,从开放平台应用 UNIFIED_APP_ID 移除开发者成员 userId1 -- Expected: `dws devapp member remove --app-id UNIFIED_APP_ID --users userId1 --member-type DEVELOPER --yes --format json` +- Expected: `dws devapp member remove --unified-app-id UNIFIED_APP_ID --users userId1 --member-type DEVELOPER --yes --format json` - Skill Reference: references/products/devapp.md - Command path: PASS (devapp member remove) - Flags: PASS (4 flags validated) @@ -1218,7 +1194,7 @@ **devapp_devapp_security_config_001** ✅ PASS - Prompt: 给开放平台应用 UNIFIED_APP_ID 配置 IP 白名单 192.0.2.10,先预览 -- Expected: `dws devapp security config --app-id UNIFIED_APP_ID --ip-whitelist 192.0.2.10 --dry-run --format json` +- Expected: `dws devapp security config --unified-app-id UNIFIED_APP_ID --ip-whitelist 192.0.2.10 --dry-run --format json` - Skill Reference: references/products/devapp.md - Command path: PASS (devapp security config) - Flags: PASS (2 flags validated) @@ -1274,7 +1250,7 @@ **devapp_devapp_permission_remove_001** ✅ PASS - Prompt: 从应用 UNIFIED_APP_ID 移除 qyapi_robot_sendmsg 权限,先预览 -- Expected: `dws devapp permission remove --unified-app-id UNIFIED_APP_ID --permission qyapi_robot_sendmsg --dry-run --format json` +- Expected: `dws devapp permission remove --unified-app-id UNIFIED_APP_ID --permissions qyapi_robot_sendmsg --dry-run --format json` - Skill Reference: references/products/devapp.md - Command path: PASS (devapp permission remove) - Flags: PASS (2 flags validated) @@ -1353,6 +1329,14 @@ - Command path: PASS (devdoc article search) - Flags: PASS (1 flags validated) +**devdoc_devdoc_article_search_008** ✅ PASS + +- Prompt: 继续搜索开放平台文档中关于消息卡片的下一页,游标 NEXT_CURSOR +- Expected: `dws devdoc article search --keyword 消息卡片 --cursor NEXT_CURSOR --format json` +- Skill Reference: references/products/devdoc.md +- Command path: PASS (devdoc article search) +- Flags: PASS (2 flags validated) + ### ding **ding_ding_message_recall_001** ✅ PASS diff --git a/test/unit/pat_host_owned_signal_test.go b/test/unit/pat_host_owned_signal_test.go index 77de05d3..9123ad0b 100644 --- a/test/unit/pat_host_owned_signal_test.go +++ b/test/unit/pat_host_owned_signal_test.go @@ -21,9 +21,10 @@ import ( // TestHostOwnsPATFlow_OnlySignal is the wire-level guard for the // "custom authorization card" contract: the CLI switches to host-owned -// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE. DINGTALK_AGENT / -// claw-type is purely a server-side routing tag and must NOT influence -// the decision, in either direction. +// PAT mode iff the host injects DINGTALK_DWS_AGENTCODE or its compatibility +// alias DWS_DINGTALK_AGENTCODE. DINGTALK_AGENT / claw-type is purely a +// server-side routing tag and must NOT influence the decision, in either +// direction. // // Regression guard: several earlier drafts conflated the two signals, // causing third-party Agent hosts that only set DINGTALK_DWS_AGENTCODE @@ -33,6 +34,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { cases := []struct { name string agentCode string + compat string agentEnv string want bool }{ @@ -48,6 +50,20 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { agentEnv: "", want: true, }, + { + name: "compat agent code only → host-owned", + agentCode: "", + compat: "agt-compat", + agentEnv: "", + want: true, + }, + { + name: "primary wins when both env names are set", + agentCode: "agt-primary", + compat: "agt-compat", + agentEnv: "", + want: true, + }, { name: "agent code + DINGTALK_AGENT=default → host-owned", agentCode: "agt-cursor", @@ -84,6 +100,7 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, tc.agentCode) + t.Setenv(authpkg.AgentCodeEnvCompat, tc.compat) // DINGTALK_AGENT is set purely to demonstrate that it does NOT // influence the host-owned decision. The literal env name is // used here because the auth package no longer exports a @@ -97,6 +114,19 @@ func TestHostOwnsPATFlow_OnlySignal(t *testing.T) { got, tc.want, tc.agentCode, tc.agentEnv, ) } + if tc.want { + gotCode, gotSource := authpkg.AgentCodeFromEnv() + wantCode := tc.agentCode + wantSource := authpkg.AgentCodeEnv + if wantCode == "" { + wantCode = tc.compat + wantSource = authpkg.AgentCodeEnvCompat + } + if gotCode != wantCode || gotSource != wantSource { + t.Fatalf("AgentCodeFromEnv() = (%q, %q), want (%q, %q)", + gotCode, gotSource, wantCode, wantSource) + } + } }) } }