Compare commits

...
Author SHA1 Message Date
chichuan 305ccf0984 Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 13:53:37 +08:00
chichuan c2c1131079 fix: match the release archive directory literally, not as a regex
release_version was interpolated into an awk regex, where '.' matches any
character. Version 1.0.1-beta.1 therefore also admitted
.changes/released/1x0x1-betaX1/, letting the archive drift from the
CHANGELOG version while every other seal assertion still passed and
breaking the documented audit trail.

Compare the archive prefix with index() and split the basename off with
substr(), matching the literal-comparison idiom already used throughout
check-changelog-pr.sh. Only the basename, whose character class is fixed,
stays a pattern.
2026-08-12 13:52:25 +08:00
github-actions[bot] 38e387bcd6 Merge pull request #959 from DingTalk-Real-AI/codex/drive-shortcuts
feat(drive): harden and expand shortcut workflows
2026-08-12 12:18:58 +08:00
chichuan 12435e6e54 refactor: stage the .changes diff once for both fragment triggers
Both trigger predicates ran the same git diff, which the script already
avoids elsewhere by staging --name-status into $tmp_root/status. Write the
path list once and let each awk predicate read it, matching that idiom.
2026-08-12 12:07:07 +08:00
chichuan 1d8182bcfb Merge remote-tracking branch 'origin/main' into pr888-nested-gate 2026-08-12 12:01:38 +08:00
chichuan 4243676739 fix: trigger release fragment tree validation on nested .changes paths
Git records no diff entry for a directory itself, so adding
.changes/foo/bar.md only surfaced the nested path, which the single-level
trigger regex skipped. The entry validation and the renderer were both
bypassed, letting a nested directory reach main and break every later
fragment render with 'unexpected directory'.

Trigger the top-level tree validation on any .changes change outside
.changes/released/ (which keeps its own immutability and release-seal
checks), and assert .changes itself is still a tree so replacing it with a
blob or symlink cannot empty the child listing unnoticed.

Re-rendering stays keyed on fragment changes so a README-only edit does
not fail on an empty fragment set.
2026-08-12 12:00:42 +08:00
Dennis 4324fa72f2 fix(drive): preserve copy schema properties 2026-08-12 11:56:10 +08:00
Dennis ef5462a4dc fix(drive): scan paginated file versions 2026-08-12 11:36:33 +08:00
Dennis e1da6ba356 fix(drive): preserve download output shorthand 2026-08-12 11:21:03 +08:00
Dennis ae309b5846 feat(drive): harden and expand shortcut workflows 2026-08-12 11:11:46 +08:00
github-actions[bot] 9ef26055fa chore: update beta formula for v1.0.58-beta.4 [skip ci] 2026-08-12 02:45:42 +00:00
chichuan d1bd518043 Merge pull request #957 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.4
docs: seal v1.0.58-beta.4 changelog
2026-08-12 10:32:34 +08:00
chichuan bac4fded0d docs: seal v1.0.58-beta.4 changelog 2026-08-12 10:25:48 +08:00
github-actions[bot] 82bfddc1c2 Merge pull request #922 from typefield/feat/skill-mode-migration
feat(skill): default installs and upgrades to multi-skill layout
2026-08-12 09:04:30 +08:00
玉澜 8cf23ee7cb Merge remote-tracking branch 'upstream/main' into feat/skill-mode-migration 2026-08-12 08:47:01 +08:00
玉澜 5777ea36e9 fix(skill): roll back partial mono installs 2026-08-12 08:44:48 +08:00
github-actions[bot] 6eceebd701 Merge pull request #953 from Anonymity-0/feat/card-send-native-mentions
feat(chat): support mentions in native card creation
2026-08-12 02:41:26 +08:00
玉澜 4b898e9011 test(auth): remove PAT polling timing race 2026-08-12 02:41:14 +08:00
玉澜 cb14ae96b3 Merge remote-tracking branch 'upstream/main' into feat/skill-mode-migration 2026-08-12 02:07:42 +08:00
前津 aeb4b2dcaa fix(chat): reject conflicting card update responses 2026-08-12 02:01:13 +08:00
玉澜 09f9289deb fix(skill): match managed names literally 2026-08-12 01:56:17 +08:00
前津 bbb14c24dc Merge remote-tracking branch 'upstream/main' into feat/card-send-native-mentions 2026-08-12 01:28:00 +08:00
github-actions[bot] 79f4be31d5 Merge pull request #956 from DingTalk-Real-AI/codex/fix-text-input-bounds
fix(localio): bound all text input paths
2026-08-11 17:16:44 +00:00
玉澜 e2a1be5e93 fix(skill): roll back partial setup transactions 2026-08-12 01:09:19 +08:00
Dennis 69911543c3 Merge remote-tracking branch 'origin/main' into codex/fix-text-input-bounds 2026-08-12 00:58:25 +08:00
john d4eba7fa96 Merge branch 'main' into feat/skill-mode-migration 2026-08-12 00:54:52 +08:00
前津 bbc2eb111c Merge remote-tracking branch 'upstream/main' into feat/card-send-native-mentions 2026-08-12 00:54:37 +08:00
github-actions[bot] b58b8c51bf Merge pull request #955 from DingTalk-Real-AI/codex/fix-eval-dispatch-403
fix(ci): restore eval dispatch PR comments
2026-08-12 00:52:54 +08:00
Dennis a7678472ab test(localio): scope path replacement to unix 2026-08-12 00:40:36 +08:00
Dennis f413db06be fix(localio): reject special files before open 2026-08-12 00:32:56 +08:00
Dennis 3d67d83110 test(localio): isolate input boundary e2e 2026-08-12 00:28:43 +08:00
玉澜 9de722ab34 fix(skill): roll back failed installer transactions 2026-08-12 00:22:09 +08:00
Dennis df088573fb fix(localio): bound all text input paths 2026-08-11 23:58:11 +08:00
chichuan a0c64e5ef4 fix(ci): restore eval dispatch PR comments 2026-08-11 23:57:12 +08:00
前津 eebd6b2a1c fix(chat): accept card update acknowledgement 2026-08-11 23:44:53 +08:00
玉澜 181f030350 test(skill): normalize backup paths on Windows 2026-08-11 23:40:28 +08:00
john 6140e503ec Merge branch 'main' into feat/skill-mode-migration 2026-08-11 23:31:08 +08:00
玉澜 9539ae8e40 fix(skill): centralize managed skill metadata 2026-08-11 23:26:49 +08:00
github-actions[bot] 7a140e59c3 Merge pull request #946 from DingTalk-Real-AI/codex/minutes-shortcuts
feat(minutes): align and expand shortcut workflows
2026-08-11 23:20:21 +08:00
前津 b1bfe6002d Revert "docs(skill): route create-only cards to native command"
This reverts commit 8e8e3a3ce8.
2026-08-11 22:54:02 +08:00
Dennis 38832448d2 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 22:53:51 +08:00
前津 8e8e3a3ce8 docs(skill): route create-only cards to native command 2026-08-11 22:52:24 +08:00
Dennis 3d4e43f4fc fix(minutes): align search scope enums 2026-08-11 22:49:27 +08:00
github-actions[bot] 155ce984c9 Merge pull request #916 from gtezg30062/feat/pull_knowledge_base_dynamic_1
Feat/pull knowledge base dynamic 1
2026-08-11 14:21:30 +00:00
john 4b93a1cb28 Merge branch 'main' into feat/pull_knowledge_base_dynamic_1 2026-08-11 22:05:50 +08:00
github-actions[bot] 1d384b9189 Merge pull request #952 from DingTalk-Real-AI/feat/eval-devix-poll
feat(eval): 用可验证轮询中继替代受限网络直连
2026-08-11 21:51:30 +08:00
玉澜 9f4e748404 fix(skill): preserve installs during layout migration 2026-08-11 21:37:55 +08:00
chichuan e0dd800378 docs: state the release fragment filename and file-kind contract 2026-08-11 21:24:43 +08:00
chichuan 309c39a8e0 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 21:24:25 +08:00
chichuan 39d6caa24d fix: validate every top-level .changes entry in the fragment gate
The fragment gate only ran validation when the changed path matched the
legal fragment name pattern, so `.changes/Foo.md`, `.changes/notes.txt`
and a symlinked fragment slipped through untouched and then broke the
next PR that added a legal fragment. The trigger now fires on any
top-level `.changes/` change other than README.md and rejects every
entry that is not README.md, released/, or a 100644 blob named
^[a-z0-9][a-z0-9._-]*\.md$.

The renderer had the same hole from the other side: `find -type f`
is false for symlinks, so a symlinked fragment was silently dropped
from the rendered notes, and the `[a-z0-9]*.md` glob only constrained
the first character so `chat reply.md` passed. It now walks every
top-level entry and fails on symlinks, unexpected directories,
non-regular files and illegal names. Both scripts pin LC_ALL=C so the
ASCII ranges cannot match uppercase under a different collation.

Adds regression coverage for illegal names, non-markdown entries,
symlinks and executable modes on both the gate and the renderer.
2026-08-11 21:07:12 +08:00
玉澜 0d4bd28a08 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 20:46:15 +08:00
玉澜 9264323b29 revert(ci): keep existing pull request checkout policy 2026-08-11 20:45:32 +08:00
玉澜 1744880648 test(skill): cover managed marker failure 2026-08-11 20:34:00 +08:00
玉澜 246f4ebaf5 docs(skill): consolidate migration design into RFC 2026-08-11 20:24:07 +08:00
Dennis a3f5a83527 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 20:22:35 +08:00
玉澜 ec5f312fd6 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 20:19:30 +08:00
玉澜 3c81741e2e fix(skill): fail partial setup installs 2026-08-11 20:19:00 +08:00
Dennis 931af6af59 chore(pr): keep evidence out of merge tree 2026-08-11 19:51:57 +08:00
Dennis 44311d0160 docs(pr): publish minutes agent e2e evidence 2026-08-11 19:51:08 +08:00
chichuan afb25ae0e9 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 19:49:49 +08:00
玉澜 293c085634 fix(skill): preserve same-prefix user skills 2026-08-11 19:47:30 +08:00
Dennis f81d09fb95 fix(localio): pin verified upload file across retries 2026-08-11 19:45:06 +08:00
前津 f8258576ef feat(chat): support mentions in native card creation 2026-08-11 19:42:06 +08:00
Dennis cd1ba34d96 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 19:30:15 +08:00
Dennis 2cc410db6c docs: remove shortcut analysis artifacts 2026-08-11 19:09:30 +08:00
玉澜 f57c002ae7 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 19:06:57 +08:00
Dennis 06ec207d17 fix(minutes): harden end-to-end failure handling 2026-08-11 18:54:24 +08:00
玉澜 b17634ef7d fix(skill): fail incomplete bundled skill installs 2026-08-11 18:49:05 +08:00
玉澜 e0fd344a26 fix(skill): always refresh bundled skills 2026-08-11 18:13:44 +08:00
john c38e988b14 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 17:34:26 +08:00
Dennis 66468c703f fix(minutes): preserve upload recovery and schema compatibility 2026-08-11 17:06:22 +08:00
玉澜 773e76a1c6 Merge remote-tracking branch 'fork/feat/skill-mode-migration' into feat/skill-mode-migration 2026-08-11 17:01:44 +08:00
玉澜 f4e39a219b fix(skill): preserve state on partial setup 2026-08-11 17:01:32 +08:00
john c170a464e1 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 17:00:13 +08:00
Dennis 74ef426064 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 16:55:06 +08:00
玉澜 4665b42bbf fix(skill): preserve installer caches during refresh 2026-08-11 16:16:49 +08:00
john 16273de554 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 16:13:48 +08:00
john aabee99e3f Merge branch 'main' into feat/skill-mode-migration 2026-08-11 15:35:23 +08:00
玉澜 9e3a083c27 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 14:12:43 +08:00
玉澜 3a0d814276 docs(skill): remove confirmation bypass examples 2026-08-11 13:52:24 +08:00
玉澜 f3ddbb2db0 fix(upgrade): preserve skill cache during refresh 2026-08-11 13:17:07 +08:00
玉澜 fbdb5e8d4d Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 12:07:58 +08:00
玉澜 cc7e7bf0e0 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration
# Conflicts:
#	internal/app/skill_setup.go
#	internal/app/skill_setup_test.go
2026-08-11 12:06:59 +08:00
Dennis 461b9b773a feat(minutes): align and expand shortcut workflows 2026-08-11 11:52:57 +08:00
liangxiaoqin.lxq 4bd9f75231 cr修复1 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 8f8f64c391 cr修复,增加测试 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq ae9caa06af cr修复 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq ee0c3507a5 补充测试 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 72a9902254 wiki feed list命令 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 5f337e0ce5 wiki feed list命令:新增时间格式化/字段裁剪 2026-08-11 10:57:44 +08:00
玉澜 01a7b20026 fix(skill): keep setup confirmation and Windows tests safe 2026-08-11 10:46:40 +08:00
玉澜 62541947e7 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 10:10:01 +08:00
玉澜 596da1343e fix(skill): sync installed multi-skill set safely 2026-08-11 00:10:59 +08:00
玉澜 12c7b6eb89 test(skill): cover mono cleanup failure on Windows 2026-08-10 22:50:52 +08:00
玉澜 bc3d92ccaf Merge remote-tracking branch 'origin/main' into pr-922 2026-08-10 22:36:07 +08:00
玉澜 61adc87987 fix(skill): fail safely during layout migration 2026-08-10 22:35:36 +08:00
玉澜 c515f7c1e5 test(ci): cover aggregate changed-code edges 2026-08-10 21:28:58 +08:00
玉澜 8eae408e28 fix(ci): pin synthetic merge to event SHA 2026-08-10 21:08:55 +08:00
玉澜 9f3df91584 fix(ci): pin merge checkout and cover Windows edges 2026-08-10 21:04:08 +08:00
玉澜 37cccdbc0e Merge remote-tracking branch 'origin/main' into pr-922 2026-08-10 20:51:58 +08:00
玉澜andCursor b6851e641e fix(skill): back up skill dirs before removal and satisfy coverage gate
Address the two P1 review findings and the coverage-gate CI failures:
- Every install/upgrade path that removes a skill dir (opposite-mode
  leftovers, stale dingtalk-* / dws-shared, and same-name refreshes) now
  moves the directory to ~/.dws/skill-backups/<stamp>/ first across
  install.sh, install-skills.sh, install.ps1, install.js, `dws skill
  setup`, and `dws upgrade`. A backup failure preserves the original
  directory and never removes it.
- Remove --yes from every copyable `dws skill setup` example and document
  what the command may remove; add regression tests that declining the
  confirmation performs no removal and that the confirmation previews
  every directory slated for backup+removal.
- Rename the skill-mode tests to the TestCrossPlatformCoverage* prefix so
  the platform coverage gate selects them, and add edge tests for the
  backup/prune/cleanup fallback branches, restoring changed-code coverage
  to 100%.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:23:35 +08:00
chichuan 3af7adaad6 Merge branch 'main' into codex/release-fragments 2026-08-10 17:06:47 +08:00
玉澜andCursor e02e4a666d Merge latest main into feat/skill-mode-migration
Upstream reorganized the multi-skill layout (#887: long-tail skills folded
into dingtalk-misc, dws-shared renamed to dingtalk-shared). Conflict
resolution keeps this branch's multi-by-default semantics (install.sh /
install.ps1 / skill setup default to multi; interactive prompts list multi
first) and adapts the cleanup paths to the rename: cleanup predicates now
recognize both dingtalk-shared (new bundle name, covered by the dingtalk-
prefix) and the legacy dws-shared so full installs and mode switches remove
pre-rename leftovers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 11:03:33 +08:00
chichuan 1f127881c9 Merge branch 'main' into codex/release-fragments 2026-08-07 10:24:51 +08:00
chichuan c52f2b6e05 release: use isolated changelog fragments 2026-08-06 10:49:46 +08:00
玉澜andCursor d5c8982c00 feat(upgrade): always refresh to multi-skill layout (no sticky)
When a release zip contains multi/, upgrade one-shot refreshes to the
multi-skill layout and migrates existing mono installs. Docs drop the
cancelled runtime switch / sticky design.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:33:37 +08:00
玉澜andCursor 402429ac2a feat(skill): default installs and upgrades to multi-skill layout
Flip the agent-skill default from mono (single dws/ dir) to multi
(per-product dingtalk-* + dws-shared) across all distribution faces,
and fix the upgrade path so it no longer re-installs mono alongside
multi (mono+multi co-existence bug).

- upgrade: LocateSkillsRoot prefers the zip multi/ tree; multi refresh
  removes mono leftovers and stale skills, refreshes the multi cache
- install.sh/ps1/install-skills.sh/npm install.js: multi real-install
  (was print-only), default flipped, mono stays opt-in via DWS_SKILL_MODE
- skill setup: non-interactive default multi; full installs now clean
  stale dingtalk-*/dws-shared with confirm-preview disclosure, filtered
  (-s/-x) installs stay additive
- mutual exclusion is symmetric and includes dws-shared (previously
  leaked through the dingtalk- prefix) on all faces
- install.js: guard empty/corrupt multi trees (fall back to mono),
  validate SKILL.md on the mono branch, guard cache refreshes
- docs: roadmap (8/30 back-schedule), migration plan, distribution
  mechanism, rollout capability, capability completion, architecture
  optimization, wukong comparison (archived; line retired)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:13:45 +08:00
128 changed files with 22695 additions and 1902 deletions
+34
View File
@@ -0,0 +1,34 @@
# Release fragments
普通功能、修复和面向用户的行为变更不要再修改根目录 `CHANGELOG.md` 的
`Unreleased` 区域。每个 PR 在本目录新增一个独立的 Markdown fragment,避免
并行 PR 争用同一文件。
文件名使用能唯一定位变更的短名,通常是 PR 号,例如
`1234-chat-reply-mentions.md`。文件名必须匹配
`^[a-z0-9][a-z0-9._-]*\.md$`,且必须是普通文件,不能是符号链接。本目录顶层
只接受 `README.md`、`released/` 和符合该规则的 fragment:fragment 一律平铺在
顶层,不接受任何其它子目录,本目录自身也不能被替换成文件或符号链接。其余条目
会被 CI 直接拒绝而不是忽略,以免非法条目跳过校验后拖垮下一个 PR。文件格式
严格如下:
```markdown
---
category: Added
---
- **Chat reply mentions** (#1234) — supports mentioning selected members.
```
`category` 只能是 `Added`、`Changed`、`Deprecated`、`Removed`、`Fixed` 或
`Security`。正文至少包含一个 Markdown 列表项,且不得包含 `TODO` 或 `TBD`。
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
将未归档 fragments 汇总为唯一的版本章节,并移动到
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
删除或重写已归档文件。
无需面向用户发布说明的改动不添加 fragment。评审者根据改动是否可见来判断该
例外是否成立。
+4 -2
View File
@@ -19,8 +19,10 @@ repeat the entire CI suite locally only to fill this checklist: CI expands the
selected tier from documentation checks, through affected-package tests, to
the complete high-risk suite.
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Release fragment added for a user-visible behavior/interface change (otherwise `N/A`):
`.changes/<unique-name>.md`; ordinary PRs must not edit `CHANGELOG.md`.
- [ ] Release-seal validation (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --content-only "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Targeted test/check commands and results:
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
+6
View File
@@ -1288,6 +1288,12 @@ jobs:
./scripts/policy/check-changelog-pr.sh \
"$mode" "$PR_BASE_SHA" HEAD
- name: Validate release fragment lifecycle
if: github.event_name == 'pull_request'
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
- name: Validate trusted main CHANGELOG-only push
if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true'
env:
+15 -29
View File
@@ -34,8 +34,8 @@ jobs:
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: read
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
pull-requests: write
steps:
- name: Check out default branch tooling
uses: actions/checkout@v4
@@ -69,12 +69,10 @@ jobs:
PARSE_ERROR: ${{ steps.parse.outputs.error }}
run: |
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$body" \
> /dev/null
exit 1
- name: Verify reviewed PR head
@@ -103,13 +101,9 @@ jobs:
set -euo pipefail
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
response="$(
jq -n --arg body "$placeholder_body" '{body: $body}' \
| curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$placeholder_body"
)"
comment_id="$(
printf '%s' "$response" \
@@ -277,13 +271,9 @@ jobs:
fi
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
response="$(
jq -n --arg body "$body" '{body: $body}' \
| curl --fail --silent --show-error \
-X PATCH \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}"
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$body"
)"
printf '%s' "$response" \
| jq -e \
@@ -299,12 +289,8 @@ jobs:
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
run: |
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
jq -n --arg body "$failure_body" '{body: $body}' \
| curl --fail --silent --show-error \
-X PATCH \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$failure_body" \
> /dev/null \
|| true
+1 -1
View File
@@ -2793,7 +2793,7 @@ jobs:
fi
if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then
echo
echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish."
echo "Plan only: no tag or package was created. Render pending \`.changes/*.md\` fragments into the exact \`CHANGELOG.md\` section, merge the release-seal PR to main, then run publish."
fi
} >> "$GITHUB_STEP_SUMMARY"
+45
View File
@@ -6,6 +6,51 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58-beta.4] - 2026-08-12
### Added
- **Multi-skill installation and upgrade** — fresh installs, `dws skill setup`,
and `dws upgrade` now use the multi-skill layout by default. Existing mono
installations migrate during upgrade; mono remains an explicit legacy option.
- **Native streaming-card mentions** — `dws chat message send-card` now accepts
`--at-open-dingtalk-ids` and `--at-all` for group cards and forwards them to
`create_and_send_card`, matching the existing shortcut behavior without
changing single-chat card creation.
- **Expanded Minutes workflows** — 27 public Minutes shortcuts now cover
upload, download, export, recording, analysis, sharing, and recovery flows;
every write command keeps an explicit confirmation requirement.
- **Chat command discovery** — 30 existing typed Chat commands are now
available in the runtime Schema and Agent catalog, with sensitive writes
carrying their required confirmation metadata.
### Changed
- **Chat read results** — typed commands and shortcuts now expose a consistent
top-level `messages` list with stable `messageId` and `text` fields while
retaining existing response envelopes and fields.
- **Wiki feed results** — Wiki feed list output now formats time fields and
trims excess fields. Its `--limit` default is 10 and maximum is 20.
- **Developer command results** — the `dev` and selected `devapp` commands now
use the unified result envelope for consistent success, pending, partial,
and failure reporting.
- **Evaluation dispatch hardening** — `/eval` now uses a verifiable polling
relay instead of direct access from the hosted runner, binding the workflow,
comment, PR head, parameters, and result provenance.
### Fixed
- **Streaming-card update acknowledgement** — accepts the pre-production
`success: true` response from `update_streaming_card` as affirmative write
evidence while preserving explicit negative, conflicting, and bizId-drift
failures, so Agents do not repeat an update that the service already applied.
- **Text input bounds** — literal input, stdin, and `@file` inputs now all
enforce the same byte limit; file reads validate the opened descriptor and
cannot exceed the limit after a path replacement or file growth.
- **Evaluation PR comments** — restores `/eval` PR conversation comments with
the least required pull-request write permission and actionable GitHub 403
diagnostics.
## [1.0.58-beta.3] - 2026-08-11
### Added
+4 -1
View File
@@ -83,7 +83,10 @@ coverage is additionally selected for platform-sensitive code.
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
installer surfaces change (run `make package` first).
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
7. Update docs and add one `.changes/<unique-name>.md` release fragment for
behavior/interface changes. Do not edit `CHANGELOG.md` in an ordinary PR;
the release-seal workflow renders and archives fragments into the versioned
changelog section.
## Submission Flow
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58-beta.3"
version "1.0.58-beta.4"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-arm64.tar.gz"
sha256 "29b4fb9e081f36a699933c0919fe7530544f62de3e27c785d27626a575a2efc2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-arm64.tar.gz"
sha256 "5c2ac92e35b1f1dba80234af8b0c9505b2883f4a37c1e73892b8a1c3087b7702"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-darwin-amd64.tar.gz"
sha256 "a6b9c4ef212c533e02b414bd9c3be0b8d1874d4af983a7896072825bdfec1033"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-darwin-amd64.tar.gz"
sha256 "93ef787770105fe1f0d27585adcac7b740aa6c37ff490275c4113814541ae095"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-arm64.tar.gz"
sha256 "ae3a9ebe151702fd05dee0c56d778a20789d98c390cf8c0a0b2ec695b57b5ec3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-arm64.tar.gz"
sha256 "011ce16a73d8fd24275e34c3122d3d0832c60cde2480f496018eb654059b5c05"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-linux-amd64.tar.gz"
sha256 "544b480701e9ec9ec5366467ad4c855fca06dea887e3d577ff50e4b3eeb13ac2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-linux-amd64.tar.gz"
sha256 "847b17ff8a8d80dce38f0013eb35c77c102be16c9f98b955a632b983cd5ec104"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.3/dws-skills.zip"
sha256 "322f1840442ff183ad4b6d4f2a2b38825ff9f96a3fcde06ba57b8f80647468ae"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.4/dws-skills.zip"
sha256 "f5e0c72cc92cb7e8886409319cf68bbbfc7740e969bd39a389b74af4befdbc66"
end
def install
+28 -17
View File
@@ -70,15 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
How to pick:
- **Quick install** (one-liner above): non-interactive, installs `mono`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
- **Quick install** (one-liner above): non-interactive, installs `multi`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
</details>
@@ -391,19 +393,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
```bash
# Install skills into current project (defaults to mono)
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
> `install.sh` installs under `$HOME/.agents/skills/` (global; multi layout is per-product siblings, mono is the `dws/` subdirectory); `install-skills.sh` installs under `./.agents/skills/` (current project).
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
@@ -413,13 +415,18 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
# Interactive: prompts for mode + target agents
dws skill setup
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# Preview the exact directories that mono setup would back up and replace
dws skill setup --mode mono --target all --dry-run
# Install multi skills to a single agent home
dws skill setup --mode multi --target cursor --yes
# Run interactively and confirm the listed directories
dws skill setup --mode mono --target all
# Point at a local source tree (e.g. a fork or work-in-progress)
# Preview, then install multi skills to a single agent home with interactive confirmation
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
@@ -428,7 +435,11 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
| `--source` | path | Local source directory (overrides bundled skills) |
| `--yes` | — | Skip confirmation prompts |
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
@@ -726,7 +737,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
<summary>Coming soon</summary>
- `conference` (video meetings)
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back after interactive confirmation
</details>
+28 -17
View File
@@ -70,15 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
怎么选:
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
</details>
@@ -385,19 +387,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
```bash
# 安装 skills 到当前项目(默认 mono)
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
> `install.sh` 安装到 `$HOME/.agents/skills/`(全局,multi 为按产品平铺,mono 为 `dws/` 子目录);`install-skills.sh` 安装到 `./.agents/skills/`(当前项目)。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
@@ -407,13 +409,18 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
# 交互式:提示选模式 + 目标 Agent
dws skill setup
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# 先预览 mono setup 将备份和替换的精确目录
dws skill setup --mode mono --target all --dry-run
# 只装到某一个 Agent home
dws skill setup --mode multi --target cursor --yes
# 交互执行并确认列出的目录
dws skill setup --mode mono --target all
# 指定本地源目录(比如 fork 或正在改的版本)
# 先预览,再交互确认装到某一个 Agent home
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# 指定本地源目录(比如 fork 或正在改的版本),先预览
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
@@ -422,7 +429,11 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
| `--yes` | — | 跳过确认提示 |
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
> setup 命令可能移除对面模式残留(装 multi 删 `dws/`,装 mono 清理统一状态中登记或属于状态上线前精确官方名称集合的 multi Skill)以及不在 bundle 内的过期受管 Skill。DWS 在 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)集中记录所有权、安装版本、来源和内容摘要。仅有 `dingtalk-*` 前缀不能触发清理,因此其他同前缀市场/用户 Skill 会保留。所有删除都会先列入确认预览,并备份到 `~/.dws/skill-backups/<时间戳>/`;备份失败的目录会保留原样、绝不删除。非交互环境应先用 `--dry-run` 核对输出,再由调用方显式决定是否使用仅供脚本的确认跳过参数。
multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权元数据写入 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)。每次 upgrade 都会安装并覆盖该版本的全部预制 Skill;手工删除或通过 setup 排除预制 Skill 不会永久保留,下次 upgrade 会恢复。`dws upgrade --force` 还允许在没有新版本时重装当前 CLI 版本。
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
@@ -715,7 +726,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
<summary>即将推出</summary>
- `conference`(视频会议)
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
</details>
+570 -17
View File
@@ -3,6 +3,7 @@
"use strict";
const fs = require("fs");
const crypto = require("crypto");
const os = require("os");
const path = require("path");
const childProcess = require("child_process");
@@ -45,6 +46,58 @@ function ensureCleanDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
// backupStamp returns the UTC timestamp used for backup directory names,
// matching the shell installers' `date -u +%Y%m%d-%H%M%S` layout.
function backupStamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
);
}
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel-or-basename> instead of destroying it (non-interactive
// installs cannot confirm, so removals must stay reversible). Missing paths
// are a no-op success. On any backup failure the directory is left in place
// and false is returned so callers skip that target rather than silently
// deleting data.
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return true;
}
const rel = path.relative(homeDir, dir);
const name =
rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel)
? rel.split(path.sep).join("-")
: path.basename(dir);
const stamp = backupStamp();
const backupRoot = path.join(homeDir, ".dws", "skill-backups");
let targetRoot = path.join(backupRoot, stamp);
let target = path.join(targetRoot, name);
for (let i = 1; fs.existsSync(target); i++) {
if (i > 1000) {
console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`);
return false;
}
targetRoot = path.join(backupRoot, `${stamp}-${i}`);
target = path.join(targetRoot, name);
}
try {
fs.mkdirSync(targetRoot, { recursive: true });
renameFn(dir, target);
} catch (err) {
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
return false;
}
if (backups) {
backups.push({ original: dir, backup: target });
}
console.log(` × 已备份并移除 ${dir} → ${target}`);
return true;
}
function findBinary(root) {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
@@ -117,9 +170,86 @@ function copyChildren(srcDir, destDir) {
}
}
// publishCacheAtomically prepares a complete sibling tree before replacing a
// cache. If copying or publishing fails, the previous cache stays available.
// copyFn is injectable so the failure contract can be tested without relying
// on platform-specific permission behavior.
function publishCacheAtomically(sourceDir, cacheDir, copyFn = copyChildren) {
const cacheParent = path.dirname(cacheDir);
const cacheName = path.basename(cacheDir);
fs.mkdirSync(cacheParent, { recursive: true });
const stagedDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.tmp-`));
let rollbackDir = "";
let published = false;
try {
copyFn(sourceDir, stagedDir);
if (fs.existsSync(cacheDir)) {
rollbackDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.old-`));
fs.rmSync(rollbackDir, { recursive: true, force: true });
fs.renameSync(cacheDir, rollbackDir);
}
try {
fs.renameSync(stagedDir, cacheDir);
published = true;
} catch (publishErr) {
if (rollbackDir) {
try {
fs.renameSync(rollbackDir, cacheDir);
rollbackDir = "";
} catch (restoreErr) {
throw new Error(
`failed to publish cache ${cacheDir}: ${publishErr.message}; ` +
`failed to restore previous cache from ${rollbackDir}: ${restoreErr.message}`,
);
}
}
throw publishErr;
}
if (rollbackDir) {
try {
fs.rmSync(rollbackDir, { recursive: true, force: true });
} catch (cleanupErr) {
console.warn(
`⚠️ New cache is active, but old cache cleanup failed at ${rollbackDir}: ${cleanupErr.message}`,
);
}
rollbackDir = "";
}
} finally {
if (!published) {
fs.rmSync(stagedDir, { recursive: true, force: true });
}
}
}
function installSkillsToHomes(skillRoot) {
const homeDir = os.homedir();
const managedNames = readManagedSkillNames(homeDir);
let installed = 0;
let attempted = 0;
let failed = 0;
const installToBase = (baseDir) => {
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
}
try {
publishManagedMonoSkillSetAtomically(homeDir, skillRoot, baseDir, victims);
} catch (err) {
console.warn(`⚠️ 跳过 ${baseDir}(mono 集合发布失败,已回滚): ${err.message}`);
return false;
}
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
const baseDir = path.join(homeDir, agentDir);
@@ -127,37 +257,434 @@ function installSkillsToHomes(skillRoot) {
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
const destDir = path.join(baseDir, "dws");
fs.rmSync(destDir, { recursive: true, force: true });
copyChildren(skillRoot, destDir);
installed += 1;
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
if (installed === 0) {
copyChildren(skillRoot, path.join(homeDir, ".agents", "skills", "dws"));
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
}
}
if (installed === 0) {
throw new Error("未安装任何 mono Skill:所有检测到的 Agent 目标均失败");
}
if (failed > 0) {
throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`);
}
fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true });
}
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
// bundle carries at least one product skill (a subdir with SKILL.md). An
// empty or corrupt multi/ tree must never select the multi branch nor refresh
// the multi cache — installing it would wipe existing skills and lay down
// nothing.
function multiTreeHasSkills(dir) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return false;
}
return fs
.readdirSync(dir, { withFileTypes: true })
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
}
const MANAGED_SKILL_DIGEST_SCOPE = "skill-directory-v1";
// Frozen exact names shipped before centralized ownership metadata. Retired
// names stay here so old installs can be migrated without treating every
// dingtalk-* directory as DWS-owned.
const LEGACY_OFFICIAL_MULTI_SKILLS = new Set([
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
"dingtalk-todo", "dingtalk-wiki", "dws-shared",
]);
function skillStateDir(homeDir) {
return (process.env.DWS_CONFIG_DIR || "").trim() || path.join(homeDir, ".dws");
}
function readManagedSkillNames(homeDir) {
try {
const state = JSON.parse(fs.readFileSync(path.join(skillStateDir(homeDir), "skills-state.json"), "utf8"));
return new Set((state.managed_skills || []).map((record) => record.name).filter(Boolean));
} catch (_) {
return new Set();
}
}
function isManagedMultiSkillDir(dir, managedNames) {
const name = path.basename(dir);
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
}
function skillDirectoryDigest(dir) {
const files = [];
const visit = (current, prefix) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
visit(full, rel);
} else {
files.push({ rel, full });
}
}
};
visit(dir, "");
files.sort((a, b) => Buffer.from(a.rel).compare(Buffer.from(b.rel)));
const hash = crypto.createHash("sha256");
for (const file of files) {
hash.update(file.rel, "utf8");
hash.update(Buffer.from([0]));
hash.update(fs.readFileSync(file.full));
hash.update(Buffer.from([0]));
}
return `sha256:${hash.digest("hex")}`;
}
// Publish a complete multi-skill set as one transaction. The entire new set
// is staged before any Agent-visible directory moves. If a later backup or
// publish fails, every partial publication is removed and all old directories
// are restored from their exact backup paths.
function publishManagedMultiSkillSetAtomically(
homeDir,
multiRoot,
baseDir,
skills,
victims,
options = {},
) {
const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-"));
const staged = [];
const backups = [];
const published = [];
const restore = () => {
const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) {
try {
removeFn(published[i]);
} catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
}
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
const item = backups[i];
try {
fs.mkdirSync(path.dirname(item.original), { recursive: true });
renameFn(item.backup, item.original);
} catch (err) {
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(restoreErrors.join("; "));
}
};
try {
for (const name of skills) {
const stagedDir = path.join(stageRoot, name);
copyFn(path.join(multiRoot, name), stagedDir);
staged.push({ staged: stagedDir, dest: path.join(baseDir, name) });
}
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
for (const item of staged) {
renameFn(item.staged, item.dest);
published.push(item.dest);
}
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
removeFn(stageRoot);
}
}
// Publish mono plus every mutually-exclusive managed multi victim as one
// transaction. The complete dws/ tree is staged before any live directory is
// moved; a later backup or publish failure restores the exact previous set.
function publishManagedMonoSkillSetAtomically(
homeDir,
monoRoot,
baseDir,
victims,
options = {},
) {
const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-"));
const stagedDir = path.join(stageRoot, "dws");
const destDir = path.join(baseDir, "dws");
const backups = [];
const published = [];
const restore = () => {
const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) {
try {
removeFn(published[i]);
} catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
}
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
const item = backups[i];
try {
fs.mkdirSync(path.dirname(item.original), { recursive: true });
renameFn(item.backup, item.original);
} catch (err) {
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(restoreErrors.join("; "));
}
};
try {
copyFn(monoRoot, stagedDir);
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
published.push(destDir);
renameFn(stagedDir, destDir);
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
removeFn(stageRoot);
}
}
function writeSkillsState(homeDir, multiRoot, skills) {
const version = process.env.npm_package_version || process.env.DWS_PACKAGE_VERSION || "unknown";
const managedSkills = [...skills].sort().map((name) => ({
name,
version,
source: "npm-postinstall",
digest: skillDirectoryDigest(path.join(multiRoot, name)),
digest_scope: MANAGED_SKILL_DIGEST_SCOPE,
}));
const state = {
version,
official_skills: [...skills].sort(),
updated_skills: [...skills].sort(),
managed_skills: managedSkills,
updated_at: new Date().toISOString(),
};
const stateDir = skillStateDir(homeDir);
fs.mkdirSync(stateDir, { recursive: true });
const stage = fs.mkdtempSync(path.join(stateDir, ".skills-state.tmp-"));
const stagedFile = path.join(stage, "skills-state.json");
const statePath = path.join(stateDir, "skills-state.json");
const rollbackPath = path.join(stage, "skills-state.previous.json");
let movedPrevious = false;
let preserveRecovery = false;
try {
fs.writeFileSync(stagedFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
if (fs.existsSync(statePath)) {
fs.renameSync(statePath, rollbackPath);
movedPrevious = true;
}
try {
fs.renameSync(stagedFile, statePath);
} catch (err) {
if (movedPrevious && !fs.existsSync(statePath)) {
try {
fs.renameSync(rollbackPath, statePath);
movedPrevious = false;
} catch (restoreErr) {
preserveRecovery = true;
throw new Error(
`publish skills state failed: ${err.message}; restore also failed: ${restoreErr.message}; previous state retained at ${rollbackPath}`,
);
}
}
throw err;
}
} finally {
if (!preserveRecovery) {
fs.rmSync(stage, { recursive: true, force: true });
}
}
}
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
// every product skill becomes a sibling directory of the agent home. Mutual
// exclusion: the mono leftover (dws/) and stale, proven DWS-managed skills not
// present in the new bundle are removed first.
function installMultiSkillsToHomes(multiRoot) {
const homeDir = os.homedir();
const skills = fs
.readdirSync(multiRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
.map((e) => e.name);
if (skills.length === 0) {
throw new Error(`no product skills found under ${multiRoot}`);
}
const skillSet = new Set(skills);
const managedNames = readManagedSkillNames(homeDir);
let installed = 0;
let attempted = 0;
let failed = 0;
const installToBase = (baseDir) => {
fs.mkdirSync(baseDir, { recursive: true });
const victims = [path.join(baseDir, "dws")];
// Mutual exclusion: include the mono leftover and stale managed skills in
// the same transaction as every replaced bundled skill.
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (
entry.isDirectory() &&
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
!skillSet.has(entry.name)
) {
victims.push(path.join(baseDir, entry.name));
}
}
for (const name of skills) {
victims.push(path.join(baseDir, name));
}
try {
publishManagedMultiSkillSetAtomically(homeDir, multiRoot, baseDir, skills, victims);
} catch (err) {
console.warn(`⚠️ 跳过 ${baseDir}(multi 集合发布失败,已回滚): ${err.message}`);
return false;
}
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
}
}
if (installed === 0) {
throw new Error("未安装任何 multi Skill:所有检测到的 Agent 目标均失败");
}
if (failed > 0) {
throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`);
}
writeSkillsState(homeDir, multiRoot, skills);
}
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
// wins; multi is the default. The --skill-mode flag accepts both the space
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
function resolveSkillMode() {
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
if (raw === "mono" || raw === "multi") {
return raw;
}
if (raw !== "") {
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
}
let fromFlag;
const flagIndex = process.argv.indexOf("--skill-mode");
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
fromFlag = process.argv[flagIndex + 1];
} else {
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
if (equalsArg) {
fromFlag = equalsArg.slice("--skill-mode=".length);
}
}
if (fromFlag !== undefined) {
const mode = fromFlag.trim().toLowerCase();
if (mode === "mono" || mode === "multi") {
return mode;
}
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
}
return "multi";
}
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
// can fall back to a user-local cache when --source is not provided. mono is
// already installed into agent homes by installSkillsToHomes; the cache is
// purely a source-of-truth for the setup command.
// can fall back to a user-local cache when --source is not provided. A cache
// is only refreshed when the new bundle actually carries that tree — an
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
// good cache.
function cacheUserSkills(extractedSkillsRoot) {
const cacheBase = path.join(os.homedir(), ".dws", "skills");
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
? path.join(extractedSkillsRoot, "mono")
: extractedSkillsRoot;
const monoCache = path.join(cacheBase, "mono");
fs.rmSync(monoCache, { recursive: true, force: true });
copyChildren(monoSource, monoCache);
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
const monoCache = path.join(cacheBase, "mono");
publishCacheAtomically(monoSource, monoCache);
}
const multiSource = path.join(extractedSkillsRoot, "multi");
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
if (multiTreeHasSkills(multiSource)) {
const multiCache = path.join(cacheBase, "multi");
fs.rmSync(multiCache, { recursive: true, force: true });
copyChildren(multiSource, multiCache);
publishCacheAtomically(multiSource, multiCache);
}
}
@@ -191,8 +718,34 @@ function main() {
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
? path.join(skillsStaging, "mono")
: skillsStaging;
installSkillsToHomes(monoRoot);
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
// multi-only zip monoRoot would degrade to the staging root and copy the
// whole bundle (multi/ included) into a dws/ directory — skip instead.
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
const multiRoot = path.join(skillsStaging, "multi");
const skillMode = resolveSkillMode();
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
console.log(`Skill mode: multi — installing per-product skills`);
installMultiSkillsToHomes(multiRoot);
} else {
if (skillMode === "multi") {
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
}
if (monoHasSkill) {
installSkillsToHomes(monoRoot);
} else {
console.log("mono skill tree not found in bundle; skipping skill install.");
}
}
cacheUserSkills(skillsStaging);
}
main();
if (require.main === module) {
main();
}
module.exports = {
publishCacheAtomically,
publishManagedMonoSkillSetAtomically,
publishManagedMultiSkillSetAtomically,
};
+14 -8
View File
@@ -48,8 +48,12 @@ It then runs:
--fast-path "$PR_BASE_SHA" HEAD
```
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
its policy dependencies in that merge tree are byte-for-byte the current base
The exact fast path remains limited to historic one-file maintenance. A
release-seal PR uses `--content-only`, which permits the generated
`CHANGELOG.md` change together with archival moves from `.changes/` to
`.changes/released/`; it receives the normal scoped admission instead of this
fast path. Ordinary PRs must not modify `CHANGELOG.md`; they add a standalone
release fragment instead. The validator and its policy dependencies in that merge tree are byte-for-byte the current base
versions. Validation targets the synthetic merge tree, not the feature-branch
tree, so a stale branch cannot supply an older validator or combine with newer
base notes into an invalid final CHANGELOG.
@@ -79,10 +83,12 @@ to the complete main admission suite. A source change can therefore never
inherit the CHANGELOG-only result.
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
content contract in `Policy` with `--content-only`. That mode permits the
second file but still rejects invalid dates or versions, missing bullets,
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
Adding a second file therefore cannot bypass CHANGELOG validation.
content contract in `Policy` with `--content-only`. That mode accepts only
fragment archival moves (`.changes/<name>.md` to
`.changes/released/<version>/<name>.md`) alongside the changelog; source and
documentation changes are rejected. It still rejects invalid dates or
versions, missing bullets, placeholder `TODO`/`TBD`, unmanaged-section
changes, and unsafe tree modes.
## Risk tiers and downstream boundaries
@@ -184,11 +190,11 @@ Schema,并让 candidate 对两份历史 contract 独立执行检查;它只
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
放宽其他 Schema 历史字段。
For an exact CHANGELOG-only branch:
For a release-seal branch that archives rendered fragments:
```sh
base_ref=$(git merge-base HEAD origin/main)
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
./scripts/policy/check-changelog-pr.sh --content-only "$base_ref" HEAD
```
`make coverage-gate` is an enforcement step, not a profile generator. For a
+312
View File
@@ -0,0 +1,312 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="DWS Drive Shortcut 与 lark-cli 的业务能力、真实数据 E2E 证据和平台边界分析。">
<title>Drive Shortcut 能力全景|业务评审版</title>
<style>
:root {
color-scheme: light;
--paper: #f4f6f2; --surface: #fffefa; --ink: #17251f; --muted: #66746d;
--line: #dce2dc; --forest: #154f3d; --green: #17765a; --mint: #dff4e8;
--blue: #265f86; --blue-soft: #e7f1f7; --amber: #8c5a09; --amber-soft: #fff2cf;
--red: #a43b32; --red-soft: #fde9e5; --shadow: 0 14px 40px rgba(28, 48, 38, .08);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; color: var(--ink); background: var(--paper); font: 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
a { color: inherit; text-decoration: none; }
code { padding: .12rem .38rem; border: 1px solid #d6e0da; border-radius: 6px; color: #174f3e; background: #f1f7f3; font: 600 .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; }
.wrap { width: min(1180px, calc(100% - 40px)); margin: auto; }
.hero { position: relative; overflow: hidden; padding: 64px 0 52px; color: #f7fff9; background: linear-gradient(125deg, #102b22 0%, #154c3c 57%, #1c6b54 100%); }
.hero::after { position: absolute; inset: -180px -100px auto auto; width: 540px; height: 540px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; box-shadow: 0 0 0 76px rgba(255,255,255,.035), 0 0 0 152px rgba(255,255,255,.025); content: ""; }
.hero-grid { position: relative; z-index: 1; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); gap: 32px; align-items: end; }
.eyebrow, .section-kicker { margin: 0 0 9px; color: #9fd6bd; font-size: 11px; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(40px, 6vw, 68px); line-height: 1.03; letter-spacing: -.045em; }
.subtitle { max-width: 760px; margin: 19px 0 0; color: #d3e9de; font-size: 17px; }
.meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 21px; }
.meta-pill { padding: 6px 10px; border: 1px solid rgba(255,255,255,.18); border-radius: 999px; color: #d5e9df; background: rgba(255,255,255,.07); font-size: 11px; font-weight: 750; }
.meta-pill.good { color: #bff2d5; border-color: rgba(139,232,179,.38); }
.hero-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.hero-stat { padding: 17px 16px; border: 1px solid rgba(255,255,255,.17); border-radius: 15px; background: rgba(255,255,255,.075); backdrop-filter: blur(8px); }
.hero-stat strong { display: block; font-size: 30px; line-height: 1; }
.hero-stat span { display: block; margin-top: 7px; color: #cce2d7; font-size: 11px; }
.nav { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--line); background: rgba(255,254,250,.94); backdrop-filter: blur(12px); }
.nav .wrap { display: flex; overflow-x: auto; }
.nav a { flex: 0 0 auto; padding: 14px 15px; color: #5b6c64; font-size: 12px; font-weight: 800; }
.nav a:hover { color: var(--forest); background: #eaf3ee; }
main { padding: 38px 0 74px; }
section { margin-top: 50px; scroll-margin-top: 72px; }
section:first-child { margin-top: 0; }
.section-head { display: flex; justify-content: space-between; gap: 28px; align-items: end; margin-bottom: 19px; }
h2 { margin: 0; font-size: clamp(25px, 3.2vw, 36px); line-height: 1.16; letter-spacing: -.025em; }
h3 { margin: 0 0 7px; font-size: 18px; }
.section-desc { max-width: 660px; margin: 0; color: var(--muted); font-size: 13px; }
.callout { padding: 19px 21px; border: 1px solid #bdd8cb; border-left: 4px solid var(--green); border-radius: 13px; background: #ecf7f1; box-shadow: 0 6px 20px rgba(28,48,38,.04); }
.callout strong { color: #13513d; }
.callout.warn { border-color: #ead29a; border-left-color: #b67508; background: #fff8e7; }
.callout.warn strong { color: #784b00; }
.callout.danger { border-color: #e9b7b1; border-left-color: var(--red); background: var(--red-soft); }
.score-grid, .domain-grid, .evidence-grid, .review-grid { display: grid; gap: 13px; }
.score-grid { grid-template-columns: repeat(4, 1fr); margin-top: 16px; }
.score, .domain-card, .evidence-card, .review-card { border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
.score { padding: 19px; }
.score strong { display: block; color: var(--forest); font-size: 29px; line-height: 1; }
.score span { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
.domain-grid { grid-template-columns: repeat(4, 1fr); }
.domain-card { position: relative; padding: 21px; overflow: hidden; }
.domain-card .number { position: absolute; top: 12px; right: 17px; color: #d5e8de; font: 800 42px/1 ui-monospace, monospace; }
.domain-card p { min-height: 64px; margin: 8px 0 12px; color: var(--muted); font-size: 13px; }
.domain-card small { color: var(--green); font-weight: 800; }
.compare { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
.compare-top { display: grid; grid-template-columns: repeat(3, 1fr); }
.compare-column { padding: 21px; border-right: 1px solid var(--line); }
.compare-column:last-child { border-right: 0; }
.compare-column p, .compare-column li { color: var(--muted); font-size: 13px; }
.compare-column ul { margin: 9px 0 0; padding-left: 18px; }
.compare-column.covered { border-top: 5px solid var(--green); }
.compare-column.partial { border-top: 5px solid #c78b22; }
.compare-column.gap { border-top: 5px solid var(--red); }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: #617069; background: #f7f8f5; font-size: 11px; font-weight: 900; letter-spacing: .03em; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: #f8fbf8; }
.verdict, .badge { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 900; white-space: nowrap; }
.v-covered, .badge.read { color: #116045; background: var(--mint); }
.v-ahead, .badge.smart { color: #20577c; background: var(--blue-soft); }
.v-partial, .badge.write { color: #7b510a; background: var(--amber-soft); }
.v-gap, .badge.high { color: #8f3028; background: var(--red-soft); }
.truth-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 14px; }
.truth-card { padding: 22px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface); box-shadow: var(--shadow); }
.truth-step { display: grid; grid-template-columns: 30px 1fr; gap: 11px; margin-top: 13px; }
.truth-step b { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 50%; color: #fff; background: var(--forest); font-size: 12px; }
.truth-step strong, .truth-step span { display: block; }
.truth-step span { color: var(--muted); font-size: 12px; }
.toolbar { display: grid; grid-template-columns: minmax(260px, 1fr) 180px 180px auto; gap: 10px; align-items: center; margin: 18px 0; padding: 13px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
input, select { width: 100%; min-height: 42px; padding: 9px 11px; border: 1px solid #ccd7d0; border-radius: 9px; color: var(--ink); background: #fff; font: inherit; }
input:focus, select:focus { outline: 3px solid rgba(23,118,90,.13); border-color: var(--green); }
.result-count { color: var(--muted); font-size: 12px; text-align: right; white-space: nowrap; }
.catalog { overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: var(--shadow); }
.shortcut-row { display: grid; grid-template-columns: 215px minmax(0, 1fr) 200px; gap: 16px; align-items: center; padding: 14px 17px; border-bottom: 1px solid var(--line); }
.shortcut-row:last-child { border-bottom: 0; }
.shortcut-row:hover { background: #f8fbf8; }
.command code { font-size: 12px; }
.row-main p { margin: 0; font-size: 13px; }
.row-main small { color: var(--muted); }
.badges { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 5px; }
.hidden-row { display: none; }
.evidence-grid { grid-template-columns: repeat(4, 1fr); }
.evidence-card { padding: 19px; }
.evidence-card strong { display: block; color: var(--forest); font-size: 23px; }
.evidence-card p { margin: 7px 0 0; color: var(--muted); font-size: 12px; }
.timeline { margin-top: 15px; border-left: 2px solid #bdd7ca; }
.event { position: relative; padding: 0 0 17px 22px; }
.event::before { position: absolute; left: -7px; top: 5px; width: 12px; height: 12px; border: 3px solid var(--paper); border-radius: 50%; background: var(--green); content: ""; }
.event b { display: block; }
.event span { color: var(--muted); font-size: 12px; }
.review-grid { grid-template-columns: repeat(3, 1fr); }
.review-card { position: relative; padding: 20px; }
.review-card .review-num { color: #b8d1c4; font: 800 12px/1 ui-monospace, monospace; letter-spacing: .1em; }
.review-card p { margin: 7px 0 0; color: var(--muted); font-size: 13px; }
footer { margin-top: 52px; padding: 23px 0; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
@media (max-width: 900px) { .hero-grid, .truth-grid { grid-template-columns: 1fr; } .score-grid, .domain-grid, .evidence-grid { grid-template-columns: repeat(2, 1fr); } .review-grid { grid-template-columns: 1fr 1fr; } .shortcut-row { grid-template-columns: 180px 1fr; } .badges { grid-column: 1 / -1; justify-content: flex-start; } }
@media (max-width: 620px) { .wrap { width: min(100% - 24px, 1180px); } .hero { padding: 44px 0 38px; } .hero-stats, .score-grid, .domain-grid, .evidence-grid, .review-grid, .compare-top { grid-template-columns: 1fr; } .compare-column { border-right: 0; border-bottom: 1px solid var(--line); } .toolbar { grid-template-columns: 1fr; } .result-count { text-align: left; } .shortcut-row { grid-template-columns: 1fr; } }
@media print { body { background: #fff; } .hero { color: var(--ink); background: #fff; border-bottom: 2px solid var(--ink); } .subtitle, .meta-pill, .hero-stat span { color: #425249; } .hero-stat { border-color: #aebbb3; } .nav, .toolbar { display: none; } .score, .domain-card, .compare, .truth-card, .catalog, .evidence-card, .review-card { box-shadow: none; break-inside: avoid; } }
</style>
</head>
<body>
<header class="hero">
<div class="wrap hero-grid">
<div>
<p class="eyebrow">Business Review · Drive</p>
<h1>Drive Shortcut<br>能力全景</h1>
<p class="subtitle">从 lark-cli 对齐出发,但不止于命令名:逐项审查输入、校验、多步编排、失败语义、真实字节和平台边界。</p>
<div class="meta-row">
<span class="meta-pill good">真实账号 E2E 已执行</span>
<span class="meta-pill">28 个公开入口</span>
<span class="meta-pill">统一 Result / Pagination</span>
<span class="meta-pill">报告已移除 PII / 凭证 / 业务正文</span>
</div>
</div>
<div class="hero-stats" aria-label="关键统计">
<div class="hero-stat"><strong>38</strong><span>lark-cli Drive 逐项审查</span></div>
<div class="hero-stat"><strong>26</strong><span>已覆盖或跨产品路由</span></div>
<div class="hero-stat"><strong>7</strong><span>部分对齐,边界已公开</span></div>
<div class="hero-stat"><strong>5</strong><span>客观不可对齐能力</span></div>
</div>
</div>
</header>
<nav class="nav"><div class="wrap"><a href="#overview">全景</a><a href="#compare">Lark 对齐</a><a href="#ahead">超越项</a><a href="#truth">真实语义</a><a href="#catalog">完整目录</a><a href="#e2e">E2E</a><a href="#review">评审</a></div></nav>
<main class="wrap">
<section id="overview">
<div class="section-head"><div><p class="section-kicker">Executive summary</p><h2>28 个公开入口,覆盖文件完整生命周期</h2></div><p class="section-desc">另有 <code>+publish-set</code> 已实现契约和读回逻辑,但真实普通文件与在线文档均被服务端拒绝,因此保持 unavailable,不进入 Agent 公开目录。</p></div>
<div class="callout"><strong>结论:</strong>Drive 已从 9 个偏原子入口扩展为 28 个可发现 Shortcut。它不仅补齐 Lark 的核心文件、版本和状态任务,还通过严格响应合同、真实落盘、写后读回、回收恢复和个人收藏形成更可审计的钉盘工作流。</div>
<div class="score-grid">
<article class="score"><strong>29</strong><span>已审查注册项(含 1 unavailable)</span></article>
<article class="score"><strong>25</strong><span>公开主能力 / 语义适配</span></article>
<article class="score"><strong>3</strong><span>公开兼容入口</span></article>
<article class="score"><strong>3</strong><span>高风险写入口,均需确认</span></article>
</div>
</section>
<section>
<div class="section-head"><div><p class="section-kicker">Capability map</p><h2>四个业务域</h2></div><p class="section-desc">目录按用户任务组织;兼容命令不重复计为新增能力。</p></div>
<div class="domain-grid">
<article class="domain-card"><span class="number">09</span><h3>发现与检查</h3><p>严格目录分页、搜索、最近访问,以及元数据、统计和封面聚合检查。</p><small>+list · +search · +recent · +inspect</small></article>
<article class="domain-card"><span class="number">09</span><h3>文件生命周期</h3><p>创建目录、上传下载、快捷方式、在线对象复制、移动重命名、删除与恢复。</p><small>+upload · +download · +rename · +recycle-restore</small></article>
<article class="domain-card"><span class="number">06</span><h3>个人与公开状态</h3><p>回收站清单、收藏闭环和互联网公开状态的独立安全域。</p><small>+star-list · +star-add · +publish-get</small></article>
<article class="domain-card"><span class="number">04</span><h3>历史版本</h3><p>版本列表、精确定位、真实字节下载与高风险回滚读回。</p><small>+version-history · +version-download · +version-revert</small></article>
</div>
</section>
<section id="compare">
<div class="section-head"><div><p class="section-kicker">Lark alignment</p><h2>对齐业务语义,不追求同名率</h2></div><p class="section-desc">38 项逐项核对。评论、导入导出和成员权限在 DWS 由更成熟的 Doc 或原子权限入口承接,不在 Drive 再复制一套。</p></div>
<div class="compare">
<div class="compare-top">
<article class="compare-column covered"><h3>26 · 已覆盖 / 路由</h3><p>上传下载、目录与快捷方式、版本、移动删除、状态、搜索、评论、导入导出和成员任务均有真实入口。</p></article>
<article class="compare-column partial"><h3>7 · 部分对齐</h3><p>预览、resolve/reaction、push/pull、权限申请与 setting 受对象模型或接口粒度约束,明确保留有限语义。</p></article>
<article class="compare-column gap"><h3>5 · 客观缺口</h3><p>删除评论恢复、普通文件版本删除、安全标签读写、可靠双向目录同步缺少必要下层接口。</p></article>
</div>
<div class="table-wrap"><table><thead><tr><th>Lark 任务组</th><th>DWS 主路径</th><th>结论</th><th>关键差异与交付决定</th></tr></thead><tbody>
<tr><td>upload / folder / shortcut / download</td><td><code>drive +upload</code> 等</td><td><span class="verdict v-ahead">增强</span></td><td>工作目录边界、OSS PUT、严格 commit、no-clobber、原子落盘、非零字节和读回验证。</td></tr>
<tr><td>preview / cover</td><td><code>drive +cover</code></td><td><span class="verdict v-partial">部分</span></td><td>封面/缩略图可读;没有等价的服务端多格式预览转换,不扩大宣称。</td></tr>
<tr><td>comments / replies</td><td><code>doc +comment-*</code> / <code>doc +review</code></td><td><span class="verdict v-covered">路由</span></td><td>评论归在线文档协作域;独立 resolve、reaction identity 与删除后恢复仍受接口限制。</td></tr>
<tr><td>export / import / task result</td><td><code>doc +export</code> / <code>doc +import</code></td><td><span class="verdict v-ahead">增强</span></td><td>提交、轮询、恢复、安全下载形成类型化闭环,不保留泛化下划线命令。</td></tr>
<tr><td>version history / get / revert</td><td><code>drive +version-*</code></td><td><span class="verdict v-ahead">增强</span></td><td>严格分页、精确版本、历史字节落盘、回滚前预检与终态读回;历史版本删除无接口。</td></tr>
<tr><td>status / inspect</td><td><code>drive +inspect</code></td><td><span class="verdict v-ahead">超越</span></td><td>元数据为必达结果,统计、公开状态和封面按需 fan-out;可选失败为 partial_success。</td></tr>
<tr><td>push / pull / sync</td><td><code>+upload</code> / <code>+download</code> 单文件</td><td><span class="verdict v-partial">部分</span></td><td>不在缺少稳定 hash、rename/delete journal 和冲突向量时制造危险目录同步。</td></tr>
<tr><td>member / permission</td><td><code>doc +access-*</code> / <code>drive permission</code></td><td><span class="verdict v-covered">路由</span></td><td>协作者权限与互联网公开是两个安全域;申请权限需真实上下文,未伪装为通用 Shortcut。</td></tr>
<tr><td>secure labels</td><td>无等价</td><td><span class="verdict v-gap">缺口</span></td><td>当前 DWS/钉钉下层没有 Drive 安全标签目录和写入接口,不能用普通权限代替。</td></tr>
<tr><td>search</td><td><code>drive +search</code> / <code>doc +search</code></td><td><span class="verdict v-ahead">增强</span></td><td>文件与在线文档按域路由;文件搜索严格验证数组、过滤和分页。</td></tr>
</tbody></table></div>
</div>
<div class="callout warn" style="margin-top:14px"><strong>普通文件 copy 边界:</strong>钉钉现有复制接口对普通文件产生 <code>.dlink</code>,不是字节独立副本。因此 <code>+copy</code> 只接受在线对象;普通文件快捷入口用 <code>+create-shortcut</code>,独立副本使用 <code>+download</code> 后 <code>+upload</code>。</div>
</section>
<section id="ahead">
<div class="section-head"><div><p class="section-kicker">Beyond parity</p><h2>DWS 可主推的八个差异化点</h2></div><p class="section-desc">价值来自正确性与完整闭环,而不是额外注册同义命令。</p></div>
<div class="domain-grid">
<article class="domain-card"><h3>严格目录语义</h3><p><code>+list</code> / <code>+recent</code> 只有服务端明确返回数组时才接受空集合。</p><small>缺字段 ≠ 空目录</small></article>
<article class="domain-card"><h3>聚合检查</h3><p><code>+inspect</code> 一次汇总身份、统计、公开状态和封面,并保留局部失败。</p><small>partial_success 可审计</small></article>
<article class="domain-card"><h3>真实文件传输</h3><p>上传执行完整事务;下载验证受控路径、覆盖策略、原子发布和字节。</p><small>不是只返回临时 URL</small></article>
<article class="domain-card"><h3>回收恢复闭环</h3><p>从 <code>recycleItemId</code> 恢复后读取真实节点,证明资源确实回到可访问状态。</p><small>恢复后读回</small></article>
<article class="domain-card"><h3>个人收藏闭环</h3><p>收藏、列表、取消收藏覆盖完整用户偏好过程,并保留分页。</p><small>add → list → remove</small></article>
<article class="domain-card"><h3>版本真实字节</h3><p>除元数据外可下载任意已知历史版本,并用本地字节核验回滚结果。</p><small>version-download</small></article>
<article class="domain-card"><h3>重命名终态</h3><p>处理服务端扩展名规则,再读取节点确认最终名称,避免重复扩展名。</p><small>write → read-back</small></article>
<article class="domain-card"><h3>公开域诚实降级</h3><p>查询和关闭可验证;开启在 eligible 节点闭环完成前保持 unavailable。</p><small>不把 notSupported 当成功</small></article>
</div>
</section>
<section id="truth">
<div class="section-head"><div><p class="section-kicker">Truthful execution</p><h2>空数组不再是“看起来成功”</h2></div><p class="section-desc">合法业务空集合可以成功,但必须先证明响应结构、元素类型和分页语义成立。内部错误、缺字段与坏投影必须失败。</p></div>
<div class="truth-grid">
<article class="truth-card"><h3>四层成功证据</h3>
<div class="truth-step"><b>1</b><div><strong>传输成功</strong><span>进程成功,MCP / HTTP 没有显式错误。</span></div></div>
<div class="truth-step"><b>2</b><div><strong>响应合同</strong><span>对象、数组、success 标志和元素类型与命令声明一致。</span></div></div>
<div class="truth-step"><b>3</b><div><strong>业务终态</strong><span>写命令必须获得新 ID、终态证据或后续元数据读回。</span></div></div>
<div class="truth-step"><b>4</b><div><strong>产物校验</strong><span>下载必须落盘、非零字节;关键链路比较大小和 SHA-256。</span></div></div>
</article>
<article class="truth-card"><h3>明确失败的情况</h3>
<ul><li>空响应、缺少预期集合字段或集合类型错误。</li><li>集合存在坏元素,不能投影时静默丢弃。</li><li><code>success=false</code>、写响应没有 ID 或读回不一致。</li><li>inspect 的可选分支失败却返回整体 success。</li><li>下载得到空文件、越界路径或覆盖既有文件。</li><li>普通文件 copy 返回快捷链接却声称独立副本。</li></ul>
</article>
</div>
</section>
<section id="catalog">
<div class="section-head"><div><p class="section-kicker">Full catalog</p><h2>28 个公开 Shortcut 完整目录</h2></div><p class="section-desc">16 个只读、9 个普通写、3 个高风险写;3 个历史入口保留兼容但不作为新 Agent 主路径。</p></div>
<div class="toolbar"><input id="q" type="search" placeholder="搜索命令或用途,例如 版本、回收、+inspect…" aria-label="搜索 Shortcut"><select id="domain"><option value="all">全部业务域</option><option value="discover">发现与检查</option><option value="lifecycle">文件生命周期</option><option value="personal">个人与公开</option><option value="version">历史版本</option></select><select id="risk"><option value="all">全部风险</option><option value="read">只读</option><option value="write">普通写</option><option value="high">高风险写</option></select><span id="result-count" class="result-count">显示 28 / 28</span></div>
<div class="catalog">
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +list</code></div><div class="row-main"><p>严格分页列出目录,保留游标,区分显式空目录和畸形响应。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +inspect</code></div><div class="row-main"><p>聚合元数据与可选统计、公开状态、封面;局部失败如实报告。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +info</code></div><div class="row-main"><p>历史元数据兼容入口;新场景优先使用 +inspect。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search</code></div><div class="row-main"><p>按关键词、类型、扩展名、创建人、时间和分页搜索钉盘文件。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +find-file</code></div><div class="row-main"><p>历史文件搜索兼容入口;新场景优先使用 +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +search-docs</code></div><div class="row-main"><p>历史跨域搜索入口;新的在线文档搜索路由 doc +search。</p><small>兼容入口</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +recent</code></div><div class="row-main"><p>读取最近访问或编辑列表,支持创建人筛选并保留分页。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +stats</code></div><div class="row-main"><p>读取访问、编辑、评论、点赞、预览和下载统计。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="discover" data-risk="read"><div class="command"><code>dws drive +cover</code></div><div class="row-main"><p>读取封面或缩略图;不宣称服务端多格式预览。</p><small>发现与检查</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +upload</code></div><div class="row-main"><p>上传凭证、OSS PUT、严格提交和远端元数据读回的一体化事务。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="read"><div class="command"><code>dws drive +download</code></div><div class="row-main"><p>安全落盘、no-clobber、原子发布并验证非零字节。</p><small>文件生命周期</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-folder</code></div><div class="row-main"><p>创建文件夹后要求新 ID,并读回名称验证。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +create-shortcut</code></div><div class="row-main"><p>创建快捷方式并读回,明确区别于独立副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +copy</code></div><div class="row-main"><p>复制在线对象;普通文件预检拒绝,避免把 .dlink 当副本。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +move</code></div><div class="row-main"><p>移动到指定文件夹或知识库位置,语义与 copy/shortcut 消歧。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +rename</code></div><div class="row-main"><p>重命名后读取真实节点,验证最终名称和扩展名。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="high"><div class="command"><code>dws drive +delete</code></div><div class="row-main"><p>将确认过的节点移入回收站,要求 success=true 终态证据。</p><small>文件生命周期</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span></div></article>
<article class="shortcut-row" data-tool data-domain="lifecycle" data-risk="write"><div class="command"><code>dws drive +recycle-restore</code></div><div class="row-main"><p>按回收项 ID 恢复,并读回恢复后的节点。</p><small>文件生命周期</small></div><div class="badges"><span class="badge write">WRITE · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +recycle-list</code></div><div class="row-main"><p>严格分页列出回收项并稳定投影 recycleItemId。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +star-list</code></div><div class="row-main"><p>严格分页列出当前用户收藏并保留游标。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-add</code></div><div class="row-main"><p>以幂等用户偏好语义收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="write"><div class="command"><code>dws drive +star-remove</code></div><div class="row-main"><p>以幂等用户偏好语义取消收藏指定节点。</p><small>个人与公开</small></div><div class="badges"><span class="badge write">WRITE</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="read"><div class="command"><code>dws drive +publish-get</code></div><div class="row-main"><p>只读查询互联网公开状态,不沿用错误的写风险标签。</p><small>个人与公开</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="personal" data-risk="high"><div class="command"><code>dws drive +publish-unset</code></div><div class="row-main"><p>关闭互联网公开并读回验证外链状态。</p><small>个人与公开</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-history</code></div><div class="row-main"><p>严格分页列出普通文件历史版本。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-get</code></div><div class="row-main"><p>按正整数版本号精确匹配,零命中显式失败。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="read"><div class="command"><code>dws drive +version-download</code></div><div class="row-main"><p>预检版本后安全下载历史字节,要求非零产物。</p><small>历史版本</small></div><div class="badges"><span class="badge read">READ</span><span class="badge smart">SMART</span></div></article>
<article class="shortcut-row" data-tool data-domain="version" data-risk="high"><div class="command"><code>dws drive +version-revert</code></div><div class="row-main"><p>验证版本存在后回滚,并读取当前节点终态。</p><small>历史版本</small></div><div class="badges"><span class="badge high">HIGH · CONFIRM</span><span class="badge smart">SMART</span></div></article>
</div>
<div class="callout warn" style="margin-top:14px"><strong>未公开入口:</strong><code>+publish-set</code> 的安全契约和 set→get 读回代码已存在,但真实后端返回 <code>operation.notSupported</code>。在找到 eligible 节点并完成 set→get→unset 闭环前,不进入公开 Agent catalog。</div>
</section>
<section id="e2e">
<div class="section-head"><div><p class="section-kicker">Real-data E2E</p><h2>真实数据验证,不用空结果证明成功</h2></div><p class="section-desc">测试在隔离目录创建临时资源,覆盖读取、写入、下载、版本、收藏、回收和清理。资源 ID、账号、URL、上传凭证、绝对路径和业务正文均未进入报告。</p></div>
<div class="evidence-grid">
<article class="evidence-card"><strong>39,838 B</strong><p>真实文件上传后下载字节数;与源文件 SHA-256 完全一致。</p></article>
<article class="evidence-card"><strong>2 versions</strong><p>覆盖写入生成两个版本;精确查询、历史下载和回滚全部读回。</p></article>
<article class="evidence-card"><strong>4 / 5</strong><p>隔离夹具中搜索命中 4 项、最近列表命中 5 项,证明非空投影链路。</p></article>
<article class="evidence-card"><strong>0 remain</strong><p>测试结束后隔离根目录无残留;临时资源进入回收站并完成本地清理。</p></article>
</div>
<div class="timeline">
<div class="event"><b>创建与发现</b><span>创建两个隔离目录并读回;+list 命中真实节点,由此发现并修复 dentryId 与 32 字符 fileId 混用。</span></div>
<div class="event"><b>上传与下载</b><span>真实 OSS 上传、远端元数据读回、下载、no-clobber 二次路径、大小与 SHA-256 一致性全部通过。</span></div>
<div class="event"><b>检查与个人状态</b><span>+inspect(含 stats / publish / cover)、+stats、+cover、收藏 add→list→remove 通过。</span></div>
<div class="event"><b>版本闭环</b><span>覆盖文件产生两个版本;history/get/download/revert 通过,回滚后最新字节与原始内容一致。</span></div>
<div class="event"><b>复制、移动与命名</b><span>在线文档 copy 通过;普通文件 .dlink 被修正为预检拒绝;move 往返、rename 扩展名规范化通过。</span></div>
<div class="event"><b>删除与恢复</b><span>delete→recycle-list→recycle-restore 通过,真实回收响应字段已按后端形态修正。</span></div>
<div class="event"><b>平台负向证据</b><span>publish-set 对普通文件和在线文档均明确返回不支持,因此保持 unavailable;没有把失败改写成空对象成功。</span></div>
<div class="event"><b>清理</b><span>隔离目录进入回收站,根目录残留计数为零;本地下载产物删除。</span></div>
</div>
</section>
<section id="review">
<div class="section-head"><div><p class="section-kicker">Review prompts</p><h2>建议业务评审重点确认</h2></div><p class="section-desc">这些是需要接受的产品边界,不是被空结果遮蔽的实现问题。</p></div>
<div class="review-grid">
<article class="review-card"><span class="review-num">01</span><h3>是否接受 26 / 7 / 5 结论?</h3><p>按用户任务计覆盖、部分与缺口,不用同名命令数量代替语义保真。</p></article>
<article class="review-card"><span class="review-num">02</span><h3>普通文件 copy 是否足够清晰?</h3><p>服务端无法提供原子独立副本;快捷方式与下载后上传两条替代路径已明确。</p></article>
<article class="review-card"><span class="review-num">03</span><h3>是否拒绝不可靠目录 sync?</h3><p>缺稳定 hash、删除/重命名日志和冲突向量时,不发布可能覆盖数据的双向同步。</p></article>
<article class="review-card"><span class="review-num">04</span><h3>跨产品路由是否合理?</h3><p>评论、导入导出和协作者权限优先复用 Doc 成熟入口,不在 Drive 制造同义表面。</p></article>
<article class="review-card"><span class="review-num">05</span><h3>publish-set 是否继续 unavailable?</h3><p>建议维持,直到真实 eligible 节点完成开启、查询、关闭的可恢复闭环。</p></article>
<article class="review-card"><span class="review-num">06</span><h3>下一批后端解锁优先级?</h3><p>建议依次评估普通文件原子 copy、同步所需版本信号、安全标签和评论恢复接口。</p></article>
</div>
</section>
</main>
<footer><div class="wrap">依据:DWS 最终 Shortcut catalog / Schema、Drive 实现与测试、真实账号 E2E、lark-cli Drive registrations 与实现。范围仅含 Drive Shortcut 及必要跨产品路由;不包含原子命令总表。所有业务标识、凭证、签名 URL、用户信息和正文均已脱敏。</div></footer>
<script>
const q = document.querySelector('#q');
const domain = document.querySelector('#domain');
const risk = document.querySelector('#risk');
const rows = [...document.querySelectorAll('[data-tool]')];
const count = document.querySelector('#result-count');
function filterTools() {
const needle = q.value.trim().toLocaleLowerCase('zh-CN');
let visible = 0;
rows.forEach((row) => {
const show = (!needle || row.textContent.toLocaleLowerCase('zh-CN').includes(needle)) && (domain.value === 'all' || row.dataset.domain === domain.value) && (risk.value === 'all' || row.dataset.risk === risk.value);
row.classList.toggle('hidden-row', !show);
if (show) visible += 1;
});
count.textContent = `显示 ${visible} / ${rows.length}`;
}
[q, domain, risk].forEach((control) => control.addEventListener('input', filterTools));
</script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
# Drive Shortcut 对齐与超越 Lark CLI
## 目标与判定口径
本轮以 Lark CLI `drive` 的 38 个 shortcut 为对照,但不把“同名命令数量”当完成标准。对齐按用户任务判定:
1. `drive +...` 有更稳定的 Agent 主入口时,提供 Shortcut,并发布 Selection、Safety、Result 与 Pagination。
2. 钉钉已经在其他产品提供更成熟入口时,Skill 明确跨产品路由,不在 Drive 重复实现。
3. 只有原子能力且 Shortcut 不增加校验、编排或投影价值时,保留 Runtime Schema leaf,不制造同义别名。
4. 下层接口不存在或无法满足相同语义时,明确记录 gap;不得用空数组、空对象或只返回任务提交结果伪装完成。
成功判定统一为:进程成功 + 统一结果 `ok=true/outcome=success` + 必要业务字段 + 真实数据读回或本地字节校验。服务端显式返回空数组可以是合法业务空结果;空响应、缺少数组、数组类型错误、坏元素、`success=false`、写入缺少终态证据都必须失败。
## Lark 38 项映射
| Lark Drive shortcut | DWS 路由 | 结论与原因 |
|---|---|---|
| `+upload` | `drive +upload` | 对齐并增强:工作目录边界、OSS PUT、严格 commit、元数据读回。 |
| `+create-folder` | `drive +create-folder` | 对齐并增强:要求新 fileId 和名称读回。 |
| `+create-shortcut` | `drive +create-shortcut` | 对齐并增强:明确 shortcut≠copy,创建后读回。 |
| `+download` | `drive +download` | 对齐并增强:真实落盘、no-clobber、原子发布、非零字节。 |
| `+preview` | `drive +cover`(有限) | 不完全对齐:钉钉当前只提供封面/缩略图读取,没有等价的服务端多格式预览转换接口。 |
| `+cover` | `drive +cover` | 对齐:严格读取封面/缩略图对象。 |
| `+add-comment` | `doc +comment-create` | 用户任务对齐;评论归在线文档协作域,Drive 不复制一套。 |
| `+list-comments` | `doc +comment-list` | 用户任务对齐;Doc 已有类型、状态与分页。 |
| `+batch-query-comments` | `doc +review` / `doc +comment-list` | 超越:可聚合未解决评论与确定性正文上下文;跨文档批量仍由调用方按节点编排。 |
| `+resolve-comment` | `doc +comment-update`(有限) | 部分对齐:DWS 可更新评论,但当前下层未声明独立 resolve 状态接口。 |
| `+restore-comment` | 无等价 | gap:钉钉当前下层未暴露恢复已删除评论的等价能力。 |
| `+add-reply` | `doc +comment-reply` | 对齐。 |
| `+list-replies` | `doc +comment-list` | 用户任务对齐:评论列表返回回复上下文;无独立 Drive reply 目录。 |
| `+update-reply` | `doc +comment-update` | 对齐到评论/回复统一更新语义。 |
| `+delete-reply` | `doc +comment-delete` | 对齐到评论/回复统一删除语义,高风险确认。 |
| `+react-reply` | `doc +comment-reply`(有限) | 部分对齐:支持表情回复;不声称拥有 Lark 的独立 reaction identity。 |
| `+export` | `doc +export` | 用户任务对齐并增强:提交、轮询、安全下载一体化。 |
| `+export-download` | `doc +export` / `doc +export-get` | 超越:常规一体化,`+export-get` 仅作中断恢复。 |
| `+import` | `doc +import` | 用户任务对齐并增强:转换白名单、上传 fallback、轮询终态。 |
| `+version-history` | `drive +version-history` | 对齐并增强:严格空结果与分页。 |
| `+version-get` | `drive +version-get` | 对齐并增强:精确版本号,零命中失败。 |
| `+version-revert` | `drive +version-revert` | 对齐并增强:版本预检、高风险确认、节点读回。 |
| `+version-delete` | 无等价 | gap:钉钉当前普通文件版本接口没有删除历史版本能力。 |
| `+move` | `drive +move` | 对齐;与 copy/shortcut 明确消歧并发布确认。 |
| `+delete` | `drive +delete` | 对齐;移入回收站、高风险确认、终态证据。 |
| `+status` | `drive +inspect` | 超越:远端身份、统计、公开状态和封面按需聚合;不伪装成本地同步状态。 |
| `+push` | `drive +upload`(单文件) | 部分对齐:单文件上传可靠;没有可靠的目录 diff、冲突和远端删除传播语义,因此不提供同名批量 push。 |
| `+pull` | `drive +download`(单文件) | 部分对齐:单文件下载可靠;目录级增量拉取需稳定路径、hash 与冲突策略,当前接口不完整。 |
| `+sync` | 无等价 | gap:在缺少稳定远端内容 hash、rename/delete journal 和冲突版本向量时,双向同步会有数据覆盖风险。 |
| `+task_result` | `doc +export-get` / 导入任务恢复入口 | 用户任务对齐;DWS 按任务所属产品提供类型化恢复入口,不保留 Lark 的下划线泛化命令。 |
| `+apply-permission` | `drive permission apply` raw leaf | 下层能力存在但未提升为 Shortcut:需要真实申请上下文和权限夹具,无法在通用 E2E 中安全创建。 |
| `+member-add` | `doc +access-grant` | 用户任务对齐并增强:解析接收人、批量 ledger、首次写入前停止。 |
| `+member-list` | `drive permission list` / `doc +inspect --include-permissions` | 对齐;常规 Agent 场景优先 Doc 聚合检查。 |
| `+permission-get-setting` | `drive permission list` + `drive +publish-get` | 部分对齐:协作者与互联网公开是两个独立安全域,没有一个与 Lark setting 完全同构的钉钉接口。 |
| `+secure-label-list` | 无等价 | gap:当前 DWS/钉钉下层没有可声明的 Drive 安全标签目录接口。 |
| `+secure-label-update` | 无等价 | gap:没有安全标签写接口,不能用普通权限或公开状态替代。 |
| `+search` | `drive +search` | 对齐并增强:过滤、严格数组和分页;在线文档搜索路由 `doc +search`。 |
| `+inspect` | `drive +inspect` | 对齐并增强:必达元数据 + 可选聚合,部分失败不伪装成功。 |
## DWS 超出 Lark Drive 的可挖掘能力
- `+list`:严格目录分页,而不是把缺字段当空目录。
- `+recent`:最近访问/编辑与创建人筛选。
- `+stats`:阅读、编辑、评论、点赞、预览和下载统计。
- `+recycle-list` / `+recycle-restore`:显式回收项身份与恢复后读回。
- `+star-list` / `+star-add` / `+star-remove`:个人收藏完整闭环。
- `+publish-get` / `+publish-unset`:互联网公开独立安全域与关闭后读回;`+publish-set` 保留为 unavailable 诊断入口。
- `+version-download`:历史版本真实字节下载与本地 artifact 校验。
- `+rename`:写后读回验证。
普通钉盘文件的独立 `copy` 是额外确认出的部分 gap:钉钉当前 `doc/copy_document` 对该对象会生成 `.dlink`,不是字节独立副本。`drive +copy` 因此只接受在线对象;普通文件需要快捷入口时用 `+create-shortcut`,需要独立副本时用 `+download` 后 `+upload`。这不是完整的服务端原子 copy,对大文件也不能宣称完全等价。
互联网公开开启也是账号/对象能力 gap:真实普通文件与在线文档夹具都由服务端返回 `operation.notSupported`。`+publish-get` 与关闭语义可验证,但 `+publish-set` 在找到 eligible 节点完成 set→get→unset 闭环前保持 `unavailable` 且不进入公开 Agent catalog。
## 端到端门禁
每个公开 Drive shortcut 必须至少覆盖:
- Cobra 参数、静态确认、Shortcut Execute、MCP 调度和最终输出;
- 明确业务空集合、空响应、缺字段、错误类型、坏元素、`success=false`;
- 写入的 ID/终态证据与读回不一致;
- 下载的本地路径边界、no-clobber、真实字节数;
- 真实账号数据:读命令必须命中已知非空夹具或明确验证合法空集合;写命令必须创建隔离资源、读回、必要时下载比对字节并清理。
发布前运行 `make build`、完整 Go 测试、Schema 生成/漂移/策略检查,并保存不含账号业务内容的结构化 E2E 汇总。
+13 -2
View File
@@ -17,7 +17,8 @@
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
3. workflow summary 会给出唯一的下一版本。运行 `prepare-changelog.sh` 将已合入的
release fragments 汇总成对应的精确 `CHANGELOG.md` 章节,并通过唯一的 release-seal PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
@@ -94,7 +95,8 @@ main 上的候选代码 + beta CHANGELOG
dws-release v1.2.3-beta.1
```
如果 CHANGELOG 尚不存在,该命令只生成模板并停止。补全内容、删除所有 `TODO`,提交后通过 PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
如果 CHANGELOG 尚不存在,该命令会从 `.changes/*.md` 生成 beta 章节并归档已消费的
fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR 合入 `main`;然后重新运行完全相同的命令,它会执行完整预检:
```bash
dws-release v1.2.3-beta.1
@@ -132,6 +134,15 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
正式版使用 `## [1.2.3] - YYYY-MM-DD`。该章节会直接成为 GitHub Release Notes。
### Release fragments
普通 PR 不修改 `CHANGELOG.md` 的 `Unreleased` 区域。需要面向用户发布说明的改动在
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
[`.changes/README.md`](../.changes/README.md)。预发封板时
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
+216
View File
@@ -0,0 +1,216 @@
# RFC:DWS 预制 Skill 安装、升级与模式迁移
| 字段 | 内容 |
|---|---|
| 状态 | Accepted / as implemented |
| 生效范围 | DWS CLI、升级器、npm 与平台安装脚本 |
| 事实源 | 本 RFC 与当前代码;两者冲突时以代码和测试为准 |
| 关联合同 | [Skill 内容框架](skill-content-framework.md)、[Mono↔Multi 内容质检](skill-mono-multi-qa.md) |
## 1. 背景
DWS 同时通过 CLI、升级器、npm、Shell 和 PowerShell 分发预制 Skill。multi
成为默认布局后,所有入口必须对安装集合、模式互斥、失败退出、缓存发布和目录
所有权保持一致。此前分散的调研、迁移计划、阶段性 roadmap 和 rollout 文档容易
相互冲突;本 RFC 将最终行为收敛为一个长期合同。
## 2. 目标与非目标
### 2.1 目标
- 新装与升级默认使用 multi 布局,mono 在兼容期内保留显式 opt-in。
- 每次升级使用当前版本的官方清单全量覆盖预制 Skill。
- 删除或替换任何目录前先创建可恢复备份,备份失败不修改该 Agent 目标。
- 只清理能够证明由 DWS 管理的目录,不通过名称前缀推断所有权。
- 所有安装入口对部分失败返回非零状态,不误报整体成功。
- 安装预览、确认和实际执行使用同一份计划。
### 2.2 非目标
- 不建设独立的 `dws skill mode status|set|rollback` 产品面。
- 不持久化用户对预制 Skill 的本地删除或排除意图。
- 不提供跨所有 Agent 目标的事务式回滚。
- 不把市场 Skill 纳入预制 Skill 的升级和清理范围。
## 3. 业内调研
对主流 CLI 与 Agent Skill 分发方式的公开实现进行归纳后,可以得到以下共性:
| 观察 | 对 DWS 的启示 |
|---|---|
| 多个产品能力通常以同级 Skill 目录安装,由 Agent 按目录发现 | multi 使用平铺的产品 Skill,并保留一个共享 Skill 承载公共协议 |
| CLI 本体安装和 Agent Skill 安装是两个生命周期 | DWS 可以在 CLI 安装、setup 和 upgrade 中触发 Skill 同步,但二者的失败与状态必须分别报告 |
| 生态安装器通常天然采用 multi,不提供 mono/multi 状态机 | DWS 的模式切换保持为重新执行 setup,不新增长期驻留的 mode lifecycle |
| 市场 Skill 与 CLI 预制 Skill 可能落在同一 Agent 根目录 | 必须使用统一所有权元数据识别受管目录,名称前缀不能作为删除依据 |
| 多 Skill 更新常以新清单刷新官方集合 | DWS 使用当前 bundle 官方清单全量覆盖,新增 Skill 自动加入,本地删除不视为持久化排除 |
| 制品可能需要同时服务无运行时依赖、离线和多镜像环境 | DWS 保留 embed、zip 和平台安装脚本,不把单一生态包管理器设为唯一入口 |
| 中断的复制和原地覆盖容易破坏最后一个可用版本 | 缓存与 Go upgrade 的 Agent 目标采用 staging publish;发布失败自动恢复该目标的完整旧集合 |
| Agent 通常以 `SKILL.md` 为入口,其他文件按引用或工具规则按需读取 | 安装元数据使用不被内容引用的隐藏文件,并保证其内容不包含 Agent 指令 |
本节只保留可复用的工程结论,不记录具体产品、仓库、版本或逐项能力对照,也不构成
DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约束和本 RFC 的行为合同为准。
## 4. 布局合同
| 模式 | Agent 目录布局 | 选择方式 |
|---|---|---|
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
不可恢复的删除。
## 5. 官方集合与升级策略
当前版本 bundle 中的 multi 目录清单是升级集合的唯一权威来源。普通 upgrade 和
`--force` 都安装并覆盖该版本的全部官方预制 Skill:
- 本地删除的预制 Skill 会在下一次升级恢复;
- setup 时通过 `--exclude` 暂时排除的 Skill 会在下一次升级恢复;
- 新版本新增的官方 Skill 会自动安装;
- 用户对预制 Skill 的本地修改会被官方版本覆盖;
- `dingtalk-shared` 始终随官方集合安装。
`~/.dws/skills-state.json`(设置 `DWS_CONFIG_DIR` 时位于该目录)不参与安装集合
求解,也不保存排除策略。它既记录结果快照,也集中记录 multi Skill 的所有权和
provenance,供安全清理、诊断与后续迁移使用。
## 6. 目录所有权
每次 multi setup 或 upgrade 全部成功后,DWS 在统一的
`~/.dws/skills-state.json` 中写入:
```json
{
"version": "v0.2.14",
"official_skills": ["dingtalk-aitable"],
"updated_skills": ["dingtalk-aitable"],
"managed_skills": [
{
"name": "dingtalk-aitable",
"version": "v0.2.14",
"source": "dws-upgrade",
"digest": "sha256:<64 个十六进制字符>",
"digest_scope": "skill-directory-v1"
}
],
"updated_at": "2026-08-11T12:34:56Z"
}
```
每条 `managed_skills` 记录代表一个由 DWS 管理的官方 Skill。`version` 记录安装该
副本的 DWS/发布包版本,`source` 记录安装入口,`digest` 是对 bundle 中 Skill 目录
全部普通文件按相对路径排序后计算的内容摘要。摘要用于诊断和来源追踪,不作为后续
升级的完整性门禁;用户修改 Skill 内容后,DWS 仍保有明确管理权并能在下一次升级时
覆盖恢复。
清理 stale Skill 或切换到 mono 时,只接受以下所有权证据:
1. Skill 名称存在于统一状态的 `managed_skills` 中;
2. 统一状态上线前曾发布过的官方 Skill 精确名称集合。
历史集合是冻结的迁移清单,包含 `dws-shared` 以及已退役、折叠或仍在发布的旧官方
目录名。仅有 `dingtalk-*` 前缀不构成所有权证据。因此,市场或用户创建的
`dingtalk-custom` 等非官方精确名称目录不会被迁走。
### 6.1 对 Agent 的影响
Skill 目录内不再放置 DWS 所有权文件,也不增加非通用 frontmatter 字段。支持的
Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agent Skill 目录之外,
不会成为提示词上下文或影响 Agent 行为。
## 7. Setup:Plan → Confirm → Execute
`dws skill setup` 分为三个阶段:
1. **Plan**:只读计算目标、安装集合以及所有待备份路径;
2. **Confirm**:`--dry-run` 和交互确认渲染同一份计划;
3. **Execute**:确认后严格执行计划中的备份和安装。
安全要求:
- 非交互环境未传 `--yes` 时拒绝执行;
- 用户拒绝确认时必须零文件写入;
- 备份失败时跳过整个 Agent 目标,不开始铺设相反布局;
- 同一目标先完成所有必要备份,再复制新集合;
- multi Skill 必须在同级 staging 中完成复制,再原子发布到正式目录;
- 任意 `skipped > 0` 都返回非零退出码,并且不写入完整成功快照;
- 一个 Agent 目标失败不阻止其他目标尝试,但最终结果仍为失败。
## 8. Upgrade 与恢复语义
升级器对每个 Agent 目标执行:
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合;
3. staging 全部成功后,才将旧集合移入备份目录;
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
“不提供跨所有 Agent 目标的事务式回滚”非目标保持一致。
## 9. 备份合同
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
- 主要操作:同一文件系统内使用 rename 移动;
- 失败语义:备份失败时原目录保持不变,目标安装失败;
- 可见性:计划和执行日志显示原路径与备份路径;
- 保留策略:自动修剪,仅保留最近 5 批。
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
`dws skill setup --mode mono`。
## 10. 缓存与制品
发布制品和二进制内嵌内容同时携带 mono 与 multi 源树。`~/.dws/skills/` 只是
setup 在未显式指定 `--source` 时的本地回退缓存。
缓存刷新必须采用同级 staging + publish:
1. 在 staging 中完整复制并验证新树;
2. 发布前保留旧缓存;
3. 通过 rename 发布新缓存;
4. 复制或发布失败时保留或恢复旧缓存;
5. 空、缺失或损坏的 bundle 不能擦除有效缓存。
## 11. 安装入口一致性
以下入口都遵守本 RFC:
| 入口 | 默认模式 | 失败合同 |
|---|---|---|
| `dws skill setup` | multi | 部分失败返回非零;不写完整成功状态 |
| `dws upgrade` | bundle 含 multi 时安装 multi | 目标失败返回失败;下次全量重试 |
| `scripts/install.sh` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
## 12. 验收与回归门禁
合入和后续修改至少覆盖:
- mono → multi、multi → mono 互斥切换;
- 状态上线前的官方 multi 目录切换 mono 时能够被精确迁移;
- 未登记的同前缀市场/用户 Skill 在刷新和切换后仍存在;
- 统一状态中登记的过期官方 Skill 被备份并移除;
- 备份、复制、统一状态写入、缓存 publish 故障注入;
- 非交互确认拒绝与显式 `--yes`;
- 部分失败返回非零且不写错误状态快照;
- 复制失败不留下 Agent 可见的残缺官方目录;
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
- Windows、macOS、Linux 的路径和覆盖率门禁;
- npm、Shell、PowerShell 与包管理器安装冒烟。
## 13. 后续演进
- 收敛各安装入口中的 Agent home 清单,减少跨语言复制;
- 如确有运维需求,可单独设计备份查看和显式恢复命令;
- mono 的物理删除必须作为独立变更,在 multi 内容、安装入口和迁移回归稳定后推进;
- `managed_skills` 字段若演进,必须同步更新所有安装入口和跨平台回归。
-188
View File
@@ -1,188 +0,0 @@
# lark-cli Shortcut 深度对齐矩阵
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
## 2026-07-13 最新源码复核
对比基线:
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
- lark-cli:`main@e96c4fa5`
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
### 当前命令面快照
| 指标 | 数量 | 说明 |
|---|---:|---|
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
| 产品映射 | DWS | lark | 同名 |
|---|---:|---:|---:|
| aitable ↔ base | 82 | 87 | 31 |
| calendar ↔ calendar | 23 | 10 | 3 |
| chat ↔ im | 89 | 21 | 2 |
| contact ↔ contact | 16 | 2 | 1 |
| devapp ↔ apps | 30 | 63 | 3 |
| doc ↔ doc | 19 | 14 | 1 |
| drive ↔ drive | 9 | 26 | 3 |
| mail ↔ mail | 10 | 21 | 0 |
| minutes ↔ minutes | 13 | 9 | 1 |
| sheet ↔ sheets | 2 | 42 | 0 |
| todo ↔ task | 13 | 17 | 2 |
| wiki ↔ wiki | 7 | 12 | 3 |
### 最新优先差距
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
### 不建议机械追平
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
## 汇总(361 条 lark shortcut)
| dws_status | 数量 | 含义 |
|---|:---:|---|
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
### im → chat(7)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
### task → todo(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
### calendar → calendar(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
### doc (docs) → doc(2)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
### drive → drive(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
### mail → mail(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
### wiki → wiki(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
### minutes → minutes(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
### base → aitable(10)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
### sheets → sheet(14)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
### apps → devapp(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
- **task**: +complete +assign +get-my-tasks +get-related-tasks
- **contact**: +search-user
- **calendar**: +agenda +create +update +freebusy +suggestion
- **doc (docs)**: +history-revert
- **drive**: +upload +search +inspect
- **mail**: +triage
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
+501 -42
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-06T21:08:26.697858",
"count": 357,
"generated_at": "2026-08-12T00:10:44.511794",
"count": 399,
"results": [
{
"suite": "semantic",
@@ -2162,7 +2162,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -2650,8 +2650,8 @@
"command": "+history-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史列表路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-list。",
"availability": "available"
},
{
@@ -2660,8 +2660,8 @@
"command": "+history-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史回滚路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-revert。",
"availability": "available"
},
{
@@ -2670,8 +2670,8 @@
"command": "+history-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
"disposition": "alias_internal",
"semantic_delta": "保留既有历史快照路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-save。",
"availability": "available"
},
{
@@ -2841,7 +2841,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
"semantic_delta": "按名称或关键词检索模板并返回可消歧候选和 templateId。",
"availability": "available"
},
{
@@ -2860,8 +2860,8 @@
"command": "+version-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
"disposition": "semantic_adapter",
"semantic_delta": "版本浏览的 Agent 主入口;统一分页参数并返回可用于回滚的版本号。",
"availability": "available"
},
{
@@ -2870,8 +2870,8 @@
"command": "+version-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
"disposition": "primary_smart",
"semantic_delta": "版本回滚的 Agent 主入口;先验证目标版本存在,再回滚并读回当前状态。",
"availability": "available"
},
{
@@ -2880,58 +2880,289 @@
"command": "+version-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
"disposition": "semantic_adapter",
"semantic_delta": "版本快照的 Agent 主入口;只保存当前快照,不隐式修改正文。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "drive",
"command": "+copy",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "复制前预检在线对象类型;普通钉盘文件因下层只会生成 .dlink 而显式拒绝,避免把快捷方式伪装成独立副本。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+cover",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取节点封面或缩略图地址;明确不声称服务端多格式预览转换。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+create-folder",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建普通钉盘文件夹后要求 fileId,并读回名称验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+create-shortcut",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "明确 shortcut 与 copy 语义差异,创建后读取新节点验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "将已确认节点移入回收站,要求高风险确认和 success=true 终态证据。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "不再只返回临时链接;使用受控相对路径、no-clobber、原子发布并验证非零本地字节。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+find-file",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文件定位入口;新的 Agent 文件搜索统一使用 +search。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+info",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史元数据入口;新的 Agent 场景统一使用可扩展的 +inspect。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "drive",
"command": "+inspect",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "以文件元数据为必达结果,按需聚合统计、公开状态和封面;可选读取失败显式报告 partial_success。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空目录与缺失/畸形响应,稳定投影节点并完整保留分页游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+move",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "移动后原位置不保留,统一 folder/workspace 目标语义并发布静态确认。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+publish-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "只读查询互联网公开状态和权限,不沿用原子命令错误的写风险标签。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+publish-unset",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "高风险确认后关闭互联网公开,并读回状态验证外链已失效。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+recent",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取最近访问/编辑列表并保留 nextCursor/hasMore,防止嵌套响应被投影为空。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+recycle-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出回收项并稳定投影 recycleItemId,显式空数组才是空回收站。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+recycle-restore",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "只要求列表可获得的 recycleItemId;恢复响应必须给出节点 ID,随后读回验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+rename",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "重命名后读取真实节点元数据验证最终名称。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一关键词、文件类型、扩展名、创建者、时间和分页过滤,并拒绝缺失结果数组。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "drive",
"command": "+search-docs",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史跨域文档搜索入口;新的在线文档搜索统一使用 doc +search。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-add",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "以幂等用户偏好语义收藏指定节点。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出当前用户收藏并保留游标。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+star-remove",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "以幂等用户偏好语义取消收藏指定节点。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+stats",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取节点访问、编辑、评论、点赞、预览和下载统计的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "组合工作目录边界校验、上传凭证、OSS PUT、严格提交响应和远端元数据读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "预检版本存在后安全下载历史字节,受控相对路径原子发布且要求非零产物。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按正整数版本号精确匹配元数据;零命中显式失败。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-history",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格分页列出普通文件版本,区分合法空历史与响应契约错误。",
"availability": "available"
},
{
"suite": "semantic",
"service": "drive",
"command": "+version-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再经高风险确认回滚并读取当前节点状态。",
"availability": "available"
},
{
"suite": "read",
@@ -3004,46 +3235,274 @@
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+action-items",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+apply-permission",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+detail",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+export-pack",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+latest",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+list-all",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of all accessible Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+list-mine",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of current-user Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+list-shared",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of shared Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "minutes",
"command": "+mindmap",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+prepare-asr",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-pause",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-pause facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-resume",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-resume facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-start",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-create facade with explicit confirmation and stable session parameter.",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "minutes",
"command": "+record-stop",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-stop facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-wrap-up",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+replace-batch",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+search",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; replaces the deprecated +minutes-search discovery route.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+speaker-insights",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+speaker-replace",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+summary",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+transcript",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+unshare",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Owns local file validation, create-PUT-complete polling, pre-complete transfer cancellation compensation, unknown-completion recovery and final task read-back verification.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+upload-and-analyze",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.",
"availability": "available"
},
{
"suite": "read",
+3 -13
View File
@@ -2,7 +2,8 @@
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
> 安装、升级与模式迁移见
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
## 1. 两棵内容树
@@ -87,18 +88,7 @@ skills/mono/
质检可断言源树形状;**不**断言安装器默认解压哪棵。
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|---|---|---|
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
## 7. 变更流程
## 6. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
+2 -4
View File
@@ -3,6 +3,8 @@
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
> 安装、升级与模式迁移见
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
## 1. 质检矩阵
@@ -70,7 +72,3 @@ paired_files:
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
## 4. 与悟空
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
-272
View File
@@ -1,272 +0,0 @@
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
>
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
>
> 对照仓:
>
> | 仓 | 路径 | 基线 |
> |---|---|---|
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
---
## 0. TL;DR
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
### 0.1 IN SCOPE
| 类别 | 包含 |
|---|---|
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
### 0.2 OUT OF SCOPE
| 类别 | 去向 |
|---|---|
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
| 把质检做成「改安装默认值」的后门 | 拒绝 |
---
## 1. 内容现状盘点
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
│ └── best_practices/…
└── scripts/
```
### 1.2 DWS `skills/multi`(内容主体)
```text
skills/multi/
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
└── dingtalk-*/ # 19 产品 + 各 references、scripts
```
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
### 1.4 Zip 内容布局合同
| Zip 路径 | 内容含义 |
|---|---|
| `<root>/` | mono 副本(兼容) |
| `<root>/mono/` | 显式 mono 内容源 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
### 1.5 现有 DWS skill 内容质检资产(复用清单)
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|---|---|---|
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
### 1.6 悟空侧类比质检
| 悟空 | 说明 | 本分支 |
|---|---|---|
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
---
## 2. Diff(内容组织 + 质检视角)
### 2.1 已同构
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
### 2.2 分叉与已知内容风险(质检要盯的)
| 风险 ID | 现象(线索) | 质检类型 |
|---|---|---|
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
### 2.3 Reject
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
---
## 3. Goals / Non-goals
### 3.1 Goals
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
### 3.2 Non-goals
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
---
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
### Phase 0 — 方案冻结(本文)
| | |
|---|---|
| **范围** | 本文件;§7(含质检轨)勾选 |
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
| | |
|---|---|
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
| **触达** | `docs/skill-content-framework.md` |
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
| | |
|---|---|
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
| | |
|---|---|
| **范围** | G1–G4 自动门禁 |
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
| | |
|---|---|
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
### 延期登记(非本分支)
| 主题 | 载体 |
|---|---|
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
| skillhome / 安装面 bootstrap | 行为分支 |
---
## 5. Port / Adapt / Reject
| 项 | 决策 | 说明 |
|---|---|---|
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
| 行为 cherry-pick | **Defer** | 另分支 |
---
## 6. 与 `402429ac` / `d5c8982c`
| | |
|---|---|
| 本分支 cherry-pick? | **否** |
| 质检是否替代行为翻转? | **否** |
| 行为分支 | 另开;可与内容/质检并行 |
---
## 7. 批准清单(请重新勾选)
**范围**
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
- [x] 取消产品与悟空客户端链路仍拒绝
**内容框架 Phase**
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
**质检轨 Phase**
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
**可选**
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
**Follow-up 知悉**
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
---
## 8. 下一步
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
安装默认 multi 等行为仍走 **另一分支**。
---
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
+17 -9
View File
@@ -33,6 +33,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -2033,6 +2034,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatal(err)
}
state, readable, err := skillstate.Read(home)
if err != nil || !readable || len(state.OfficialSkills) != 3 || len(state.UpdatedSkills) != 2 {
t.Fatalf("setup state = %#v, readable=%v, err=%v", state, readable, err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
t.Fatalf("multi dry run = %q, %v", output, err)
}
@@ -2048,8 +2053,11 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
t.Fatalf("invalid setup %#v succeeded", args)
}
}
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
t.Fatalf("default mono setup: %v", err)
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
t.Fatalf("mono setup: %v", err)
}
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
t.Fatalf("default mode should be multi: %q, %v", output, err)
}
}
@@ -2082,7 +2090,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing multi source succeeded")
}
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
t.Fatalf("default setup mode = %q, %v", mode, err)
}
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
@@ -2117,7 +2125,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
for _, tc := range []struct{ path, mode string }{{"", skillSetupModeMono}, {mono, skillSetupModeMono}, {filepath.Dir(multi), skillSetupModeMulti}, {root, "bad"}} {
_ = isSkillSourceRoot(tc.path, tc.mode)
}
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
for _, tc := range []struct{ target, mode string }{{"agents", skillSetupModeMono}, {"agents", skillSetupModeMulti}, {"all", skillSetupModeMono}, {"missing", skillSetupModeMono}} {
_, _ = resolveSkillSetupTargets(tc.target, tc.mode)
}
@@ -2125,8 +2133,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
_ = agentHomeForMode("base", skillSetupModeMulti)
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
_ = mutualExclusionVictims(root, mode)
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
_, _ = mutualExclusionVictims(root, mode)
}
if isCharDevice(nil) || isInteractiveTerminal() {
t.Fatal("test process unexpectedly interactive")
@@ -2134,17 +2142,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
multiDest := filepath.Join(t.TempDir(), "agent")
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
badParent := filepath.Join(t.TempDir(), "file")
_ = os.WriteFile(badParent, []byte("x"), 0o600)
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
t.Fatal("copy missing directory succeeded")
}
+1 -1
View File
@@ -58,7 +58,7 @@ func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
@@ -79,13 +79,15 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
t.Fatalf("timed out authorization = %v, %v", ok, err)
}
patAuthorizationTimeout = 5 * time.Millisecond
patAuthorizationTimeout = time.Second
patAuthorizationPollInterval = time.Millisecond
pollCtx, pollCancel := context.WithCancel(context.Background())
patResolveAccessToken = func(context.Context, string, string) (string, error) {
pollCancel()
return "", authpkg.ErrTokenDataNotFound
}
out.Reset()
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
if ok, err := WaitForPatAuthorization(pollCtx, "", &out); ok || !errors.Is(err, context.Canceled) || !strings.Contains(out.String(), "等待授权中") {
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
}
}
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 357
publicShortcutCount = 399
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 358
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 401
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 357
publiclyDeliveredShortcutCount = 399
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,707 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func useManagedSkillNames(t *testing.T, names ...string) {
t.Helper()
records := make([]skillprovenance.Record, 0, len(names))
for _, name := range names {
records = append(records, skillprovenance.Record{Name: name})
}
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
return &skillstate.State{ManagedSkills: records}, true, nil
})
}
// TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills verifies the
// confirmation prompt lists stale dingtalk-* / dws-shared directories that a
// full (unfiltered) multi install will back up and remove, and that a
// filtered install previews nothing extra.
func TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills(t *testing.T) {
testseam.Swap(t, &skillSetupInteractive, func() bool { return false })
dest := filepath.Join(t.TempDir(), ".claude", "skills")
stale := filepath.Join(dest, "dingtalk-old")
if err := os.MkdirAll(stale, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stale, "SKILL.md"), []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, "dingtalk-old")
var out bytes.Buffer
ok, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, false)
if err == nil || ok || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
}
if !strings.Contains(out.String(), "将备份并移除过期 skill") {
t.Fatalf("full install preview must list stale skills, got %q", out.String())
}
if !strings.Contains(out.String(), filepath.Join(dest, "dingtalk-old")) {
t.Fatalf("preview must name the stale directory, got %q", out.String())
}
out.Reset()
ok, err = confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, true)
if err == nil || ok {
t.Fatalf("filtered confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
}
if strings.Contains(out.String(), "将备份并移除过期 skill") {
t.Fatalf("filtered install must stay additive in the preview, got %q", out.String())
}
}
func TestCrossPlatformCoverageSkillSetupUnifiedOwnership(t *testing.T) {
dir := filepath.Join(t.TempDir(), "dingtalk-custom")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if isManagedDWSMultiSkillDir(dir) {
t.Fatal("an unregistered dingtalk-* directory must not be treated as DWS-owned")
}
managed := map[string]bool{"dingtalk-custom": true}
if !isManagedDWSMultiSkillDir(dir, managed) {
t.Fatal("unified metadata must prove ownership")
}
legacy := filepath.Join(t.TempDir(), legacySharedSkill)
if !isManagedDWSMultiSkillDir(legacy) {
t.Fatal("the exact legacy dws-shared name must remain managed")
}
}
func TestCrossPlatformCoveragePublishManagedSkillFailurePaths(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
skillSrc := filepath.Join(src, "dingtalk-a")
failure := errors.New("publish denied")
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishTemp, func(string, string) (string, error) { return "", failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "staging") {
t.Fatalf("mkdir error = %v", err)
}
})
t.Run("copy", func(t *testing.T) {
parent := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
if !errors.Is(err, failure) {
t.Fatalf("copy error = %v", err)
}
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
t.Fatalf("copy failure retained staging: %v, err=%v", entries, readErr)
}
})
t.Run("rename", func(t *testing.T) {
parent := t.TempDir()
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "发布 Skill") {
t.Fatalf("rename error = %v", err)
}
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
t.Fatalf("rename failure retained staging: %v, err=%v", entries, readErr)
}
})
t.Run("cleanup", func(t *testing.T) {
renameErr := errors.New("rename denied")
cleanupErr := errors.New("cleanup denied")
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return renameErr })
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
if !errors.Is(err, renameErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("cleanup error = %v", err)
}
})
}
// TestCrossPlatformCoverageSkillSetupCleanupHomeFailure verifies that
// cleanupMutualExclusion keeps every victim in place with a warning when
// $HOME cannot be resolved, instead of destroying anything.
func TestCrossPlatformCoverageSkillSetupCleanupHomeFailure(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".agents", "skills")
victim := filepath.Join(dest, "dws")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(victim, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
homeErr := errors.New("home boom")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", homeErr })
var out, errOut bytes.Buffer
cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
t.Fatalf("expected HOME warning on errOut, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(victim, "SKILL.md")); err != nil {
t.Fatalf("victim must survive the HOME failure: %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupBackupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dws" {
return "", failure
}
return "", nil
})
dest := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
}
if copyCalls != 2 {
t.Fatalf("backup failure staged %d new Skills, want 2", copyCalls)
}
if !strings.Contains(errOut.String(), "跳过整个 Agent 目标") {
t.Fatalf("missing whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupCleanupMutualExclusionBackupFailure(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
victim := filepath.Join(dest, "dws")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
failure := errors.New("backup boom")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if dir != victim {
t.Fatalf("backup victim = %q, want %q", dir, victim)
}
return "", failure
})
var out, errOut bytes.Buffer
err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
if !errors.Is(err, failure) {
t.Fatalf("cleanup error = %v, want %v", err, failure)
}
if out.Len() != 0 || !strings.Contains(errOut.String(), "互斥清理失败") {
t.Fatalf("cleanup output = %q / %q", out.String(), errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupMonoCleanupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("multi backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dingtalk-a" {
return "", failure
}
return "", nil
})
base := filepath.Join(home, ".agents", "skills")
multi := filepath.Join(base, "dingtalk-a")
if err := os.MkdirAll(multi, 0o755); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, filepath.Base(multi))
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if copyCalls != 1 {
t.Fatalf("multi cleanup failure staged mono %d times, want 1", copyCalls)
}
if _, err := os.Stat(multi); err != nil {
t.Fatalf("multi leftover must survive backup failure: %v", err)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("missing mono whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupStaleBackupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("stale backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dingtalk-stale" {
return "", failure
}
return "", nil
})
dest := filepath.Join(home, ".agents", "skills")
stale := filepath.Join(dest, "dingtalk-stale")
if err := os.MkdirAll(stale, 0o755); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, filepath.Base(stale))
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
}
if copyCalls != 2 {
t.Fatalf("stale backup failure staged %d new Skills, want 2", copyCalls)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("missing stale whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *testing.T) {
for _, failureKind := range []string{"later_backup", "later_publish"} {
failureKind := failureKind
t.Run(failureKind, func(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
first := filepath.Join(dest, "dingtalk-first")
second := filepath.Join(dest, "dingtalk-second")
for path, body := range map[string]string{
filepath.Join(first, "SKILL.md"): "old first\n",
filepath.Join(second, "SKILL.md"): "old second\n",
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
src := writeMultiSkillSource(t, []string{"dingtalk-first", "dingtalk-second"})
if err := os.WriteFile(filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
failure := errors.New("injected " + failureKind + " failure")
if failureKind == "later_backup" {
originalBackup := skillSetupBackupAndRemove
testseam.Swap(t, &skillSetupBackupAndRemove, func(homeDir, dir string) (string, error) {
if dir == second {
return "", failure
}
return originalBackup(homeDir, dir)
})
} else {
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
}
return originalRename(oldPath, newPath)
})
}
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(
src,
[]string{"dingtalk-first", "dingtalk-second"},
[]string{dest},
&out,
&errOut,
true,
)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("transaction failure = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
}
for path, want := range map[string]string{
filepath.Join(first, "SKILL.md"): "old first\n",
filepath.Join(second, "SKILL.md"): "old second\n",
} {
got, readErr := os.ReadFile(path)
if readErr != nil || string(got) != want {
t.Fatalf("restored %s = %q, err=%v, want %q", path, got, readErr, want)
}
}
entries, readErr := os.ReadDir(dest)
if readErr != nil {
t.Fatal(readErr)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-setup-set-") {
t.Fatalf("transaction left staging directory %s", entry.Name())
}
}
if !strings.Contains(errOut.String(), "已执行回滚") || !strings.Contains(errOut.String(), failure.Error()) {
t.Fatalf("transaction failure output = %q", errOut.String())
}
})
}
}
func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
failure := errors.New("injected transaction failure")
t.Run("managed publish success", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := filepath.Join(t.TempDir(), "dingtalk-a")
if err := publishDWSManagedSkillDir(filepath.Join(src, "dingtalk-a"), dest); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
t.Fatalf("published Skill missing: %v", err)
}
})
t.Run("staging cleanup failure", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
cleanupErr := errors.New("staging cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
_, _, err := stageSkillSetupTarget(
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
skillSetupTargetPlan{Destination: dest},
)
if !errors.Is(err, failure) || !errors.Is(err, cleanupErr) {
t.Fatalf("staging cleanup error = %v", err)
}
})
t.Run("staging directory failure", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := t.TempDir()
originalMkdirAll := skillSetupMkdirAll
testseam.Swap(t, &skillSetupMkdirAll, func(path string, mode os.FileMode) error {
if filepath.Base(path) == "dingtalk-a" && strings.HasPrefix(filepath.Base(filepath.Dir(path)), ".dws-setup-set-") {
return failure
}
return originalMkdirAll(path, mode)
})
_, _, err := stageSkillSetupTarget(
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
skillSetupTargetPlan{Destination: dest},
)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill staging 目录失败") {
t.Fatalf("staging directory error = %v", err)
}
})
t.Run("restore failure aggregation", func(t *testing.T) {
t.Run("remove published", func(t *testing.T) {
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure })
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
t.Fatalf("remove published error = %v", err)
}
})
t.Run("original still exists", func(t *testing.T) {
original := t.TempDir()
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: original, backup: "backup"}})
if err == nil || !strings.Contains(err.Error(), "恢复目标仍存在") {
t.Fatalf("existing restore target error = %v", err)
}
})
t.Run("stat", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") {
t.Fatalf("restore stat error = %v", err)
}
})
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") {
t.Fatalf("restore mkdir error = %v", err)
}
})
t.Run("rename", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") {
t.Fatalf("restore rename error = %v", err)
}
})
})
t.Run("backup rollback failure", func(t *testing.T) {
calls := 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
calls++
if calls == 1 {
return "backup", nil
}
return "", failure
})
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
restoreErr := errors.New("restore failure")
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
_, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard)
if !errors.Is(err, failure) || !errors.Is(err, restoreErr) {
t.Fatalf("backup rollback error = %v", err)
}
})
t.Run("publish rollback failure", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
err := publishSkillSetupTarget(
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
[]skillSetupBackedUpDir{{original: "dest", backup: "backup"}},
)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "回滚不完整") {
t.Fatalf("publish rollback error = %v", err)
}
})
t.Run("execute cleanup errors", func(t *testing.T) {
newPlan := func(t *testing.T) *skillSetupPlan {
t.Helper()
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
return &skillSetupPlan{
Mode: skillSetupModeMulti,
Source: src,
MultiSkillNames: []string{"dingtalk-a"},
Targets: []skillSetupTargetPlan{{Destination: t.TempDir()}},
}
}
t.Run("after backup failure", func(t *testing.T) {
plan := newPlan(t)
plan.Targets[0].Backups = []skillSetupBackup{{Path: filepath.Join(plan.Targets[0].Destination, "old")}}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
cleanupErr := errors.New("cleanup after backup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("backup cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
}
})
t.Run("after publish failure", func(t *testing.T) {
plan := newPlan(t)
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
}
return originalRename(oldPath, newPath)
})
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after publish failure")
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
return cleanupErr
}
return originalRemoveAll(path)
})
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("publish cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
}
})
t.Run("after success", func(t *testing.T) {
plan := newPlan(t)
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after success")
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
return cleanupErr
}
return originalRemoveAll(path)
})
var stderr bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || installed != 1 || skipped != 0 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("success cleanup = installed %d, skipped %d, err %v, stderr %q", installed, skipped, err, stderr.String())
}
})
})
}
// TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips verifies both
// install paths skip (never destroy) every target when $HOME cannot be
// resolved for the pre-refresh backup.
func TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if err := os.MkdirAll(monoDest, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(monoDest, "SKILL.md"), []byte("# old"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{monoDest}, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("mono install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过刷新") {
t.Fatalf("expected HOME skip warning, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
t.Fatalf("existing mono dir must be preserved: %v", err)
}
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-a"})
multiDest := filepath.Join(t.TempDir(), ".claude", "skills")
if err := os.MkdirAll(filepath.Join(multiDest, "dingtalk-a"), 0o755); err != nil {
t.Fatal(err)
}
out.Reset()
errOut.Reset()
installed, skipped, err = installMultiSkillToHomes(multiSrc, []string{"dingtalk-a"}, []string{multiDest}, &out, &errOut, true)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("multi install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过整个 Agent 目标") {
t.Fatalf("expected multi HOME skip warning, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(multiDest, "dingtalk-a")); err != nil {
t.Fatalf("existing sub skill must be preserved: %v", err)
}
}
// TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges covers
// removeStaleMultiSkills and its preview companion staleMultiSkillVictims:
// scan failures, the HOME failure, backup failures, and the success path.
func TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".cursor", "skills")
keep := []string{"dingtalk-chat"}
entries := map[string]bool{ // dir entries; README below is a plain file
"dingtalk-chat": true, // kept (in bundle)
"dingtalk-stale": true, // stale product skill
"dws-shared": true, // legacy shared name is stale too
"other-skill": true, // non-DWS, must survive
}
for name := range entries {
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
if err := os.WriteFile(filepath.Join(dest, "README"), []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
// Non-ENOENT scan failure warns; ENOENT is silent.
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("scan boom") })
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "过期 skill 扫描失败") {
t.Fatalf("expected scan warning, got %q", errOut.String())
}
errOut.Reset()
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrNotExist })
removeStaleMultiSkills(dest, keep, &out, &errOut)
if errOut.Len() != 0 {
t.Fatalf("ENOENT scan must be silent, got %q", errOut.String())
}
testseam.Swap(t, &skillSetupReadDir, os.ReadDir)
// The preview companion sees the same victims and skips files/kept/non-DWS.
victims := staleMultiSkillVictims(dest, keep)
wantVictims := []string{filepath.Join(dest, "dingtalk-stale"), filepath.Join(dest, "dws-shared")}
if len(victims) != len(wantVictims) {
t.Fatalf("staleMultiSkillVictims = %v, want %v", victims, wantVictims)
}
// HOME failure keeps every stale directory with a warning.
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
errOut.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
t.Fatalf("expected HOME warning, got %q", errOut.String())
}
for name := range entries {
if _, err := os.Stat(filepath.Join(dest, name)); err != nil {
t.Fatalf("entry %s must survive the HOME failure: %v", name, err)
}
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
// Backup failure keeps the stale directory with a warning.
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup boom") })
errOut.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "过期 skill 清理失败(保留原目录") {
t.Fatalf("expected backup failure warning, got %q", errOut.String())
}
for _, stale := range wantVictims {
if _, err := os.Stat(stale); err != nil {
t.Fatalf("stale dir must survive the backup failure: %v", err)
}
}
// Success: both stale dirs are backed up and reported; the rest survives.
testseam.Swap(t, &skillSetupBackupAndRemove, func(_, dir string) (string, error) { return filepath.Join(t.TempDir(), "backup"), nil })
out.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if count := strings.Count(out.String(), "已备份并清理过期 skill"); count != len(wantVictims) {
t.Fatalf("expected %d stale cleanup lines, got %d (out=%q)", len(wantVictims), count, out.String())
}
if _, err := os.Stat(filepath.Join(dest, "other-skill")); err != nil {
t.Fatalf("non-DWS dir must survive: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-chat")); err != nil {
t.Fatalf("bundle skill must survive: %v", err)
}
}
+39 -31
View File
@@ -47,18 +47,16 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
oldTargets := skillSetupResolveTargets
oldList := skillSetupListMulti
oldFilter := skillSetupFilterMulti
oldConfirm := skillSetupConfirm
oldMono := skillSetupInstallMono
oldMulti := skillSetupInstallMulti
oldConfirm := skillSetupConfirmPlan
oldExecute := skillSetupExecutePlan
t.Cleanup(func() {
skillSetupResolveMode = oldMode
skillSetupResolveSource = oldSource
skillSetupResolveTargets = oldTargets
skillSetupListMulti = oldList
skillSetupFilterMulti = oldFilter
skillSetupConfirm = oldConfirm
skillSetupInstallMono = oldMono
skillSetupInstallMulti = oldMulti
skillSetupConfirmPlan = oldConfirm
skillSetupExecutePlan = oldExecute
})
fail := errors.New("failure")
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
@@ -83,17 +81,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
_ = cmd.Root().PersistentFlags().Set("dry-run", "true")
cmd.Flags().Bool("dry-run", true, "")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("confirmation failure should propagate")
}
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
@@ -105,17 +103,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
t.Fatal("unknown resolved mode should fail")
}
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("mono install failure should propagate")
}
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("multi install failure should propagate")
@@ -123,6 +121,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
setTestHome(t, t.TempDir())
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
home := filepath.Join(t.TempDir(), "skills")
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
@@ -143,6 +142,7 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
[]string{home},
&out,
&errOut,
true,
)
if err != nil || installed != 2 || skipped != 0 {
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
@@ -156,7 +156,7 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
t.Fatalf("unrelated custom skill changed: %v", err)
}
if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
if !strings.Contains(out.String(), "已备份并清理过期 skill") {
t.Fatalf("legacy cleanup was not reported: %s", out.String())
}
@@ -178,12 +178,17 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
[]string{failureHome},
&failureOut,
&failureErr,
true,
)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
}
if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
t.Fatalf("failed replacement removed legacy shared skill: %v", err)
got, readErr := os.ReadFile(filepath.Join(failureLegacy, "SKILL.md"))
if readErr != nil || string(got) != "legacy\n" {
t.Fatalf("failed replacement changed the live legacy copy: %q, err=%v", got, readErr)
}
if !strings.Contains(failureErr.String(), "Skill staging 失败,保留原集合") {
t.Fatalf("failed replacement did not report preserved live set: %s", failureErr.String())
}
})
}
@@ -226,6 +231,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
oldBackup := skillSetupBackupAndRemove
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
@@ -234,6 +240,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
skillSetupBackupAndRemove = oldBackup
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
@@ -246,7 +253,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
t.Fatal("interactive mode failure should propagate")
}
skillSetupRunForm = func(*huh.Form) error { return nil }
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMulti {
t.Fatalf("interactive default choice = %q, %v", got, err)
}
@@ -305,23 +312,24 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
var out, errOut bytes.Buffer
skillSetupRunForm = func(*huh.Form) error { return fail }
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}, false); err == nil {
t.Fatal("confirmation form failure should propagate")
}
skillSetupRunForm = func(*huh.Form) error { return nil }
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil, false); err != nil || ok {
t.Fatalf("EOF confirmation = %v, %v", ok, err)
}
skillSetupRemoveAll = func(string) error { return fail }
skillSetupUserHomeDir = func() (string, error) { return t.TempDir(), nil }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
skillSetupCopyDir = func(string, string) error { return fail }
skillSetupRemoveAll = func(string) error { return fail }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
_, skipped, _ := installSkillToHomes("src", []string{"a"}, &out, &errOut)
if skipped != 1 {
t.Fatal("mono remove failure not skipped")
t.Fatal("mono backup failure not skipped")
}
skillSetupRemoveAll = func(string) error { return nil }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
_, skipped, _ = installSkillToHomes("src", []string{"b"}, &out, &errOut)
if skipped != 1 {
@@ -334,18 +342,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
}
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 2 {
t.Fatal("multi mkdir failure count mismatch")
}
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
skillSetupRemoveAll = func(string) error { return fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 1 {
t.Fatal("multi remove failure count mismatch")
t.Fatal("multi backup failure count mismatch")
}
skillSetupRemoveAll = func(string) error { return nil }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 1 {
t.Fatal("multi copy failure count mismatch")
}
@@ -516,15 +524,15 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing
})
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
return 0, 0, fail
})
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("ordinary install failure = %v", err)
}
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("prerequisite install failure = %v", err)
}
})
+345
View File
@@ -0,0 +1,345 @@
package app
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/charmbracelet/huh"
)
func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".claude", "skills")
source := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
for _, name := range []string{"dws", "dingtalk-a", "dingtalk-stale"} {
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return source, func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{dest}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupInteractive, func() bool { return true })
testseam.Swap(t, &skillSetupRunForm, func(*huh.Form) error { return nil })
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
wantBackups := []string{
filepath.Join(dest, "dingtalk-a"),
filepath.Join(dest, "dingtalk-stale"),
filepath.Join(dest, "dws"),
}
// Dry-run must disclose every exact path and perform no backup or copy.
backupCalls, copyCalls := []string{}, 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
backupCalls = append(backupCalls, path)
return "backup", nil
})
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(src, dest)
})
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var dryRunOut bytes.Buffer
dryRunCmd.SetOut(&dryRunOut)
if err := dryRunCmd.Root().PersistentFlags().Set("dry-run", "true"); err != nil {
t.Fatal(err)
}
if err := dryRunCmd.RunE(dryRunCmd, nil); err != nil {
t.Fatal(err)
}
for _, path := range wantBackups {
if strings.Count(dryRunOut.String(), path) != 1 {
t.Fatalf("dry-run path %s count != 1:\n%s", path, dryRunOut.String())
}
}
if len(backupCalls) != 0 || copyCalls != 0 {
t.Fatalf("dry-run mutated backup=%v copy=%d", backupCalls, copyCalls)
}
// The real confirmation renderer discloses the same paths. Its default
// negative answer must leave backup and copy at zero calls.
declineCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var declineOut bytes.Buffer
declineCmd.SetOut(&declineOut)
if err := declineCmd.RunE(declineCmd, nil); err != nil {
t.Fatal(err)
}
for _, path := range wantBackups {
if strings.Count(declineOut.String(), path) != 1 {
t.Fatalf("confirmation path %s count != 1:\n%s", path, declineOut.String())
}
}
if len(backupCalls) != 0 || copyCalls != 0 {
t.Fatalf("declined confirmation mutated backup=%v copy=%d", backupCalls, copyCalls)
}
// Explicit confirmation executes exactly the paths rendered from the plan.
var confirmedPlan *skillSetupPlan
testseam.Swap(t, &skillSetupConfirmPlan, func(out io.Writer, plan *skillSetupPlan) (bool, error) {
confirmedPlan = plan
renderSkillSetupPlan(out, plan)
return true, nil
})
confirmCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
if err := confirmCmd.RunE(confirmCmd, nil); err != nil {
t.Fatal(err)
}
var planned []string
for _, target := range confirmedPlan.Targets {
for _, backup := range target.Backups {
planned = append(planned, backup.Path)
}
}
if !reflect.DeepEqual(planned, wantBackups) || !reflect.DeepEqual(backupCalls, wantBackups) {
t.Fatalf("planned=%v executed=%v want=%v", planned, backupCalls, wantBackups)
}
if copyCalls != 2 {
t.Fatalf("copy calls = %d, want 2", copyCalls)
}
// A filtered multi plan replaces only selected same-name skills and leaves
// unselected siblings out of the backup set.
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
if err != nil {
t.Fatal(err)
}
var filteredPaths []string
for _, backup := range filtered.Targets[0].Backups {
filteredPaths = append(filteredPaths, backup.Path)
}
if !reflect.DeepEqual(filteredPaths, []string{filepath.Join(dest, "dingtalk-a"), filepath.Join(dest, "dws")}) {
t.Fatalf("filtered backups = %v", filteredPaths)
}
}
func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".agents", "skills", "dws")
if err := os.MkdirAll(dest, 0o755); err != nil {
t.Fatal(err)
}
plan, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{dest}, nil, false)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 1 || len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != dest || plan.Targets[0].Backups[0].Reason != skillSetupBackupReplace {
t.Fatalf("mono plan = %#v", plan)
}
}
func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
// "dws" is synthetic but makes the mutual-exclusion target and selected
// same-name target overlap, pinning path deduplication in the plan itself.
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dws"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != filepath.Join(dest, "dws") {
t.Fatalf("deduplicated plan = %#v", plan)
}
failure := errors.New("scan denied")
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if err := os.MkdirAll(filepath.Dir(monoDest), 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") {
t.Fatalf("replacement stat error = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") {
t.Fatalf("stale scan error = %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupInstallWrappersFailOnPlanErrors(t *testing.T) {
t.Run("multi mono-leftover stat failure", func(t *testing.T) {
failure := errors.New("stat denied")
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
return nil, failure
})
installed, skipped, err := installMultiSkillToHomes(
"source",
[]string{"dingtalk-a"},
[]string{filepath.Join(t.TempDir(), "skills")},
io.Discard,
io.Discard,
true,
)
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
t.Fatalf("installMultiSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
}
})
t.Run("mono multi-leftover scan failure", func(t *testing.T) {
failure := errors.New("scan denied")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) {
return nil, failure
})
installed, skipped, err := installSkillToHomes(
"source",
[]string{filepath.Join(t.TempDir(), "skills", "dws")},
io.Discard,
io.Discard,
)
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
t.Fatalf("installSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
}
})
}
func TestCrossPlatformCoverageSkillSetupMergedEventPlanEdges(t *testing.T) {
t.Run("legacy shared stat failure", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
failure := errors.New("legacy stat denied")
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(dest, legacySharedSkill) {
return nil, failure
}
return nil, os.ErrNotExist
})
_, err := buildSkillSetupPlan(
skillSetupModeMulti,
"source",
[]string{dest},
[]string{multiSharedSkill},
true,
)
if !errors.Is(err, failure) {
t.Fatalf("legacy shared stat error = %v, want %v", err, failure)
}
})
t.Run("migration plan retains unrelated backups", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
unrelated := filepath.Join(dest, "dws")
plan := &skillSetupPlan{Targets: []skillSetupTargetPlan{
{
Destination: dest,
Backups: []skillSetupBackup{
{Path: filepath.Join(dest, multiEventSkill)},
{Path: filepath.Join(dest, multiMiscSkill)},
{Path: unrelated},
},
},
}}
configureEventMiscMigrationPlan(plan, []string{dest}, true)
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != unrelated {
t.Fatalf("migration backups = %#v, want only %s", plan.Targets[0].Backups, unrelated)
}
})
t.Run("no migration targets delegates filtered install", func(t *testing.T) {
called := false
testseam.Swap(t, &skillSetupInstallMulti, func(_ string, _ []string, _ []string, _ io.Writer, _ io.Writer, filtered bool) (int, int, error) {
called = true
if !filtered {
t.Fatal("filtered flag was not forwarded")
}
return 1, 2, nil
})
installed, skipped, err := installMultiSkillsWithEventMigration(
"source", []string{"dingtalk-a"}, []string{"dest"}, nil, true, io.Discard, io.Discard,
)
if err != nil || installed != 1 || skipped != 2 || !called {
t.Fatalf("delegated install = (%d, %d, %v), called=%v", installed, skipped, err, called)
}
})
t.Run("migration cleanup scan failure", func(t *testing.T) {
failure := errors.New("cleanup stat denied")
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
return nil, failure
})
dest := filepath.Join(t.TempDir(), "skills")
installed, skipped, err := installMultiSkillsWithEventMigration(
"source",
[]string{multiEventSkill, multiSharedSkill},
[]string{dest},
[]string{dest},
true,
io.Discard,
io.Discard,
)
if installed != 0 || skipped != 2 || !errors.Is(err, failure) {
t.Fatalf("cleanup scan failure = (%d, %d, %v), want (0, 2, %v)", installed, skipped, err, failure)
}
})
}
func TestCrossPlatformCoverageSkillSetupEmptyCleanupPlansAreNoOps(t *testing.T) {
dest := t.TempDir()
var out, errOut bytes.Buffer
if err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut); err != nil {
t.Fatal(err)
}
if err := removeStaleMultiSkills(dest, []string{"dingtalk-a"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if out.Len() != 0 || errOut.Len() != 0 {
t.Fatalf("empty cleanup output = %q / %q", out.String(), errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupSameNameBackupFailureSkipsTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
plan := &skillSetupPlan{
Mode: skillSetupModeMulti,
Source: "source",
MultiSkillNames: []string{"dingtalk-a"},
Targets: []skillSetupTargetPlan{{
Destination: dest,
Backups: []skillSetupBackup{{
Path: filepath.Join(dest, "dingtalk-a"),
Reason: skillSetupBackupReplace,
}},
}},
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
return "", errors.New("backup denied")
})
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
var out, errOut bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 || copyCalls != 1 {
t.Fatalf("same-name failure = (%d, %d, %v), copy=%d", installed, skipped, err, copyCalls)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("same-name warning = %q", errOut.String())
}
}
+190
View File
@@ -0,0 +1,190 @@
package app
import (
"errors"
"io"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillSetupPersistsOfficialSnapshot(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
return &skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-existing"}}}, true, nil
})
testseam.Swap(t, &skillSetupExecutePlan, func(plan *skillSetupPlan, _ io.Writer, _ io.Writer) (int, int, error) {
if plan.Mode == skillSetupModeMono {
return 1, 0, nil
}
return 2, 0, nil
})
testseam.Swap(t, &skillSetupNow, func() time.Time {
return time.Date(2026, 8, 10, 1, 2, 3, 0, time.UTC)
})
var saved skillstate.State
testseam.Swap(t, &skillSetupWriteState, func(_ string, state skillstate.State) error {
saved = state
return nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(saved.OfficialSkills, []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}) ||
!reflect.DeepEqual(saved.UpdatedSkills, []string{"dingtalk-shared", "dingtalk-a"}) ||
!reflect.DeepEqual(skillprovenance.Names(saved.ManagedSkills), map[string]bool{"dingtalk-a": true, "dingtalk-existing": true, "dingtalk-shared": true}) {
t.Fatalf("saved = %#v", saved)
}
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return errors.New("denied") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "信息快照失败") {
t.Fatalf("write-state error = %v", err)
}
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return errors.New("denied") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "清理 multi") {
t.Fatalf("remove-state error = %v", err)
}
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return nil })
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("no home") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "无法解析 HOME") {
t.Fatalf("home error = %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupFilteredUnreadableStateStopsBeforeInstall(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
stateErr := errors.New("state denied")
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) { return nil, false, stateErr })
executed := 0
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
executed++
return 1, 0, nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
err := cmd.RunE(cmd, nil)
if !errors.Is(err, stateErr) || executed != 0 {
t.Fatalf("filtered setup = err %v, executed %d", err, executed)
}
}
func TestCrossPlatformCoverageSkillSetupProvenancePreflightFailures(t *testing.T) {
for _, tc := range []struct {
name string
filtered bool
buildError error
homeError error
}{
{name: "digest", buildError: errors.New("digest denied")},
{name: "filtered home", filtered: true, homeError: errors.New("home denied")},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
if tc.buildError != nil {
return skillprovenance.Record{}, tc.buildError
}
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
if tc.homeError != nil {
return "", tc.homeError
}
return home, nil
})
executed := 0
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
executed++
return 1, 0, nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if tc.filtered {
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
}
err := cmd.RunE(cmd, nil)
if err == nil || executed != 0 {
t.Fatalf("preflight = err %v, executed %d", err, executed)
}
})
}
}
func TestCrossPlatformCoverageSkillSetupPartialInstallDoesNotWriteState(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) {
return []string{filepath.Join(home, "skills")}, nil
})
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
return 2, 1, nil
})
writes := 0
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
writes++
return nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
err := cmd.RunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "Skill 安装不完整") || !strings.Contains(err.Error(), "skipped=1") {
t.Fatalf("partial setup error = %v", err)
}
if writes != 0 {
t.Fatalf("partial setup wrote %d complete state snapshot(s)", writes)
}
}
+380 -24
View File
@@ -9,6 +9,10 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestSkillSetupCommandRegistered(t *testing.T) {
@@ -25,6 +29,189 @@ func TestSkillSetupCommandRegistered(t *testing.T) {
}
}
// TestCrossPlatformCoverageSkillSetupExamplesDoNotBypassConfirmation guards
// the P1 review finding: copyable examples must preview or enter the normal
// confirmation path, never carry the scripting-only confirmation bypass.
func TestCrossPlatformCoverageSkillSetupExamplesDoNotBypassConfirmation(t *testing.T) {
cmd := newSkillSetupCommand()
var examples []string
for _, line := range strings.Split(cmd.Example, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
examples = append(examples, line)
if strings.Contains(line, "--yes") {
t.Fatalf("skill setup example bypasses confirmation: %q", line)
}
}
if len(examples) != 2 || !strings.Contains(examples[0], "--dry-run") || strings.Contains(examples[1], "--dry-run") {
t.Fatalf("examples must show preview then interactive confirmation: %v", examples)
}
}
func TestCrossPlatformCoverageSkillSetupHelpDescribesFullUpgradeRefresh(t *testing.T) {
help := newSkillSetupCommand().Long
for _, want := range []string{"每次 dws upgrade", "全量覆盖预制 skill", "本地删除", "会在升级时恢复"} {
if !strings.Contains(help, want) {
t.Fatalf("skill setup help missing full-refresh contract %q:\n%s", want, help)
}
}
for _, stale := range []string{"跳过本地已删除", "--force 恢复全量"} {
if strings.Contains(help, stale) {
t.Fatalf("skill setup help still advertises retired incremental behavior %q:\n%s", stale, help)
}
}
}
// TestCrossPlatformCoverageSkillSetupDeclinedConfirmationNeverRemoves verifies
// the destructive half of the setup contract: when the user declines the
// confirmation prompt, nothing is installed and nothing is removed (neither
// the opposite-mode leftovers nor stale skills). Confirming must then run the
// exact cleanup previewed earlier: leftovers are backed up to
// ~/.dws/skill-backups/ before they disappear.
func TestCrossPlatformCoverageSkillSetupDeclinedConfirmationNeverRemoves(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-aitable", "dingtalk-calendar"})
agentHome := filepath.Join(home, ".claude", "skills")
// Opposite-mode leftover (mono dws/) plus a stale multi skill the full
// install would clean; both must survive a declined confirmation.
for _, leftover := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
if err := os.MkdirAll(leftover, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(leftover, "SKILL.md"), []byte("keep-me"), 0o644); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
oldConfirm := skillSetupConfirmPlan
t.Cleanup(func() { skillSetupConfirmPlan = oldConfirm })
// Declined confirmation: nothing may change on disk.
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
cmd := newSkillSetupCommand()
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--mode", "multi", "--target", "claude", "--source", multiSrc})
if err := cmd.Execute(); err != nil {
t.Fatalf("declined setup should succeed as a no-op: %v (%s)", err, errOut.String())
}
if !strings.Contains(out.String(), "已取消") {
t.Fatalf("expected cancellation notice, got %q", out.String())
}
for _, survivor := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
if _, err := os.Stat(filepath.Join(survivor, "SKILL.md")); err != nil {
t.Fatalf("declined confirmation removed %s: %v", survivor, err)
}
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable")); !os.IsNotExist(err) {
t.Fatalf("declined confirmation must not install either: %v", err)
}
// Confirmed: the previewed victims are backed up + removed, bundle skills land.
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return true, nil }
out.Reset()
errOut.Reset()
cmd = newSkillSetupCommand()
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--mode", "multi", "--target", "claude", "--source", multiSrc})
if err := cmd.Execute(); err != nil {
t.Fatalf("confirmed setup failed: %v (%s)", err, errOut.String())
}
for _, gone := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
if _, err := os.Stat(gone); !os.IsNotExist(err) {
t.Fatalf("confirmed setup should remove %s (stat err=%v)", gone, err)
}
}
for _, n := range []string{"dingtalk-aitable", "dingtalk-calendar"} {
if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
t.Fatalf("confirmed setup missing %s: %v", n, err)
}
}
// Every removal went through the reversible backup path, not a hard delete.
backupRoot := filepath.Join(home, ".dws", "skill-backups")
entries, err := os.ReadDir(backupRoot)
if err != nil || len(entries) == 0 {
t.Fatalf("confirmed setup must preserve victims under %s (entries=%v, err=%v)", backupRoot, entries, err)
}
if !strings.Contains(out.String(), "已备份并清理对面模式残留") || !strings.Contains(out.String(), "已备份并清理过期 skill") {
t.Fatalf("expected backup-and-remove log lines, got %q", out.String())
}
}
// TestCrossPlatformCoverageSkillSetupNonInteractiveRequiresYes pins the real
// non-TTY safety boundary: an explicit mode alone is not consent to move
// directories. The same command with --yes performs the previewed backup and
// installation.
func TestCrossPlatformCoverageSkillSetupNonInteractiveRequiresYes(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
oldInteractive := skillSetupInteractive
skillSetupInteractive = func() bool { return false }
t.Cleanup(func() { skillSetupInteractive = oldInteractive })
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-aitable"})
agentHome := filepath.Join(home, ".claude", "skills")
mono := filepath.Join(agentHome, "dws")
if err := os.MkdirAll(mono, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("keep-me"), 0o644); err != nil {
t.Fatal(err)
}
run := func(extra ...string) error {
cmd := newSkillSetupCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
args := []string{"--mode", "multi", "--target", "claude", "--source", multiSrc}
cmd.SetArgs(append(args, extra...))
return cmd.Execute()
}
if err := run(); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("non-interactive setup error = %v, want explicit --yes requirement", err)
}
if data, err := os.ReadFile(filepath.Join(mono, "SKILL.md")); err != nil || string(data) != "keep-me" {
t.Fatalf("unconfirmed setup changed mono (data=%q, err=%v)", string(data), err)
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable")); !os.IsNotExist(err) {
t.Fatalf("unconfirmed setup installed multi, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups")); !os.IsNotExist(err) {
t.Fatalf("unconfirmed setup created backup state, stat err=%v", err)
}
if err := run("--yes"); err != nil {
t.Fatalf("explicitly confirmed setup failed: %v", err)
}
if _, err := os.Stat(mono); !os.IsNotExist(err) {
t.Fatalf("confirmed setup kept mono, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("confirmed setup did not install multi: %v", err)
}
backupFound := false
_ = filepath.Walk(filepath.Join(home, ".dws", "skill-backups"), func(path string, info os.FileInfo, walkErr error) error {
if walkErr == nil && info != nil && !info.IsDir() && info.Name() == "SKILL.md" {
if data, readErr := os.ReadFile(path); readErr == nil && string(data) == "keep-me" {
backupFound = true
}
}
return nil
})
if !backupFound {
t.Fatal("confirmed setup did not back up mono")
}
}
func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
got, err := resolveSkillSetupMode("mono", true, &bytes.Buffer{})
if err != nil || got != skillSetupModeMono {
@@ -39,14 +226,14 @@ func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
}
}
func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
func TestResolveSkillSetupModeNonInteractiveDefaultsMulti(t *testing.T) {
var buf bytes.Buffer
got, err := resolveSkillSetupMode("", true, &buf)
if err != nil || got != skillSetupModeMono {
t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err)
if err != nil || got != skillSetupModeMulti {
t.Fatalf("non-interactive empty mode should default to multi, got %q err=%v", got, err)
}
if !strings.Contains(buf.String(), "mono") {
t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
if !strings.Contains(buf.String(), "multi") {
t.Fatalf("expected output to mention multi fallback, got %q", buf.String())
}
}
@@ -143,7 +330,7 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
}
}
func TestInstallSkillToHomesEndToEnd(t *testing.T) {
func TestCrossPlatformCoverageInstallSkillToHomesEndToEnd(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
t.Fatal(err)
@@ -205,7 +392,7 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
return root
}
func TestInstallMultiSkillToHomes(t *testing.T) {
func TestCrossPlatformCoverageInstallMultiSkillToHomes(t *testing.T) {
names := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"}
src := writeMultiSkillSource(t, names)
@@ -221,7 +408,7 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr, false)
if err != nil {
t.Fatalf("installMultiSkillToHomes err: %v", err)
}
@@ -245,12 +432,14 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
}
}
func TestSkillSetupMutualExclusion(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupMutualExclusion(t *testing.T) {
names := []string{"dingtalk-aitable", "dingtalk-calendar"}
src := writeMultiSkillSource(t, names)
// Simulate a pre-existing mono install under <agent-home>/dws/
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
homeRoot := t.TempDir()
setTestHome(t, homeRoot)
agentHome := filepath.Join(homeRoot, ".claude", "skills")
monoLeftover := filepath.Join(agentHome, "dws")
if err := os.MkdirAll(filepath.Join(monoLeftover, "references"), 0o755); err != nil {
t.Fatal(err)
@@ -265,13 +454,16 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
}
// Confirm mutualExclusionVictims sees the leftover
victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
victims, vErr := mutualExclusionVictims(agentHome, skillSetupModeMulti)
if vErr != nil {
t.Fatalf("mutualExclusionVictims err: %v", vErr)
}
if len(victims) != 1 || victims[0] != monoLeftover {
t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims)
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr)
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
@@ -290,7 +482,7 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
}
}
// the cleanup line should appear in stdout (best-effort observability)
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
if !strings.Contains(stdout.String(), "已备份并清理对面模式残留") {
t.Fatalf("expected cleanup log line, got stdout=%q", stdout.String())
}
@@ -318,11 +510,48 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
t.Fatalf("mono SKILL.md missing: %v", err)
}
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
if !strings.Contains(stdout.String(), "已备份并清理对面模式残留") {
t.Fatalf("expected cleanup log line on mono install, got stdout=%q", stdout.String())
}
}
func TestCrossPlatformCoverageSkillSetupMonoPreservesUnregisteredDingtalkSkill(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
base := filepath.Join(home, ".agents", "skills")
managed := filepath.Join(base, "dingtalk-managed-old")
legacyOfficial := filepath.Join(base, "dingtalk-aitable")
custom := filepath.Join(base, "dingtalk-custom")
for _, dir := range []string{managed, legacyOfficial, custom} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(filepath.Base(dir)), 0o644); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, filepath.Base(managed))
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
if err != nil || installed != 1 || skipped != 0 {
t.Fatalf("mono install = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
}
if _, err := os.Stat(managed); !os.IsNotExist(err) {
t.Fatalf("centrally managed DWS multi Skill must be removed during mono switch: %v", err)
}
if _, err := os.Stat(legacyOfficial); !os.IsNotExist(err) {
t.Fatalf("pre-state official multi Skill must be removed during mono switch: %v", err)
}
if got, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(got) != "dingtalk-custom" {
t.Fatalf("unregistered market/user dingtalk-* Skill changed: data=%q err=%v", got, err)
}
}
// TestSkillSourceCandidatesIncludesUserCache verifies that the user-level
// cache populated by install.sh / install.ps1 / npm install.js is part of the
// fallback candidate list, so `dws skill setup` can find a source on a fresh
@@ -464,10 +693,11 @@ func TestFilterMultiSkillNames(t *testing.T) {
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
// touch already-installed dingtalk-* siblings (additive semantics).
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
src := writeMultiSkillSource(t, []string{
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
})
setTestHome(t, t.TempDir())
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
// Pretend the user already installed two dingtalk-* skills earlier.
@@ -493,7 +723,7 @@ func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr, true)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
@@ -561,6 +791,135 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
}
}
// TestCrossPlatformCoverageSkillSetupMultiFullInstallCleansStale verifies that a full (unfiltered)
// multi install removes stale dingtalk-* / dws-shared directories that are no
// longer part of the bundle, matching install.sh / install.js / upgrade paths.
// The additive counterpart (filtered install) is covered by
// TestCrossPlatformCoverageSkillSetupMultiAdditivePreservesSiblings.
func TestCrossPlatformCoverageSkillSetupMultiFullInstallCleansStale(t *testing.T) {
names := []string{"dingtalk-aitable"}
src := writeMultiSkillSource(t, names)
homeRoot := t.TempDir()
setTestHome(t, homeRoot)
agentHome := filepath.Join(homeRoot, ".claude", "skills")
// Stale multi skills absent from the bundle, plus a non-DWS dir that must survive.
for _, n := range []string{"dingtalk-stale", "dws-shared", "other-skill"} {
dir := filepath.Join(agentHome, n)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
custom := filepath.Join(agentHome, "dingtalk-custom")
if err := os.MkdirAll(custom, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
if installed != 1 || skipped != 0 {
t.Fatalf("expected installed=1 skipped=0, got %d/%d", installed, skipped)
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Errorf("missing installed skill: %v", err)
}
for _, stale := range []string{"dingtalk-stale", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentHome, stale)); !os.IsNotExist(err) {
t.Errorf("stale %q should be removed by a full multi install (stat err=%v)", stale, err)
}
}
body, err := os.ReadFile(filepath.Join(agentHome, "other-skill", "SKILL.md"))
if err != nil || !strings.HasPrefix(string(body), "OLD ") {
t.Errorf("non-DWS dir must be preserved (body=%q, err=%v)", string(body), err)
}
if body, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(body) != "market skill" {
t.Errorf("unregistered market/user dingtalk-* dir must survive (body=%q, err=%v)", string(body), err)
}
if !strings.Contains(stdout.String(), "已备份并清理过期 skill") {
t.Errorf("expected stale cleanup log line, got stdout=%q", stdout.String())
}
}
// TestSkillSetupMutualExclusionScanWarning verifies that a victim-scan failure
// surfaces as an errOut warning instead of silently skipping cleanup.
func TestCrossPlatformCoverageSkillSetupMutualExclusionScanWarning(t *testing.T) {
oldReadDir := skillSetupReadDir
t.Cleanup(func() { skillSetupReadDir = oldReadDir })
scanFail := errors.New("scan boom")
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, scanFail }
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if _, err := mutualExclusionVictims(monoDest, skillSetupModeMono); err == nil {
t.Fatal("scan failure should surface as an error")
}
var out, errOut bytes.Buffer
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
if !strings.Contains(errOut.String(), "互斥清理扫描失败") {
t.Fatalf("expected scan warning on errOut, got %q", errOut.String())
}
}
// TestRunSkillSetupThreadsFilteredFlag verifies runSkillSetup tells
// installMultiSkillToHomes whether -s/--skill or -x/--exclude was used, so a
// full install cleans stale siblings while a filtered install stays additive.
func TestRunSkillSetupThreadsFilteredFlag(t *testing.T) {
oldMode, oldSource, oldTargets := skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets
oldList, oldFilter, oldExecute := skillSetupListMulti, skillSetupFilterMulti, skillSetupExecutePlan
t.Cleanup(func() {
skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets = oldMode, oldSource, oldTargets
skillSetupListMulti, skillSetupFilterMulti, skillSetupExecutePlan = oldList, oldFilter, oldExecute
})
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
skillSetupResolveTargets = func(string, string) ([]string, error) {
return []string{filepath.Join(t.TempDir(), "dest")}, nil
}
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-aitable", "dws-shared"}, nil }
skillSetupFilterMulti = filterMultiSkillNames
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
var gotFiltered []bool
skillSetupExecutePlan = func(plan *skillSetupPlan, _, _ io.Writer) (int, int, error) {
gotFiltered = append(gotFiltered, plan.Filtered)
return 1, 0, nil
}
// Full install (no -s/-x): filtered must be false.
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("full install run err: %v", err)
}
// Filtered install: filtered must be true.
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "aitable"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("filtered install run err: %v", err)
}
if len(gotFiltered) != 2 || gotFiltered[0] != false || gotFiltered[1] != true {
t.Fatalf("filtered flag threading = %v, want [false true]", gotFiltered)
}
}
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
t.Helper()
originalTargets := skillSetupResolveTargets
@@ -633,7 +992,7 @@ func assertNoEventMigrationStages(t *testing.T, agentHome string) {
}
}
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
@@ -653,13 +1012,10 @@ func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
if !strings.Contains(stdout, "迁移伴侣") || !strings.Contains(stdout, foldedHome) {
t.Fatalf("confirmation output should expose folded misc migration: %s", stdout)
}
if !strings.Contains(stdout, "重新加载 Skills") {
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
}
@@ -703,7 +1059,7 @@ func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
}
}
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
@@ -854,7 +1210,7 @@ func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("misc migration companion ran after a primary install skip")
@@ -891,7 +1247,7 @@ func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("folded target prerequisites ran after fresh target failure")
+9 -5
View File
@@ -57,9 +57,9 @@ var (
downloadUpgradeProgress = upgrade.DownloadWithProgress
extractUpgradeZip = upgrade.ExtractZip
findExtractedBinary = upgrade.FindBinaryInDir
locateUpgradeSkill = upgrade.LocateSkillMD
locateUpgradeSkill = upgrade.LocateSkillsRoot
replaceUpgradeSelf = upgrade.ReplaceSelf
installUpgradeSkills = upgrade.UpgradeSkillLocations
installUpgradeSkills = upgrade.UpgradeSkillLocationsWithOptions
upgradeMkdirTemp = os.MkdirTemp
upgradeRemoveAll = os.RemoveAll
upgradeReadFile = os.ReadFile
@@ -99,7 +99,8 @@ func newUpgradeCommand() *cobra.Command {
Long: `检查并升级 DWS CLI 到最新版本。
自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
升级前会自动备份当前版本,可通过 --rollback 回滚。
每次升级都会按新版本官方清单全量覆盖预制 Skill;--force 仅额外允许重装当前版本。`,
Example: ` dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出最近版本
@@ -107,6 +108,7 @@ func newUpgradeCommand() *cobra.Command {
dws upgrade --beta # 升级到最新 beta 预发布版本
dws upgrade --version v1.0.7 # 升级到指定正式版本
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
dws upgrade --force # 即使已是最新版本也重装当前版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
dws upgrade -y # 跳过确认直接升级`,
@@ -158,7 +160,7 @@ func newUpgradeCommand() *cobra.Command {
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
cmd.Flags().BoolVar(&flagBeta, "beta", false, "使用最新 beta 预发布版本(默认使用正式 release)")
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "即使已是最新版本也强制重新安装当前版本")
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
return cmd
@@ -596,7 +598,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
}
if hasSkills {
result, installErr := installUpgradeSkills(skillSrc)
result, installErr := installUpgradeSkills(skillSrc, upgrade.SkillUpgradeOptions{
Version: release.Version,
})
if installErr != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("技能包安装失败: %w", installErr)
+1 -1
View File
@@ -285,7 +285,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
}
return nil
}
installUpgradeSkills = func(string) (*upgradepkg.SkillUpgradeResult, error) {
installUpgradeSkills = func(string, upgradepkg.SkillUpgradeOptions) (*upgradepkg.SkillUpgradeResult, error) {
if stage == "install" {
return nil, fail
}
@@ -0,0 +1,156 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
// TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti is the fake-HOME E2E for
// the 2026-08-05 owner decision: upgrade is not disk-sticky. Seeding a mono
// layout then calling UpgradeSkillLocations with a multi bundle must install
// product skills, remove dws/, and leave non-DWS dirs alone.
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
agentsBase := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644); err != nil {
t.Fatal(err)
}
extract := t.TempDir()
multiRoot := filepath.Join(extract, "multi")
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
dir := filepath.Join(multiRoot, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
t.Fatal(err)
}
}
result, err := upgrade.UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("expected 0 failures, got %v", failed)
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("mono leftover dws/ must be gone, stat err=%v", err)
}
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Errorf("multi skill missing: %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
}
// TestUpgradeSkillLocationsRealBundleMigratesMono runs the upgrade path against
// the repository's actual multi bundle (skills/multi, post-#887 layout with
// dingtalk-shared). Disk is seeded with a mono install plus the leftovers the
// rename and stale releases leave behind: a pre-rename dws-shared directory
// and a dingtalk-* skill no longer in the bundle. All three must be gone after
// the upgrade, every bundle skill installed, non-DWS dirs untouched, and the
// ~/.dws/skills/multi cache refreshed.
func TestCrossPlatformCoverageUpgradeSkillLocationsRealBundleMigratesMono(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
bundle := filepath.Join("..", "..", "skills", "multi")
entries, err := os.ReadDir(bundle)
if err != nil {
t.Fatalf("real multi bundle missing: %v", err)
}
var bundleNames []string
for _, e := range entries {
if e.IsDir() {
bundleNames = append(bundleNames, e.Name())
}
}
if len(bundleNames) == 0 {
t.Fatal("real multi bundle is empty")
}
agentsBase := filepath.Join(home, ".agents", "skills")
// Mono install plus pre-rename shared and a stale product skill.
for _, name := range []string{"dws", "dws-shared", "dingtalk-stale", "other-skill"} {
dir := filepath.Join(agentsBase, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
t.Fatal(err)
}
}
if err := skillstate.Write(home, skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-stale"}}}); err != nil {
t.Fatal(err)
}
custom := filepath.Join(agentsBase, "dingtalk-custom")
if err := os.MkdirAll(custom, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
t.Fatal(err)
}
result, err := upgrade.UpgradeSkillLocations(bundle)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("expected 0 failures, got %v", failed)
}
for _, stale := range []string{"dws", "dws-shared", "dingtalk-stale"} {
if _, err := os.Stat(filepath.Join(agentsBase, stale)); !os.IsNotExist(err) {
t.Errorf("leftover %q must be removed by the upgrade, stat err=%v", stale, err)
}
}
for _, name := range bundleNames {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Errorf("bundle skill %q missing after upgrade: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
if _, err := os.Stat(filepath.Join(custom, "SKILL.md")); err != nil {
t.Errorf("unregistered market/user dingtalk-* dir should be preserved: %v", err)
}
// Shared skill must come from the renamed bundle dir, never the legacy name.
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-shared", "SKILL.md")); err != nil {
t.Errorf("dingtalk-shared missing: %v", err)
}
// Cache refresh so `dws skill setup` fallbacks stay on the upgraded version.
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "SKILL.md")); err != nil {
// multi cache mirrors the bundle root, which has no top-level SKILL.md;
// check a bundle skill inside the cache instead.
if _, err2 := os.Stat(filepath.Join(home, ".dws", "skills", "multi", bundleNames[0], "SKILL.md")); err2 != nil {
t.Errorf("multi cache not refreshed: %v / %v", err, err2)
}
}
}
+21
View File
@@ -18,6 +18,27 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageUpgradeHelpDoesNotMakeFullSkillRefreshForceOnly(t *testing.T) {
cmd := newUpgradeCommand()
force := cmd.Flags().Lookup("force")
if force == nil {
t.Fatal("upgrade --force flag is missing")
}
for _, text := range []string{cmd.Example, force.Usage} {
if strings.Contains(text, "恢复全部官方 Skill") {
t.Fatalf("upgrade help still implies that only --force performs the full Skill refresh: %q", text)
}
}
if !strings.Contains(force.Usage, "已是最新版本") {
t.Fatalf("upgrade --force help must explain its remaining purpose, got %q", force.Usage)
}
for _, want := range []string{"每次升级", "全量覆盖预制 Skill", "--force 仅额外允许重装当前版本"} {
if !strings.Contains(cmd.Long, want) {
t.Fatalf("upgrade help missing full-refresh contract %q: %s", want, cmd.Long)
}
}
}
// --- ensureV ---
func TestEnsureV(t *testing.T) {
+2
View File
@@ -1356,6 +1356,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
{"chat message edit", "conversation-id", "openConversationId", true, ""},
{"chat message edit", "msg-id", "openMessageId", true, ""},
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message send-card", "at-all", "atAll", false, ""},
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
+21 -3
View File
@@ -5721,27 +5721,41 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
Use: "send-card",
Short: "创建并推送流式卡片",
Long: `向群聊或单聊创建并推送流式卡片。群聊传 --group,单聊传 --receiver,二者互斥。
群聊创建卡片时可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人。
创建时无需传入卡片内容,后续通过 update-card 更新内容。
注意:send-card 必须和 update-card 搭配使用。发送卡片后,使用返回的 bizId 调用 update-card 更新内容,
最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。`,
Example: ` dws chat message send-card --group <openConversationId>
dws chat message send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId>
dws chat message send-card --group <openConversationId> --at-all
dws chat message send-card --receiver <openDingTalkId>
# 查询群 ID: dws chat search --query "群名"
# 查询人员: dws contact user search --keyword "姓名" --format json`,
RunE: func(cmd *cobra.Command, args []string) error {
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
receiver, _ := cmd.Flags().GetString("receiver")
atOpenDingTalkIDs := uniqueNonEmptyStrings(parseCSVValues(mustGetFlag(cmd, "at-open-dingtalk-ids")))
atAll, _ := cmd.Flags().GetBool("at-all")
if groupID == "" && receiver == "" {
return fmt.Errorf("--group or --receiver is required")
}
if groupID != "" && receiver != "" {
return fmt.Errorf("--group and --receiver are mutually exclusive")
}
if groupID == "" && (len(atOpenDingTalkIDs) > 0 || atAll) {
return fmt.Errorf("--at-open-dingtalk-ids and --at-all are only supported with --group")
}
toolArgs := map[string]any{}
if groupID != "" {
toolArgs["openConversationId"] = groupID
if len(atOpenDingTalkIDs) > 0 {
toolArgs["atOpenDingTalkIds"] = atOpenDingTalkIDs
}
if atAll {
toolArgs["atAll"] = true
}
}
if receiver != "" {
resolved, err := resolveOpenDingTalkID(cmd.Context(), receiver)
@@ -5766,19 +5780,21 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
CLIPath: "chat message send-card",
PrimaryCLIPath: "chat message send-card",
},
Description: "创建并向群聊或单聊发送互动卡片",
Description: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_and_send_card"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建并向群聊或单聊发送互动卡片",
UseWhen: []string{"需要卡片式交互且已准备接收会话或用户时"},
AgentSummary: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人",
UseWhen: []string{"需要创建卡片且已准备接收会话或用户时;群聊创建可同时指定 @成员或 @所有人"},
AvoidWhen: []string{"只发送普通文本时使用 send 或 send-by-bot"},
Examples: []string{"dws chat message send-card --group <openConversationId>"},
},
Parameters: []contract.ParamDecl{
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
{Name: "group", Property: "openConversationId"},
{Name: "receiver", Property: "receiverOpenDingTalkId"},
},
@@ -5786,6 +5802,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
})
chatMessageSendCardCmd.Flags().String("group", "", "群聊 openConversationId(群聊时必填,与 --receiver 互斥)")
chatMessageSendCardCmd.Flags().String("receiver", "", "单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)")
chatMessageSendCardCmd.Flags().String("at-open-dingtalk-ids", "", "群聊创建卡片时 @ 的 openDingTalkId 列表,逗号分隔(仅与 --group 一起使用)")
chatMessageSendCardCmd.Flags().Bool("at-all", false, "群聊创建卡片时 @ 所有人(仅与 --group 一起使用)")
chatMessageUpdateCardCmd := &cobra.Command{
Use: "update-card",
+6 -4
View File
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
}
})
t.Run("generic success is unverified", func(t *testing.T) {
t.Run("success acknowledgement is verified", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
@@ -85,9 +85,11 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
"--content", "完成",
"--flow-status", "3",
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.tool != "update_streaming_card" {
t.Fatalf("call = count:%d tool:%q", caller.calls, caller.tool)
}
})
@@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -272,6 +273,52 @@ func TestCrossPlatformCoverageChatCreateAndMessageSendEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
t.Run("group forwards mention arguments", func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"message", "send-card",
"--group=cid",
"--at-open-dingtalk-ids=D1,D2,D1",
"--at-all",
)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"openConversationId": "cid",
"atOpenDingTalkIds": []string{"D1", "D2"},
"atAll": true,
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, want) {
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
}
})
for _, tc := range []struct {
name string
args []string
}{
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
t.Fatalf("error = %v, want group-only mention validation", err)
}
if caller.calls != 0 {
t.Fatalf("invalid direct-message mentions made %d tool calls", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
+79
View File
@@ -281,6 +281,85 @@ func parseDriveUploadInfo(text string) (resourceURL, uploadID string, headers ma
return
}
// DriveUploadRequest describes the reusable Drive upload transaction used by
// the native leaf and the curated +upload shortcut. FilePath must already be
// resolved and validated by the caller.
type DriveUploadRequest struct {
FilePath string
FileName string
FileSize int64
SpaceID string
ParentID string
OverwriteFile string
MIMEType string
}
// UploadDriveFileData runs credentials -> OSS PUT -> commit exactly once and
// returns the parsed commit response without rendering it. Unlike the legacy
// leaf helper, this path fails when the commit has no non-empty JSON response;
// the Shortcut can then require terminal success evidence and read back the
// created node before reporting success.
func UploadDriveFileData(ctx context.Context, request DriveUploadRequest) (map[string]any, error) {
if strings.TrimSpace(request.FilePath) == "" || strings.TrimSpace(request.FileName) == "" || request.FileSize <= 0 {
return nil, fmt.Errorf("invalid drive upload request")
}
step1Args := map[string]any{
"fileName": request.FileName,
"fileSize": float64(request.FileSize),
}
if request.SpaceID != "" {
step1Args["spaceId"] = request.SpaceID
}
if request.MIMEType != "" {
step1Args["mimeType"] = request.MIMEType
}
if request.OverwriteFile != "" {
step1Args["overwriteFileId"] = request.OverwriteFile
} else if request.ParentID != "" {
step1Args["parentId"] = request.ParentID
}
credentialText, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_upload_info", step1Args)
if err != nil {
return nil, err
}
uploadID, err := driveUploadPut(ctx, credentialText, func(refreshCtx context.Context) (string, error) {
return callMCPToolReturnTextOnServer(refreshCtx, "drive", "get_upload_info", step1Args)
}, request.FilePath, request.FileSize)
if err != nil {
return nil, err
}
commitArgs := map[string]any{
"fileName": request.FileName,
"fileSize": float64(request.FileSize),
"uploadId": uploadID,
}
if request.SpaceID != "" {
commitArgs["spaceId"] = request.SpaceID
}
if request.OverwriteFile != "" {
commitArgs["overwriteFileId"] = request.OverwriteFile
} else if request.ParentID != "" {
commitArgs["parentId"] = request.ParentID
}
commitText, err := callMCPToolReturnTextOnServer(ctx, "drive", "commit_upload", commitArgs)
if err != nil {
return nil, err
}
if strings.TrimSpace(commitText) == "" {
return nil, fmt.Errorf("commit_upload returned no business result; remote effect is unknown")
}
var result map[string]any
if err := json.Unmarshal([]byte(commitText), &result); err != nil {
return nil, fmt.Errorf("parse commit_upload response: %w", err)
}
if len(result) == 0 {
return nil, fmt.Errorf("commit_upload returned an empty JSON object; remote effect is unknown")
}
return result, nil
}
func newDriveCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/drive.json
// products.drive). Catalog assembly stamps provenance contract_final.
@@ -154,6 +154,88 @@ func TestCrossPlatformCoverageDriveUploadTransportCoverage(t *testing.T) {
})
}
func TestCrossPlatformCoverageUploadDriveFileDataStrictTransaction(t *testing.T) {
credential := `{"result":{"uploadType":"httpToCenterWithToken","resourceUrl":"https://c.example.com/u?upload_key=u1","uploadId":"u1","headers":{"dentry-token":"token"}}}`
request := DriveUploadRequest{FilePath: "fixture.bin", FileName: "fixture.bin", FileSize: 7, SpaceID: "space", ParentID: "folder", MIMEType: "application/octet-stream"}
t.Run("success with parent", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: `{"success":true,"result":{"fileId":"n1"}}`}}}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
result, err := UploadDriveFileData(context.Background(), request)
if err != nil || result["success"] != true || caller.calls != 2 {
t.Fatalf("result=%v calls=%d error=%v", result, caller.calls, err)
}
if caller.args["spaceId"] != "space" || caller.args["parentId"] != "folder" {
t.Fatalf("commit args=%v", caller.args)
}
})
t.Run("success with overwrite", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: `{"success":true}`}}}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
overwrite := request
overwrite.ParentID = "ignored"
overwrite.OverwriteFile = "existing"
if _, err := UploadDriveFileData(context.Background(), overwrite); err != nil {
t.Fatal(err)
}
if caller.args["overwriteFileId"] != "existing" {
t.Fatalf("commit args=%v", caller.args)
}
})
t.Run("credential refresh callback", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}, {text: credential}, {text: `{"success":true}`}}}
installScriptedCaller(t, caller)
putCalls := 0
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error {
putCalls++
if putCalls == 1 {
return &httpStatusError{StatusCode: 401, Body: "expired"}
}
return nil
})
t.Cleanup(func() { SetHTTPPutFile(nil) })
if _, err := UploadDriveFileData(context.Background(), request); err != nil {
t.Fatal(err)
}
if caller.calls != 3 || putCalls != 2 {
t.Fatalf("caller calls=%d put calls=%d", caller.calls, putCalls)
}
})
for _, tc := range []struct {
name string
request DriveUploadRequest
steps []scriptedToolStep
putErr error
want string
}{
{name: "invalid request", request: DriveUploadRequest{}, want: "invalid drive upload request"},
{name: "credential failure", request: request, steps: []scriptedToolStep{{err: errors.New("credentials")}}, want: "credentials"},
{name: "put failure", request: request, steps: []scriptedToolStep{{text: credential}}, putErr: errors.New("put failed"), want: "put failed"},
{name: "commit failure", request: request, steps: []scriptedToolStep{{text: credential}, {err: errors.New("commit failed")}}, want: "commit failed"},
{name: "empty commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: " "}}, want: "no business result"},
{name: "malformed commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: "{"}}, want: "parse commit_upload response"},
{name: "empty object commit", request: request, steps: []scriptedToolStep{{text: credential}, {text: `{}`}}, want: "empty JSON object"},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: tc.steps}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return tc.putErr })
t.Cleanup(func() { SetHTTPPutFile(nil) })
_, err := UploadDriveFileData(context.Background(), tc.request)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error=%v, want %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageDriveCommandRemainingEdges(t *testing.T) {
file := filepath.Join(t.TempDir(), "fixture.txt")
_ = os.WriteFile(file, []byte("fixture"), 0o600)
+82 -5
View File
@@ -1,10 +1,13 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
@@ -1322,10 +1325,13 @@ ORG 类型授权不会出现在查询结果中。`,
通过 --workspace 指定知识库,支持传入知识库 ID 或知识库 URL。
支持分页,通过 --cursor 传入上次返回的 nextToken 获取下一页。
权限要求:调用者需具备知识库的成员权限,非成员会被拒绝访问。`,
使用 --exclude-file 可排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞等)。
当用户要求"只看文档操作""排除文件上传"等意图时,必须使用此 flag,
禁止在客户端自行过滤。`,
Example: ` dws wiki feed list --workspace <workspaceId>
dws wiki feed list --workspace <workspaceId> --limit 10
dws wiki feed list --workspace <workspaceId> --exclude-file
dws wiki feed list --workspace <workspaceId> --limit 10 --exclude-file
dws wiki feed list --workspace <workspaceId> --cursor <nextToken>`,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
@@ -1345,7 +1351,19 @@ ORG 类型授权不会出现在查询结果中。`,
v, _ := cmd.Flags().GetBool("exclude-file")
toolArgs["excludeFile"] = v
}
return callMCPTool("list_workspace_feeds", toolArgs)
// 调用后对 feeds[].time 毫秒时间戳做本地化格式化,
// 使 Agent 直接看到可读时间,无需自行转换
text, err := callMCPToolReturnText(context.Background(), "list_workspace_feeds", toolArgs)
if err != nil {
return err
}
// 保持与共享 dispatcher (callMCPToolInternalOpts) 一致的输出契约:
// 仅 --format json 做时间/标签增强,raw、table 及其他格式输出原始 MCP 文本
if deps.Caller.Format() != "json" {
deps.Out.PrintRaw(text)
return nil
}
return deps.Out.PrintJSON(formatFeedTime(text))
},
}
DeclareLeafMetadata(feedListCmd, LeafSpec{
@@ -1391,9 +1409,9 @@ ORG 类型授权不会出现在查询结果中。`,
},
})
feedListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 20,最大 50)")
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10")
feedListCmd.Flags().String("cursor", "", "分页游标 (首页留空)")
feedListCmd.Flags().Bool("exclude-file", false, "是否排除文件相关的动态 (默认 false)")
feedListCmd.Flags().Bool("exclude-file", false, "排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞)。用户要求排除文件/只看文档操作时必须使用此 flag,禁止客户端过滤")
feedListCmd.Flags().String("workspace-id", "", "")
_ = feedListCmd.Flags().MarkHidden("workspace-id")
RegisterCrossProductAliases(feedListCmd)
@@ -1430,3 +1448,62 @@ ORG 类型授权不会出现在查询结果中。`,
return root
}
// beijingLoc 用于将时间戳格式化为北京时间(UTC+8)
var beijingLoc = time.FixedZone("CST", 8*3600)
// formatFeedTime 将知识库动态 feeds[].time 毫秒时间戳转为可读时间字符串,
// 写入 timeFormatted 字段,同时保留原始 time 毫秒时间戳不变(避免破坏已有脚本)。
// 解析失败时原样返回,不阻断输出。
func formatFeedTime(raw string) any {
var result map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return raw
}
feeds, ok := result["feeds"].([]any)
if !ok {
return result
}
for _, f := range feeds {
item, ok := f.(map[string]any)
if !ok {
continue
}
ms, ok := item["time"].(float64)
if !ok || ms <= 0 {
continue
}
// 保留原始 time 毫秒时间戳,新增 timeFormatted 可读字段
item["timeFormatted"] = time.UnixMilli(int64(ms)).In(beijingLoc).Format("2006-01-02 15:04")
enrichFeedFields(item)
}
return result
}
// enrichFeedFields 为单条 feed 补充可读字段(typeLabel),
func enrichFeedFields(item map[string]any) {
// 将 type 数字映射为可读标签,使 Agent 无需查表即可直接展示
if typeNum, ok := item["type"].(float64); ok {
if label, known := feedTypeLabels[int(typeNum)]; known {
item["typeLabel"] = label
}
}
}
// feedTypeLabels 将知识库动态 type 数字映射为可读中文标签,
// 与 lippi-combo OpenFeedItemDTO.type 枚举对齐。
var feedTypeLabels = map[int]string{
0: "创建文档",
1: "更新文档",
2: "评论文档",
3: "点赞文档",
4: "加入团队空间",
5: "表格选区数据变更",
6: "更新 office 文件",
7: "上传普通文件",
8: "上传媒体文件",
9: "上传文件夹",
10: "上传文件夹 V2",
11: "加入团队",
12: "创建知识库",
}
+582
View File
@@ -0,0 +1,582 @@
package helpers
import (
"bytes"
"encoding/json"
"io"
"os"
"strings"
"testing"
)
// ---------- formatFeedTime unit tests ----------
func TestCrossPlatformCoverageFormatFeedTimeInvalidJSON(t *testing.T) {
// JSON 解析失败 → 原样返回原始字符串
raw := "not-json"
got := formatFeedTime(raw)
s, ok := got.(string)
if !ok || s != raw {
t.Fatalf("formatFeedTime(invalid) = %v (%T), want raw string %q", got, got, raw)
}
}
func TestCrossPlatformCoverageFormatFeedTimeEmptyInput(t *testing.T) {
// 空字符串 → JSON 解析失败 → 原样返回
got := formatFeedTime("")
s, ok := got.(string)
if !ok || s != "" {
t.Fatalf("formatFeedTime(\"\") = %v (%T), want \"\"", got, got)
}
}
func TestCrossPlatformCoverageFormatFeedTimeNoFeedsKey(t *testing.T) {
// 合法 JSON 但没有 feeds 字段 → 返回解析后的 map
raw := `{"status":"ok","count":0}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("formatFeedTime(no feeds) = %T, want map[string]any", got)
}
if m["status"] != "ok" {
t.Fatalf("status = %v, want ok", m["status"])
}
}
func TestCrossPlatformCoverageFormatFeedTimeFeedsNotArray(t *testing.T) {
// feeds 不是数组 → 返回解析后的 map
raw := `{"feeds":"unexpected-string"}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("formatFeedTime(feeds string) = %T, want map[string]any", got)
}
if m["feeds"] != "unexpected-string" {
t.Fatalf("feeds = %v, want unexpected-string", m["feeds"])
}
}
func TestCrossPlatformCoverageFormatFeedTimeFeedsNull(t *testing.T) {
// feeds 为 null → 类型断言失败 → 返回解析后的 map
raw := `{"feeds":null}`
got := formatFeedTime(raw)
_, ok := got.(map[string]any)
if !ok {
t.Fatalf("formatFeedTime(feeds null) = %T, want map[string]any", got)
}
}
func TestCrossPlatformCoverageFormatFeedTimeNonMapItem(t *testing.T) {
// feeds 数组元素不是 object → continue 跳过
raw := `{"feeds":["string-item", 42, null]}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", got)
}
feeds := m["feeds"].([]any)
if len(feeds) != 3 {
t.Fatalf("feeds length = %d, want 3", len(feeds))
}
}
func TestCrossPlatformCoverageFormatFeedTimeTimeMissingOrInvalid(t *testing.T) {
// time 字段缺失或不是 float64 或 <= 0 → 跳过格式化
raw := `{"feeds":[
{"id":"a"},
{"id":"b","time":"not-a-number"},
{"id":"c","time":0},
{"id":"d","time":-1000}
]}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", got)
}
feeds := m["feeds"].([]any)
for i, f := range feeds {
item := f.(map[string]any)
if _, hasTimeMs := item["timeMs"]; hasTimeMs {
t.Fatalf("feed[%d] should not have timeMs, got %v", i, item)
}
}
}
func TestCrossPlatformCoverageFormatFeedTimeNormalPath(t *testing.T) {
// 正常路径:time 保留原始毫秒时间戳,timeFormatted 被格式化为北京时间
// 1750067400000 ms = 2025-06-16 17:50 Beijing time (UTC+8)
tsMs := float64(1750067400000)
raw := buildFeedJSON(t, []map[string]any{
{"time": tsMs, "type": float64(1), "id": "f1"},
})
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", got)
}
feeds := m["feeds"].([]any)
if len(feeds) != 1 {
t.Fatalf("feeds length = %d, want 1", len(feeds))
}
item := feeds[0].(map[string]any)
// time 保留原始毫秒时间戳不变
if ms, ok := item["time"].(float64); !ok || ms != tsMs {
t.Fatalf("time = %v (%T), want original ms %v", item["time"], item["time"], tsMs)
}
// timeFormatted 被格式化为北京时间字符串(硬编码期望值)
timeStr, ok := item["timeFormatted"].(string)
if !ok {
t.Fatalf("timeFormatted = %T, want string", item["timeFormatted"])
}
if timeStr != "2025-06-16 17:50" {
t.Fatalf("timeFormatted = %q, want %q", timeStr, "2025-06-16 17:50")
}
// typeLabel 被填充
if item["typeLabel"] != "更新文档" {
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
}
}
func TestCrossPlatformCoverageFormatFeedTimeMultipleFeeds(t *testing.T) {
// 多个 feed,混合有效/无效时间戳
ts1 := float64(1750067400000)
ts2 := float64(1700000000000)
raw := buildFeedJSON(t, []map[string]any{
{"time": ts1, "type": float64(0), "id": "f1"},
{"time": ts2, "type": float64(12), "id": "f2"},
{"id": "f3"}, // 没有 time → 跳过
})
got := formatFeedTime(raw)
m := got.(map[string]any)
feeds := m["feeds"].([]any)
// 前两个有时间戳
for i := 0; i < 2; i++ {
item := feeds[i].(map[string]any)
if _, ok := item["time"].(float64); !ok {
t.Fatalf("feed[%d] time should remain float64, got %T", i, item["time"])
}
if _, ok := item["timeFormatted"].(string); !ok {
t.Fatalf("feed[%d] timeFormatted not string", i)
}
}
// 第三个没有时间戳
item3 := feeds[2].(map[string]any)
if _, hasTimeFormatted := item3["timeFormatted"]; hasTimeFormatted {
t.Fatalf("feed[2] should not have timeFormatted")
}
}
// ---------- enrichFeedFields unit tests ----------
func TestCrossPlatformCoverageEnrichFeedFieldsPreservesAllFields(t *testing.T) {
// enrichFeedFields 不删除任何字段,仅新增 typeLabel
item := map[string]any{
"parentDoc": map[string]any{"name": "parent"},
"userInfo": map[string]any{"nick": "user1"},
"id": "f1",
"type": float64(1),
}
enrichFeedFields(item)
if _, ok := item["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if _, ok := item["userInfo"]; !ok {
t.Fatal("userInfo should be preserved")
}
if item["id"] != "f1" {
t.Fatal("id should be preserved")
}
if item["typeLabel"] != "更新文档" {
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelKnown(t *testing.T) {
// 已知 type → 添加 typeLabel
for typeNum, expectedLabel := range feedTypeLabels {
item := map[string]any{"type": float64(typeNum)}
enrichFeedFields(item)
if item["typeLabel"] != expectedLabel {
t.Fatalf("type %d: typeLabel = %v, want %q", typeNum, item["typeLabel"], expectedLabel)
}
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelUnknown(t *testing.T) {
// 未知 type → 不添加 typeLabel
item := map[string]any{"type": float64(999)}
enrichFeedFields(item)
if _, ok := item["typeLabel"]; ok {
t.Fatal("typeLabel should not be set for unknown type 999")
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsTypeNotFloat(t *testing.T) {
// type 不是 float64 → 不添加 typeLabel
item := map[string]any{"type": "string-type"}
enrichFeedFields(item)
if _, ok := item["typeLabel"]; ok {
t.Fatal("typeLabel should not be set for non-float type")
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsUsersPreserved(t *testing.T) {
// users 数组中的 user 对象所有字段保留不变
item := map[string]any{
"users": []any{
map[string]any{"nick": "Alice", "avatarUrl": "http://img", "userId": "u1"},
map[string]any{"nick": "Bob", "email": "bob@test"},
},
}
enrichFeedFields(item)
users := item["users"].([]any)
u0 := users[0].(map[string]any)
if u0["avatarUrl"] != "http://img" {
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
}
if u0["userId"] != "u1" {
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsUsersNonMapItem(t *testing.T) {
// users 数组中有非 map 元素 → 不 panic
item := map[string]any{
"users": []any{"not-a-map", 42, nil},
}
enrichFeedFields(item) // 不应 panic
users := item["users"].([]any)
if len(users) != 3 {
t.Fatalf("users length changed: %d", len(users))
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsContentPreserved(t *testing.T) {
// content 和 content.doc 所有字段保留不变
item := map[string]any{
"content": map[string]any{
"doc": map[string]any{
"name": "测试文档",
"extension": "adoc",
"docKey": "dk123",
"url": "http://example",
},
"dentryKey": "entry1",
"workspaceKey": "ws1",
},
}
enrichFeedFields(item)
content := item["content"].(map[string]any)
if content["dentryKey"] != "entry1" {
t.Fatalf("content.dentryKey = %v, want entry1", content["dentryKey"])
}
if content["workspaceKey"] != "ws1" {
t.Fatalf("content.workspaceKey = %v, want ws1", content["workspaceKey"])
}
doc := content["doc"].(map[string]any)
if doc["name"] != "测试文档" {
t.Fatalf("doc.name = %v, want 测试文档", doc["name"])
}
if doc["docKey"] != "dk123" {
t.Fatalf("doc.docKey = %v, want dk123", doc["docKey"])
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsNoContentNoUsers(t *testing.T) {
// 没有 content / users / type → 所有字段保留
item := map[string]any{
"id": "f1",
"parentDoc": map[string]any{"x": 1},
}
enrichFeedFields(item)
if _, ok := item["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if item["id"] != "f1" {
t.Fatal("id should be preserved")
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsEmptyItem(t *testing.T) {
// 空 map → 不 panic
item := map[string]any{}
enrichFeedFields(item)
if len(item) != 0 {
t.Fatalf("empty item changed: %v", item)
}
}
// ---------- RunE integration: feed list with formatFeedTime ----------
func TestCrossPlatformCoverageWikiFeedListFormatIntegration(t *testing.T) {
// 通过 scriptedToolCaller 返回真实结构的 feed JSON,验证 RunE 中
// callMCPToolReturnText → formatFeedTime → PrintJSON 完整路径(仅 --format json)
caller := &scriptedToolCaller{format: "json"}
ts := float64(1750067400000)
feedPayload := map[string]any{
"feeds": []any{
map[string]any{
"time": ts,
"type": float64(0),
"id": "feed1",
"parentDoc": map[string]any{"name": "parent"},
"userInfo": map[string]any{"nick": "user1", "avatarUrl": "http://img"},
"users": []any{
map[string]any{"nick": "Alice", "userId": "u1", "avatarUrl": "http://avatar"},
},
"content": map[string]any{
"doc": map[string]any{
"name": "测试文档",
"extension": "adoc",
"docKey": "dk123",
},
"dentryKey": "entry1",
"workspaceKey": "ws1",
},
},
map[string]any{
"time": float64(1700000000000),
"type": float64(7),
"id": "feed2",
},
},
}
payloadBytes, err := json.Marshal(feedPayload)
if err != nil {
t.Fatal(err)
}
caller.steps = []scriptedToolStep{{text: string(payloadBytes)}}
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1", "--exclude-file", "--format", "json"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// 验证输出是合法 JSON 且包含格式化后的 feed
var parsed map[string]any
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, out.String())
}
feeds, ok := parsed["feeds"].([]any)
if !ok || len(feeds) != 2 {
t.Fatalf("feeds length unexpected: %v", parsed["feeds"])
}
// 第一个 feed 验证 time 保留、timeFormatted 格式化、所有原始字段保留
f0 := feeds[0].(map[string]any)
// time 保留原始毫秒时间戳
if f0["time"] != ts {
t.Fatalf("time = %v, want %v", f0["time"], ts)
}
// timeFormatted 被填充
if _, ok := f0["timeFormatted"].(string); !ok {
t.Fatalf("timeFormatted should be string, got %T", f0["timeFormatted"])
}
// 所有原始字段保留(不删除任何字段)
if _, ok := f0["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if _, ok := f0["userInfo"]; !ok {
t.Fatal("userInfo should be preserved")
}
if f0["typeLabel"] != "创建文档" {
t.Fatalf("typeLabel = %v, want 创建文档", f0["typeLabel"])
}
// users 保留验证:avatarUrl / userId 均保留
if users, ok := f0["users"].([]any); ok {
u0 := users[0].(map[string]any)
if u0["avatarUrl"] != "http://avatar" {
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
}
if u0["userId"] != "u1" {
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
}
}
// content 结构保留验证
if content, ok := f0["content"].(map[string]any); ok {
if content["dentryKey"] != "entry1" {
t.Fatalf("content.dentryKey should be preserved, got %v", content["dentryKey"])
}
if content["workspaceKey"] != "ws1" {
t.Fatalf("content.workspaceKey should be preserved, got %v", content["workspaceKey"])
}
doc := content["doc"].(map[string]any)
if doc["docKey"] != "dk123" {
t.Fatalf("content.doc.docKey should be preserved, got %v", doc["docKey"])
}
}
}
func TestCrossPlatformCoverageWikiFeedListRawFormat(t *testing.T) {
// --format raw: 输出原始 MCP 文本,不经过 formatFeedTime 后处理
rawPayload := `{"feeds":[{"time":1750067400000,"type":1}]}`
caller := &scriptedToolCaller{
format: "raw",
steps: []scriptedToolStep{{text: rawPayload}},
}
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1", "--format", "raw"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// raw 输出应包含原始文本,不经过 formatFeedTime 格式化
got := out.String()
if !bytes.Contains([]byte(got), []byte(`"feeds"`)) {
t.Fatalf("raw output should contain original payload, got: %s", got)
}
}
func TestCrossPlatformCoverageWikiFeedListTableFormatMatchesDispatcher(t *testing.T) {
// --format table 回归:与共享 dispatcher (callMCPToolInternalOpts) 契约一致,
// 输出原始紧凑 MCP 文本,不做 pretty 化,也不注入 timeFormatted / typeLabel
rawPayload := `{"feeds":[{"time":1750067400000,"type":1,"id":"f1"}]}`
caller := &scriptedToolCaller{
format: "table",
steps: []scriptedToolStep{{text: rawPayload}},
}
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1", "--format", "table"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// 与共享 dispatcher 一致:输出 = 原始 MCP 文本原文
if got := strings.TrimSpace(out.String()); got != rawPayload {
t.Fatalf("table output should equal raw MCP payload,\ngot: %s\nwant: %s", got, rawPayload)
}
// 不注入任何增强字段
for _, injected := range []string{"timeFormatted", "typeLabel"} {
if strings.Contains(out.String(), injected) {
t.Fatalf("table output should not inject %s, got: %s", injected, out.String())
}
}
}
func TestCrossPlatformCoverageWikiFeedListEmptyResponse(t *testing.T) {
// MCP 返回空结果 → formatFeedTime 处理空字符串 → 不报错
caller := &scriptedToolCaller{} // 无 steps → 空结果
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
}
// ---------- helpers ----------
func buildFeedJSON(t *testing.T, feeds []map[string]any) string {
t.Helper()
payload := map[string]any{"feeds": feeds}
b, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const defaultTextInputLimit = int64(8 << 20)
var (
readTextInputAll = io.ReadAll
statTextInputPath = os.Stat
openTextInputFile = os.Open
readTextInputRel = filepath.Rel
)
// ReadTextInput resolves a literal, "-" stdin, or @workspace-relative file.
// File symlinks must resolve inside the current working directory and all input
// forms are bounded to prevent accidental unbounded memory use.
func ReadTextInput(spec string, stdin io.Reader, maxBytes int64) (string, error) {
if maxBytes <= 0 {
maxBytes = defaultTextInputLimit
}
if spec == "-" {
if stdin == nil {
return "", fmt.Errorf("LOCAL_INPUT_INVALID: stdin 不可用")
}
data, err := readTextInputAll(io.LimitReader(stdin, maxBytes+1))
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
if int64(len(data)) > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: stdin 超过 %d 字节", maxBytes)
}
return string(data), nil
}
if !strings.HasPrefix(spec, "@") {
if int64(len(spec)) > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文本输入超过 %d 字节", maxBytes)
}
return spec, nil
}
path := strings.TrimSpace(strings.TrimPrefix(spec, "@"))
if path == "" {
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 路径不能为空")
}
if filepath.IsAbs(path) {
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 只接受工作目录内的相对路径")
}
clean := filepath.Clean(path)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 不能逃逸工作目录")
}
cwd, err := localGetwd()
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
realBase, err := localEvalSymlinks(cwd)
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
realPath, err := localEvalSymlinks(filepath.Join(realBase, clean))
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
rel, err := readTextInputRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 解析后逃逸工作目录")
}
pathInfo, err := statTextInputPath(realPath)
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
if !pathInfo.Mode().IsRegular() {
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 必须是普通文件")
}
if pathInfo.Size() > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", pathInfo.Size(), maxBytes)
}
file, err := openTextInputFile(realPath)
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 必须是普通文件")
}
if info.Size() > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", info.Size(), maxBytes)
}
data, err := readTextInputAll(io.LimitReader(file, maxBytes+1))
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
if int64(len(data)) > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件超过 %d 字节", maxBytes)
}
return string(data), nil
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"io"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAndBoundedReadE2E(t *testing.T) {
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
t.Run("file grows after stat", func(t *testing.T) {
path := filepath.Join(dir, "grow.txt")
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &readTextInputAll, func(reader io.Reader) ([]byte, error) {
file, openErr := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
if openErr != nil {
return nil, openErr
}
if _, writeErr := file.WriteString("-too-large"); writeErr != nil {
_ = file.Close()
return nil, writeErr
}
if closeErr := file.Close(); closeErr != nil {
return nil, closeErr
}
return io.ReadAll(reader)
})
if _, err := ReadTextInput("@grow.txt", nil, 4); err == nil {
t.Fatal("file growth bypassed size limit")
}
})
t.Run("path becomes directory before descriptor check", func(t *testing.T) {
path := filepath.Join(dir, "becomes-directory.txt")
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
if removeErr := os.Remove(candidate); removeErr != nil {
return nil, removeErr
}
if mkdirErr := os.Mkdir(candidate, 0o700); mkdirErr != nil {
return nil, mkdirErr
}
return os.Open(candidate)
})
if _, err := ReadTextInput("@becomes-directory.txt", nil, 10); err == nil {
t.Fatal("path type replacement bypassed descriptor validation")
}
})
t.Run("file grows before descriptor check", func(t *testing.T) {
path := filepath.Join(dir, "grows-before-stat.txt")
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
file, openErr := os.Open(candidate)
if openErr != nil {
return nil, openErr
}
writer, writeOpenErr := os.OpenFile(candidate, os.O_APPEND|os.O_WRONLY, 0)
if writeOpenErr != nil {
_ = file.Close()
return nil, writeOpenErr
}
if _, writeErr := writer.WriteString("-too-large"); writeErr != nil {
_ = writer.Close()
_ = file.Close()
return nil, writeErr
}
if closeErr := writer.Close(); closeErr != nil {
_ = file.Close()
return nil, closeErr
}
return file, nil
})
if _, err := ReadTextInput("@grows-before-stat.txt", nil, 4); err == nil {
t.Fatal("pre-open growth bypassed descriptor size validation")
}
})
}
+84
View File
@@ -0,0 +1,84 @@
//go:build unix
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAfterPathReplacementE2E(t *testing.T) {
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
path := filepath.Join(dir, "input.txt")
replacement := filepath.Join(dir, "replacement.txt")
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(replacement, []byte("replacement-exceeds-limit"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
file, openErr := os.Open(candidate)
if openErr != nil {
return nil, openErr
}
if renameErr := os.Rename(replacement, candidate); renameErr != nil {
_ = file.Close()
return nil, renameErr
}
return file, nil
})
got, err := ReadTextInput("@input.txt", nil, 10)
if err != nil || got != "safe" {
t.Fatalf("replacement read = %q, %v", got, err)
}
}
func TestCrossPlatformCoverageReadTextInputRejectsUnconnectedFIFOWithoutBlockingE2E(t *testing.T) {
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
fifo := filepath.Join(dir, "input.fifo")
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
_, readErr := ReadTextInput("@input.fifo", nil, 1024)
result <- readErr
}()
select {
case err := <-result:
if err == nil || !strings.Contains(err.Error(), "LOCAL_INPUT_INVALID") {
t.Fatalf("FIFO rejection error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("FIFO without a writer blocked text input validation")
}
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageReadTextInputLiteralStdinAndFileE2E(t *testing.T) {
if got, err := ReadTextInput("literal", nil, 10); err != nil || got != "literal" {
t.Fatalf("literal = %q, %v", got, err)
}
if got, err := ReadTextInput("-", strings.NewReader("stdin"), 10); err != nil || got != "stdin" {
t.Fatalf("stdin = %q, %v", got, err)
}
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
if err := os.WriteFile(filepath.Join(dir, "input.json"), []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
if got, err := ReadTextInput("@input.json", nil, 10); err != nil || got != "file" {
t.Fatalf("file = %q, %v", got, err)
}
}
func TestCrossPlatformCoverageReadTextInputRejectsEscapeAndOversizeE2E(t *testing.T) {
for _, spec := range []string{"@../secret", "@/tmp/secret", "@"} {
if _, err := ReadTextInput(spec, nil, 10); err == nil {
t.Fatalf("unsafe input accepted: %q", spec)
}
}
if _, err := ReadTextInput("too-large", nil, 2); err == nil {
t.Fatal("oversize literal accepted")
}
if _, err := ReadTextInput("-", strings.NewReader("too-large"), 2); err == nil {
t.Fatal("oversize stdin accepted")
}
}
func TestCrossPlatformCoverageReadTextInputFailureBranchesE2E(t *testing.T) {
if got, err := ReadTextInput("literal", nil, 0); err != nil || got != "literal" {
t.Fatalf("default limit literal = %q, %v", got, err)
}
if _, err := ReadTextInput("-", nil, 1); err == nil {
t.Fatal("nil stdin accepted")
}
t.Run("stdin read", func(t *testing.T) {
testseam.Swap(t, &readTextInputAll, func(io.Reader) ([]byte, error) { return nil, errors.New("read") })
if _, err := ReadTextInput("-", strings.NewReader("x"), 1); err == nil {
t.Fatal("stdin read failure ignored")
}
})
dir := t.TempDir()
file := filepath.Join(dir, "input.txt")
if err := os.WriteFile(file, []byte("long"), 0o600); err != nil {
t.Fatal(err)
}
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
if _, err := ReadTextInput("@input.txt", nil, 1); err == nil {
t.Fatal("oversize file accepted")
}
if _, err := ReadTextInput("@.", nil, 10); err == nil {
t.Fatal("directory accepted")
}
for name, run := range map[string]func(*testing.T){
"getwd": func(t *testing.T) {
testseam.Swap(t, &localGetwd, func() (string, error) { return "", errors.New("getwd") })
},
"eval base": func(t *testing.T) {
testseam.Swap(t, &localEvalSymlinks, func(string) (string, error) { return "", errors.New("eval base") })
},
"eval path": func(t *testing.T) {
calls := 0
testseam.Swap(t, &localEvalSymlinks, func(path string) (string, error) {
calls++
if calls == 2 {
return "", errors.New("eval path")
}
return path, nil
})
},
"relative": func(t *testing.T) {
testseam.Swap(t, &readTextInputRel, func(string, string) (string, error) { return "", errors.New("rel") })
},
"path stat": func(t *testing.T) {
testseam.Swap(t, &statTextInputPath, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
},
"open": func(t *testing.T) {
testseam.Swap(t, &openTextInputFile, func(string) (*os.File, error) { return nil, errors.New("open") })
},
"opened file stat": func(t *testing.T) {
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
file, openErr := os.Open(candidate)
if openErr == nil {
_ = file.Close()
}
return file, openErr
})
},
"read file": func(t *testing.T) {
testseam.Swap(t, &readTextInputAll, func(io.Reader) ([]byte, error) { return nil, errors.New("read file") })
},
} {
t.Run(name, func(t *testing.T) {
run(t)
if _, err := ReadTextInput("@input.txt", nil, 10); err == nil {
t.Fatalf("%s failure ignored", name)
}
})
}
t.Run("resolved escape", func(t *testing.T) {
calls := 0
testseam.Swap(t, &localEvalSymlinks, func(path string) (string, error) {
calls++
if calls == 2 {
return filepath.Join(filepath.Dir(path), "..", "outside"), nil
}
return path, nil
})
if _, err := ReadTextInput("@input.txt", nil, 10); err == nil {
t.Fatal("resolved escape accepted")
}
})
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"fmt"
"path/filepath"
)
const defaultPublishBytesLimit = 64 << 20
var verifyPublishTarget = func(target *downloadTarget) error { return target.verifyParent() }
// PublishBytesOptions controls safe no-clobber publication beneath BaseDir.
type PublishBytesOptions struct {
BaseDir string
Output string
PreferredName string
MaxBytes int64
}
// PublishBytes writes an in-memory artifact through the same symlink-safe,
// fsync and atomic no-clobber path used by remote downloads.
func PublishBytes(payload []byte, opts PublishBytesOptions) (DownloadResult, error) {
limit := opts.MaxBytes
if limit <= 0 {
limit = defaultPublishBytesLimit
}
if int64(len(payload)) > limit {
return DownloadResult{}, fmt.Errorf("LOCAL_OUTPUT_TOO_LARGE: 内容大小 %d 超过上限 %d 字节", len(payload), limit)
}
target, err := openDownloadTarget(opts.BaseDir, opts.Output, "", opts.PreferredName)
if err != nil {
return DownloadResult{}, err
}
defer target.close()
if err := verifyPublishTarget(target); err != nil {
return DownloadResult{}, err
}
tmp, tmpName, err := createDownloadTemp(target.parentRoot)
if err != nil {
return DownloadResult{}, fmt.Errorf("创建输出临时文件失败: %w", err)
}
cleanup := func() {
_ = tmp.Close()
_ = target.parentRoot.Remove(tmpName)
}
if _, err := tmp.Write(payload); err != nil {
cleanup()
return DownloadResult{}, fmt.Errorf("写入输出临时文件失败: %w", err)
}
if err := tmp.Sync(); err != nil {
cleanup()
return DownloadResult{}, fmt.Errorf("同步输出临时文件失败: %w", err)
}
if err := tmp.Close(); err != nil {
cleanup()
return DownloadResult{}, fmt.Errorf("关闭输出临时文件失败: %w", err)
}
if err := verifyPublishTarget(target); err != nil {
cleanup()
return DownloadResult{}, err
}
if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil {
cleanup()
return DownloadResult{}, err
}
return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: int64(len(payload))}, nil
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoveragePublishBytesAtomicNoClobberE2E(t *testing.T) {
base := t.TempDir()
result, err := PublishBytes([]byte("verified artifact"), PublishBytesOptions{BaseDir: base, Output: "out/", PreferredName: "transcript.json"})
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(base, result.RelativePath))
if err != nil || string(raw) != "verified artifact" || result.SizeBytes != int64(len(raw)) {
t.Fatalf("publish result=%#v raw=%q err=%v", result, raw, err)
}
if _, err := PublishBytes([]byte("overwrite"), PublishBytesOptions{BaseDir: base, Output: "out/transcript.json"}); err == nil {
t.Fatal("existing output was overwritten")
}
if _, err := PublishBytes([]byte("escape"), PublishBytesOptions{BaseDir: base, Output: "../escape"}); err == nil {
t.Fatal("path escape accepted")
}
}
func TestCrossPlatformCoveragePublishBytesFailureBranchesE2E(t *testing.T) {
base := t.TempDir()
if _, err := PublishBytes([]byte("xx"), PublishBytesOptions{BaseDir: base, Output: "large", MaxBytes: 1}); err == nil {
t.Fatal("oversize payload accepted")
}
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "default-limit", MaxBytes: 0}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
make func(*os.Root) (downloadTempFile, string, error)
}{
{name: "create", make: func(*os.Root) (downloadTempFile, string, error) { return nil, "", errors.New("create") }},
{name: "write", make: func(root *os.Root) (downloadTempFile, string, error) {
file, name, err := createDownloadTempInRoot(root)
return &coverageTempFile{file: file.(*os.File), writeErr: errors.New("write")}, name, err
}},
{name: "sync", make: func(root *os.Root) (downloadTempFile, string, error) {
file, name, err := createDownloadTempInRoot(root)
return &coverageTempFile{file: file.(*os.File), syncErr: errors.New("sync")}, name, err
}},
{name: "close", make: func(root *os.Root) (downloadTempFile, string, error) {
file, name, err := createDownloadTempInRoot(root)
return &coverageTempFile{file: file.(*os.File), closeErr: errors.New("close")}, name, err
}},
} {
t.Run(tc.name, func(t *testing.T) {
testseam.Swap(t, &createDownloadTemp, tc.make)
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: tc.name}); err == nil {
t.Fatalf("%s failure ignored", tc.name)
}
})
}
t.Run("initial parent verification", func(t *testing.T) {
testseam.Swap(t, &verifyPublishTarget, func(*downloadTarget) error { return errors.New("verify") })
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "verify-initial"}); err == nil {
t.Fatal("initial verification failure ignored")
}
})
t.Run("second parent verification", func(t *testing.T) {
calls := 0
testseam.Swap(t, &verifyPublishTarget, func(*downloadTarget) error {
calls++
if calls > 1 {
return errors.New("changed")
}
return nil
})
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "verify-second"}); err == nil {
t.Fatal("second verification failure ignored")
}
})
t.Run("publish", func(t *testing.T) {
testseam.Swap(t, &downloadRootLink, func(*os.Root, string, string) error { return errors.New("link") })
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "publish"}); err == nil {
t.Fatal("publish failure ignored")
}
})
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"context"
"fmt"
"io"
"net/http"
"os"
"time"
)
const defaultUploadLimit = int64(5 << 30)
var (
newUploadHTTPClient = secureHTTPClient
validateUploadURL = func(raw string) error {
_, err := ValidateDownloadURL(raw)
return err
}
openUploadFile = os.Open
newUploadRequest = http.NewRequestWithContext
)
// UploadResult records only non-sensitive transfer facts. The signed URL is
// deliberately never returned.
type UploadResult struct {
SizeBytes int64
Attempts int
}
// PutFile uploads a regular local file to an exact trusted pre-signed HTTPS
// endpoint. Redirects are rejected so file bytes and credentials cannot move to
// a second origin. Transient failures reuse the same verified open file so a
// path replacement cannot change the bytes sent by a later attempt.
func PutFile(ctx context.Context, rawURL, path string, maxBytes int64) (UploadResult, error) {
client := newUploadHTTPClient()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return putFileWithClient(ctx, rawURL, path, maxBytes, client, validateUploadURL)
}
func putFileWithClient(ctx context.Context, rawURL, path string, maxBytes int64, client *http.Client, validate func(string) error) (UploadResult, error) {
if validate == nil {
return UploadResult{}, fmt.Errorf("upload URL validator is nil")
}
if err := validate(rawURL); err != nil {
return UploadResult{}, err
}
if maxBytes <= 0 {
maxBytes = defaultUploadLimit
}
file, err := openUploadFile(path)
if err != nil {
return UploadResult{}, fmt.Errorf("打开上传文件失败: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return UploadResult{}, fmt.Errorf("读取上传文件失败: %w", err)
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxBytes {
return UploadResult{}, fmt.Errorf("上传文件必须是 1..%d 字节的普通文件", maxBytes)
}
result := UploadResult{SizeBytes: info.Size()}
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
result.Attempts = attempt
reader := io.NewSectionReader(file, 0, info.Size())
request, requestErr := newUploadRequest(ctx, http.MethodPut, rawURL, reader)
if requestErr != nil {
return UploadResult{}, fmt.Errorf("创建上传请求失败: %w", requestErr)
}
request.ContentLength = info.Size()
response, callErr := client.Do(request)
if callErr == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
bodyCloseErr := response.Body.Close()
if response.StatusCode >= 200 && response.StatusCode < 300 && bodyCloseErr == nil {
return result, nil
}
if response.StatusCode < 500 && response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests {
return result, fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
}
lastErr = fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
} else {
lastErr = callErr
}
if attempt < 3 {
timer := time.NewTimer(time.Duration(attempt) * 200 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return result, ctx.Err()
case <-timer.C:
}
}
}
return result, fmt.Errorf("上传在 %d 次尝试后失败: %w", result.Attempts, lastErr)
}
+217
View File
@@ -0,0 +1,217 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoveragePutFileRetriesAndUploadsExactBytesE2E(t *testing.T) {
payload := []byte("minutes-e2e")
replacement := []byte("replacement-data")
path := filepath.Join(t.TempDir(), "audio.wav")
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatal(err)
}
replacementPath := filepath.Join(t.TempDir(), "replacement.wav")
if err := os.WriteFile(replacementPath, replacement, 0o600); err != nil {
t.Fatal(err)
}
openCalls := 0
testseam.Swap(t, &openUploadFile, func(candidate string) (*os.File, error) {
openCalls++
if openCalls > 1 {
return os.Open(replacementPath)
}
return os.Open(candidate)
})
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Method != http.MethodPut {
t.Errorf("method = %s", r.Method)
}
body, _ := io.ReadAll(r.Body)
if string(body) != string(payload) {
t.Errorf("body = %q", body)
}
if calls == 1 {
http.Error(w, "retry", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
result, err := putFileWithClient(context.Background(), server.URL, path, 100, server.Client(), func(string) error { return nil })
if err != nil || calls != 2 || openCalls != 1 || result.Attempts != 2 || result.SizeBytes != int64(len(payload)) {
t.Fatalf("upload = %#v calls=%d opens=%d err=%v", result, calls, openCalls, err)
}
}
func TestCrossPlatformCoveragePutFileValidatesOpenedDescriptorE2E(t *testing.T) {
base := t.TempDir()
requestedPath := filepath.Join(base, "requested.wav")
if err := os.WriteFile(requestedPath, []byte("ok"), 0o600); err != nil {
t.Fatal(err)
}
replacementPath := filepath.Join(base, "replacement.wav")
if err := os.WriteFile(replacementPath, []byte("replacement-exceeds-limit"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openUploadFile, func(string) (*os.File, error) {
return os.Open(replacementPath)
})
called := false
client := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
called = true
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", requestedPath, 2, client, func(string) error { return nil }); err == nil {
t.Fatal("replacement descriptor bypassed size validation")
}
if called {
t.Fatal("upload started before the opened descriptor was validated")
}
}
type uploadRoundTripFunc func(*http.Request) (*http.Response, error)
func (f uploadRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}
type uploadCloseErrorBody struct{ io.Reader }
func (uploadCloseErrorBody) Close() error { return errors.New("close") }
func TestCrossPlatformCoveragePutFileFailureBranchesE2E(t *testing.T) {
base := t.TempDir()
path := filepath.Join(base, "audio.wav")
if err := os.WriteFile(path, []byte("payload"), 0o600); err != nil {
t.Fatal(err)
}
okClient := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})}
if _, err := PutFile(context.Background(), "not-a-valid-upload-url", path, 10); err == nil {
t.Fatal("default upload URL validation accepted invalid URL")
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, nil); err == nil {
t.Fatal("nil validator accepted")
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return errors.New("invalid") }); err == nil {
t.Fatal("validator error ignored")
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", filepath.Join(base, "missing"), 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("stat error ignored")
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", base, 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("directory accepted")
}
empty := filepath.Join(base, "empty")
if err := os.WriteFile(empty, nil, 0o600); err != nil {
t.Fatal(err)
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", empty, 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("empty file accepted")
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 1, okClient, func(string) error { return nil }); err == nil {
t.Fatal("oversize file accepted")
}
t.Run("wrapper", func(t *testing.T) {
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
http.Redirect(w, request, "http://example.invalid/redirected", http.StatusFound)
}))
defer redirect.Close()
testseam.Swap(t, &newUploadHTTPClient, redirect.Client)
testseam.Swap(t, &validateUploadURL, func(string) error { return nil })
if _, err := PutFile(context.Background(), redirect.URL, path, 0); err == nil {
t.Fatal("PutFile followed redirect")
}
})
t.Run("open", func(t *testing.T) {
testseam.Swap(t, &openUploadFile, func(string) (*os.File, error) { return nil, errors.New("open") })
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("open error ignored")
}
})
t.Run("opened file stat", func(t *testing.T) {
testseam.Swap(t, &openUploadFile, func(candidate string) (*os.File, error) {
file, err := os.Open(candidate)
if err == nil {
_ = file.Close()
}
return file, err
})
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("opened file stat error ignored")
}
})
t.Run("request", func(t *testing.T) {
testseam.Swap(t, &newUploadRequest, func(context.Context, string, string, io.Reader) (*http.Request, error) {
return nil, errors.New("request")
})
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("request error ignored")
}
})
for _, tc := range []struct {
name string
client *http.Client
}{
{name: "client", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("call") })}},
{name: "server", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("retry"))}, nil
})}},
{name: "close", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: uploadCloseErrorBody{Reader: strings.NewReader("ok")}}, nil
})}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, tc.client, func(string) error { return nil }); err == nil {
t.Fatalf("%s retries unexpectedly succeeded", tc.name)
}
})
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
retryClient := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("retry"))}, nil
})}
if _, err := putFileWithClient(cancelled, "http://example.invalid", path, 10, retryClient, func(string) error { return nil }); err == nil {
t.Fatal("cancelled retry succeeded")
}
}
func TestCrossPlatformCoveragePutFileRejectsRedirectAndInvalidFileE2E(t *testing.T) {
path := filepath.Join(t.TempDir(), "audio.wav")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) }))
defer redirect.Close()
client := redirect.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
if _, err := putFileWithClient(context.Background(), redirect.URL, path, 100, client, func(string) error { return nil }); err == nil {
t.Fatal("redirect accepted")
}
if _, err := putFileWithClient(context.Background(), redirect.URL, filepath.Join(t.TempDir(), "missing"), 100, client, func(string) error { return nil }); err == nil {
t.Fatal("missing file accepted")
}
}
@@ -0,0 +1,83 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDriveSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_drive.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "drive" {
t.Fatalf("semantic catalog service = %q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service != "drive" {
continue
}
if _, duplicate := registered[item.Command]; duplicate {
t.Fatalf("duplicate registered Drive Shortcut %s", item.Command)
}
registered[item.Command] = item
}
if len(registered) != 29 || len(source.Shortcuts) != 29 {
t.Fatalf("registered/catalog = %d/%d, want 29/29", len(registered), len(source.Shortcuts))
}
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
if !record.Reviewed || !item.SemanticReviewed {
t.Errorf("%s: reviewed delivery mismatch", command)
}
if got := shortcut.InPublicCatalog("drive", command); got != record.Public || item.Hidden == record.Public {
t.Errorf("%s: public/hidden mismatch: catalog=%v runtimeHidden=%v", command, record.Public, item.Hidden)
}
if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta || item.Disposition != record.Disposition {
t.Errorf("%s: semantic catalog facts drifted", command)
}
if item.Risk != record.Risk {
t.Errorf("%s: risk = %q, want %q", command, item.Risk, record.Risk)
}
if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" || strings.TrimSpace(item.Safety.Confirmation) == "" {
t.Errorf("%s: incomplete explicit contract/safety", command)
}
if command != "+find-file" && record.Public {
if item.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s: output rollout = %q", command, item.OutputRollout)
}
if item.Contract.Result == nil {
t.Errorf("%s: public Drive shortcut lacks Result declaration", command)
}
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("semantic catalog mismatch: missing=%v stale=%v", missing, stale)
}
}
@@ -0,0 +1,48 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageMinutesSemanticCatalogMatchesRegistryE2E(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_minutes.json")
if err != nil {
t.Fatal(err)
}
var catalog struct {
Shortcuts map[string]struct {
Public bool `json:"public"`
Reviewed bool `json:"reviewed"`
Delta string `json:"semantic_delta"`
} `json:"shortcuts"`
}
if err := json.Unmarshal(raw, &catalog); err != nil {
t.Fatal(err)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "minutes" {
registered[item.Command] = item
}
}
if len(registered) != len(catalog.Shortcuts) {
t.Fatalf("minutes registry/catalog count = %d/%d", len(registered), len(catalog.Shortcuts))
}
for command, record := range catalog.Shortcuts {
item, ok := registered[command]
if !ok {
t.Errorf("catalog command %s is not registered", command)
continue
}
if !record.Reviewed || record.Delta == "" || item.Hidden == record.Public || !item.SemanticReviewed || item.Contract.Empty() {
t.Errorf("minutes %s review/public/contract mismatch: record=%#v item=%#v", command, record, item)
}
}
}
+25 -14
View File
@@ -1049,14 +1049,6 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
},
wantError: "biz-preserved",
},
{
name: "unverified update preserves id",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"bizId":"biz-unverified"}`,
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}},
wantError: "biz-unverified",
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
@@ -1074,6 +1066,27 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
})
}
t.Run("success acknowledgement completes composite update", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"bizId":"biz-acknowledged"}`,
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "完成",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("calls = %#v", fake.calls)
}
})
for _, args := range [][]string{
{"--group", "cid", "--content", "x", "--flow-status", "6"},
{"--group", "cid", "--flow-status", "2"},
@@ -1091,7 +1104,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}
}
func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T) {
func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"result":{"bizId":"biz-confirm","updated":true}}`,
@@ -1115,7 +1128,7 @@ func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T
}
})
t.Run("generic success is unverified", func(t *testing.T) {
t.Run("success acknowledgement is verified", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}}
@@ -1128,10 +1141,8 @@ func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T
"--flow-status", "3",
"--yes",
})
err := root.Execute()
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" {
t.Fatalf("calls = %#v", fake.calls)
+27 -3
View File
@@ -59,9 +59,8 @@ func isCardBizIDPlaceholder(value string) bool {
}
// VerifyStreamingCardUpdate requires affirmative evidence that the requested
// write took effect. A transport-level {success:true,errorCode:null} only says
// that the RPC returned normally and is deliberately not accepted as proof of
// a card update.
// write took effect. update_streaming_card may acknowledge an applied write
// with success=true without returning an updated flag or affected count.
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) {
requestedBizID = strings.TrimSpace(requestedBizID)
observation := cardUpdateObservation{bizIDs: map[string]struct{}{}}
@@ -132,6 +131,23 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati
}
}
}
errorCode, hasErrorCode := value["errorCode"]
errorCodeEmpty := hasErrorCode && cardUpdateErrorCodeEmpty(errorCode)
if hasErrorCode && !errorCodeEmpty {
setNegativeCardUpdateEvidence(observation, "errorCode=non-empty")
}
if success, ok := value["success"].(bool); ok {
if success {
// Record success=true only when the same response envelope explicitly
// includes its business-error field. A non-empty code is already
// negative evidence above, so the two signals reject the conflict.
if hasErrorCode {
setPositiveCardUpdateEvidence(observation, "success=true")
}
} else {
setNegativeCardUpdateEvidence(observation, "success=false")
}
}
// Only documented response envelopes are traversed. This prevents an
// unrelated extension field containing "updated":true from proving the
@@ -143,6 +159,14 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati
}
}
func cardUpdateErrorCodeEmpty(value any) bool {
if value == nil {
return true
}
code, ok := value.(string)
return ok && strings.TrimSpace(code) == ""
}
func setPositiveCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
if observation.positiveEvidence == "" {
observation.positiveEvidence = evidence
@@ -62,7 +62,13 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"},
{name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified},
{name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied},
{name: "false success has no write proof", response: map[string]any{"success": true, "errorCode": nil}, wantErrIs: ErrCardUpdateUnverified},
{name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, wantProof: "success=true"},
{name: "success acknowledgement with empty error code", response: map[string]any{"success": true, "errorCode": " "}, wantProof: "success=true"},
{name: "success without explicit error code", response: map[string]any{"success": true}, wantErrIs: ErrCardUpdateUnverified},
{name: "success conflicts with error code", response: map[string]any{"success": true, "errorCode": "InternalError"}, wantErrIs: ErrCardUpdateUnverified},
{name: "success conflicts with numeric error code", response: map[string]any{"success": true, "errorCode": float64(500)}, wantErrIs: ErrCardUpdateUnverified},
{name: "error code without success", response: map[string]any{"errorCode": "InternalError"}, wantErrIs: ErrCardUpdateNotApplied},
{name: "failed acknowledgement", response: map[string]any{"success": false}, wantErrIs: ErrCardUpdateNotApplied},
{name: "explicitly not updated", response: map[string]any{"result": map[string]any{"updated": false}}, wantErrIs: ErrCardUpdateNotApplied},
{name: "mismatched id", response: map[string]any{"result": map[string]any{"bizId": "biz-2", "updated": true}}, wantErrIs: ErrCardUpdateBizIDDrift},
{name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified},
@@ -0,0 +1,524 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package drive
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var uploadDriveFile = helpers.UploadDriveFileData
var driveRestoreWait = time.Sleep
var (
driveGetwd = os.Getwd
driveEvalSymlinks = filepath.EvalSymlinks
driveRel = filepath.Rel
driveStat = os.Stat
)
var RecycleList = shortcut.Shortcut{
Service: "drive", Command: "+recycle-list", Product: "drive",
Description: "严格分页列出钉盘回收站",
Intent: "查找可恢复的回收项并获取 recycleItemId 时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+recycle-list", "严格分页列出钉盘回收站",
"查找可恢复的回收项并获取 recycleItemId 时使用。",
[]string{"恢复前必须先确认具体回收项;普通目录浏览使用 drive +list"},
[]string{`dws drive +recycle-list --limit 20`},
driveCollectionResult("items", "严格校验的回收站条目页"), driveCursorPagination(),
contract.ParamDecl{Name: "cursor", Property: "nextCursor"},
),
Flags: []shortcut.Flag{
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
},
Tips: []string{`dws drive +recycle-list --limit 20`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"maxResults": rt.Int("limit")}
if rt.Str("space-id") != "" {
params["spaceId"] = rt.Str("space-id")
}
if rt.Str("cursor") != "" {
params["nextCursor"] = rt.Str("cursor")
}
data, err := rt.CallMCPData("drive", "list_recycle_items", params)
if err != nil {
return err
}
items, page, err := requireDriveCollection(data, "drive/list_recycle_items", "recycleItems")
if err != nil {
return err
}
rows := projectDriveRows(items, map[string][]string{
"recycleItemId": {"recycleItemId", "id"},
"originalName": {"originalName", "name", "fileName", "title"},
"originalPath": {"originalPath", "path"},
"type": {"type", "fileType", "nodeType", "contentType"},
"deleteTime": {"operatorTime", "deleteTime", "deletedTime", "recycleTime"},
"fileSize": {"fileSize", "size"},
})
out := map[string]any{"count": len(rows), "items": rows}
addDrivePagination(out, page)
return rt.Output(out)
},
}
var RecycleRestore = shortcut.Shortcut{
Service: "drive", Command: "+recycle-restore", Product: "drive",
Description: "恢复已确认的回收站条目并读回节点",
Intent: "已经通过 drive +recycle-list 确认回收项,并明确要求恢复时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: driveContract(
"+recycle-restore", "恢复已确认的回收站条目并读回节点",
"已经通过 drive +recycle-list 确认回收项,并明确要求恢复时使用。",
[]string{"尚未确认回收项时先列表;不要把原节点 ID 当 recycleItemId"},
[]string{`dws drive +recycle-restore --id <recycleItemId>`},
driveObjectResult("恢复并读回验证后的节点"), nil,
contract.ParamDecl{Name: "id", Property: "recycleItemId"},
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "回收项 ID", Required: true},
},
Tips: []string{`dws drive +recycle-restore --id <recycleItemId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
recycleItem, err := findDriveRecycleItem(rt, rt.Str("id"))
if err != nil {
return err
}
written, err := rt.CallMCPWriteDataStrict("drive", "restore_recycle_item", map[string]any{"recycleItemId": rt.Str("id")})
if err != nil {
return err
}
if _, err := requireDriveWrite(written, "drive/restore_recycle_item"); err != nil {
return err
}
nodeID := nestedString(written, "fileId", "nodeId", "dentryUuid", "id")
if nodeID == "" {
nodeID, err = findRestoredDriveNode(rt, recycleItem)
if err != nil {
return err
}
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "file": verified})
},
}
func findDriveRecycleItem(rt *shortcut.RuntimeContext, recycleItemID string) (map[string]any, error) {
params := map[string]any{"maxResults": 50}
for pageNumber := 0; pageNumber < 20; pageNumber++ {
data, err := rt.CallMCPData("drive", "list_recycle_items", params)
if err != nil {
return nil, err
}
items, page, err := requireDriveCollection(data, "drive/list_recycle_items", "recycleItems")
if err != nil {
return nil, err
}
for _, item := range items {
entry := item.(map[string]any)
if nestedString(entry, "recycleItemId", "id") == recycleItemID {
return entry, nil
}
}
nextCursor := nestedString(page, "nextCursor", "nextToken", "nextPageToken")
hasMore, hasMorePresent := boolField(page, "hasMore")
if nextCursor == "" || (hasMorePresent && !hasMore) {
break
}
params["nextCursor"] = nextCursor
}
return nil, driveResponseError("drive/list_recycle_items", "recycle_item_not_found", "回收站中没有找到指定 recycleItemId;未执行恢复")
}
func findRestoredDriveNode(rt *shortcut.RuntimeContext, recycleItem map[string]any) (string, error) {
name := nestedString(recycleItem, "originalName", "name", "fileName", "title")
if name == "" {
return "", driveResponseError("drive/restore_recycle_item", "missing_restored_identity", "恢复响应没有节点 ID,且恢复前回收项没有名称,无法安全读回终态")
}
searchName := name
if extension := filepath.Ext(name); extension != "" {
searchName = strings.TrimSuffix(name, extension)
}
originalPath := nestedString(recycleItem, "originalPath", "path")
for attempt := 0; attempt < 4; attempt++ {
if attempt > 0 {
driveRestoreWait(750 * time.Millisecond)
}
if originalPath != "" {
if nodeID, found, err := findRestoredDriveNodeAtOriginalPath(rt, originalPath, name); err != nil {
return "", err
} else if found {
return nodeID, nil
}
}
data, err := rt.CallMCPData("drive", "search_files", map[string]any{"keyword": searchName, "pageSize": 30})
if err != nil {
return "", err
}
items, _, err := requireDriveCollection(data, "drive/search_files", "items", "files", "dentries", "entries", "nodes", "list")
if err != nil {
return "", err
}
candidates := make([]string, 0, 1)
for _, item := range items {
entry := item.(map[string]any)
entryName := nestedString(entry, "name", "fileName", "dentryName", "title")
entryExtension := strings.TrimPrefix(nestedString(entry, "extension", "fileExtension", "ext"), ".")
fullEntryName := entryName
if entryExtension != "" && filepath.Ext(entryName) == "" {
fullEntryName += "." + entryExtension
}
if entryName != name && fullEntryName != name && entryName != searchName {
continue
}
if originalPath != "" {
path := nestedString(entry, "path", "originalPath")
if path != "" && path != originalPath {
continue
}
}
if id := nestedString(entry, "fileId", "dentryUuid", "nodeId", "id"); id != "" {
candidates = append(candidates, id)
}
}
if len(candidates) == 1 {
return candidates[0], nil
}
if len(candidates) > 1 {
return "", driveResponseError("drive/restore_recycle_item", "restored_node_ambiguous", "服务端已接受恢复,但按原名称找到多个节点,无法唯一确认恢复终态;请用 +list 核对")
}
}
return "", driveResponseError("drive/restore_recycle_item", "restored_node_not_found", "服务端已接受恢复,但没有返回节点 ID,且在有界等待后仍按原名称搜索不到恢复后的节点;远端效果未知,请先用 +list 确认")
}
func findRestoredDriveNodeAtOriginalPath(rt *shortcut.RuntimeContext, originalPath, name string) (string, bool, error) {
parentName := filepath.Base(filepath.Dir(originalPath))
if parentName == "." || parentName == string(filepath.Separator) || parentName == "" {
return "", false, nil
}
data, err := rt.CallMCPData("drive", "search_files", map[string]any{"keyword": parentName, "pageSize": 30})
if err != nil {
return "", false, err
}
items, _, err := requireDriveCollection(data, "drive/search_files", "items", "files", "dentries", "entries", "nodes", "list")
if err != nil {
return "", false, err
}
parentIDs := make([]string, 0, 1)
for _, item := range items {
entry := item.(map[string]any)
if nestedString(entry, "name", "fileName", "dentryName", "title") != parentName {
continue
}
if id := nestedString(entry, "fileId", "dentryUuid", "nodeId", "id"); id != "" {
parentIDs = append(parentIDs, id)
}
}
if len(parentIDs) != 1 {
return "", false, nil
}
data, err = rt.CallMCPData("drive", "list_files", map[string]any{"parentId": parentIDs[0], "maxResults": 50})
if err != nil {
return "", false, err
}
items, _, err = requireDriveCollection(data, "drive/list_files", "items", "files", "dentries", "entries", "nodes", "list")
if err != nil {
return "", false, err
}
var nodeID string
for _, item := range items {
entry := item.(map[string]any)
if nestedString(entry, "name", "fileName", "dentryName", "title") != name {
continue
}
if nodeID != "" {
return "", false, driveResponseError("drive/list_files", "restored_node_ambiguous", "恢复后的原目录中存在多个同名节点,无法唯一确认终态")
}
nodeID = nestedString(entry, "fileId", "dentryUuid", "nodeId", "id")
}
return nodeID, nodeID != "", nil
}
var StarList = shortcut.Shortcut{
Service: "drive", Command: "+star-list", Product: "drive",
Description: "严格分页列出当前用户收藏",
Intent: "浏览当前用户收藏并获取后续可操作节点 ID 时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+star-list", "严格分页列出当前用户收藏",
"浏览当前用户收藏并获取后续可操作节点 ID 时使用。",
[]string{"最近访问使用 drive +recent;目录浏览使用 drive +list"},
[]string{`dws drive +star-list --limit 20`},
driveCollectionResult("items", "严格校验的收藏条目页"), driveCursorPagination(),
contract.ParamDecl{Name: "cursor", Property: "cursor"},
),
Flags: []shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
{Name: "content-types", Type: shortcut.FlagStringSlice, Desc: "内容类型过滤"},
},
Tips: []string{`dws drive +star-list --limit 20`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"limit": rt.Int("limit")}
if rt.Str("cursor") != "" {
params["cursor"] = rt.Str("cursor")
}
if values := rt.StrSlice("content-types"); len(values) > 0 {
params["contentTypes"] = values
}
data, err := rt.CallMCPData("drive", "get_star_list", params)
if err != nil {
return err
}
items, page, err := requireDriveCollection(data, "drive/get_star_list", "starList")
if err != nil {
return err
}
rows := projectDriveRows(items, map[string][]string{
"nodeId": {"nodeId", "fileId", "dentryUuid", "id"},
"name": {"name", "fileName", "title"},
"type": {"type", "contentType", "nodeType"},
"createTime": {"createTime", "starTime"},
})
out := map[string]any{"count": len(rows), "items": rows}
addDrivePagination(out, page)
return rt.Output(out)
},
}
var StarAdd = starMutationShortcut("+star-add", "收藏指定节点", "mark_star", "将指定文件或文档加入当前用户收藏。")
var StarRemove = starMutationShortcut("+star-remove", "取消收藏指定节点", "unmark_star", "将指定文件或文档从当前用户收藏移除。")
func starMutationShortcut(command, description, tool, useWhen string) shortcut.Shortcut {
return shortcut.Shortcut{
Service: "drive", Command: command, Product: "drive", Description: description, Intent: useWhen,
Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(command, description, useWhen,
[]string{"查看收藏状态和列表使用 drive +star-list"},
[]string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
driveObjectResult(description+"的终态证据"), nil, contract.ParamDecl{Name: "node", Property: "nodeId"}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
Tips: []string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
Execute: func(rt *shortcut.RuntimeContext) error {
written, err := rt.CallMCPWriteDataStrict("drive", tool, map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
written, err = requireDriveWrite(written, "drive/"+tool)
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "result": written})
},
}
}
var PublishGet = shortcut.Shortcut{
Service: "drive", Command: "+publish-get", Product: "drive",
Description: "查询文件互联网公开状态",
Intent: "查询文件当前互联网公开状态和权限时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+publish-get", "查询文件互联网公开状态", "查询文件当前互联网公开状态和权限时使用。",
[]string{"开启或关闭公开分别使用 drive +publish-set / +publish-unset"},
[]string{`dws drive +publish-get --node <dentryUuid>`}, driveObjectResult("互联网公开状态"), nil,
contract.ParamDecl{Name: "node", Property: "fileId"},
),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文件 ID", Required: true}},
Tips: []string{`dws drive +publish-get --node <dentryUuid>`},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("drive", "get_file_publish_status", map[string]any{"fileId": rt.Str("node")})
if err != nil {
return err
}
data, err = requireDriveObject(data, "drive/get_file_publish_status")
if err != nil {
return err
}
return rt.Output(data)
},
}
var PublishSet = publishMutationShortcut("+publish-set", true)
var PublishUnset = publishMutationShortcut("+publish-unset", false)
func publishMutationShortcut(command string, published bool) shortcut.Shortcut {
description := "开启文件互联网公开发布"
useWhen := "用户明确同意任何持链接者可访问,并已确认公开权限时使用。"
if !published {
description = "关闭文件互联网公开发布"
useWhen = "用户明确要求让现有互联网公开链接失效时使用。"
}
flags := []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文件 ID", Required: true}}
if published {
flags = append(flags, shortcut.Flag{Name: "permission", Type: shortcut.FlagString, Default: "DOWNLOADER", Desc: "公开权限", Enum: []string{"READER", "DOWNLOADER", "EDITOR"}})
}
return shortcut.Shortcut{
Service: "drive", Command: command, Product: "drive", Description: description, Intent: useWhen,
Risk: shortcut.RiskHighWrite, Safety: contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
Contract: driveContract(command, description, useWhen,
[]string{"只查询状态使用 drive +publish-get;企业内部协作者权限使用 doc +access-grant"},
[]string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
driveObjectResult(description+"并读回验证的状态"), nil, contract.ParamDecl{Name: "node", Property: "fileId"}),
Flags: flags,
Tips: []string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"fileId": rt.Str("node"), "published": published}
if published {
params["publishPermission"] = rt.Str("permission")
}
written, err := rt.CallMCPWriteDataStrict("drive", "set_file_publish", params)
if err != nil {
return err
}
if _, err := requireDriveWrite(written, "drive/set_file_publish"); err != nil {
return err
}
verified, err := rt.CallMCPData("drive", "get_file_publish_status", map[string]any{"fileId": rt.Str("node")})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_publish_status")
if err != nil {
return err
}
actual, ok := boolField(verified, "published", "isPublished", "publish")
if !ok || actual != published {
return driveResponseError("drive/set_file_publish", "readback_mismatch", "公开状态写入后读回不一致")
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "publish": verified})
},
}
}
func boolField(data map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
if value, ok := data[key].(bool); ok {
return value, true
}
}
return false, false
}
var Upload = shortcut.Shortcut{
Service: "drive", Command: "+upload", Product: "drive",
Description: "从工作目录上传本地文件并读回验证",
Intent: "把工作目录内普通文件上传到钉盘,并要求验证远端文件 ID、名称和大小时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: driveContract(
"+upload", "从工作目录上传本地文件并读回验证",
"把工作目录内普通文件上传到钉盘,并要求验证远端文件 ID、名称和大小时使用。",
[]string{"在线文档导入转换使用 doc +import;作为正文附件使用 doc +media-insert;覆盖已有文件必须显式 --node"},
[]string{`dws drive +upload --file report.pdf`, `dws drive +upload --file report.pdf --folder <dentryUuid>`},
driveObjectResult("上传并读回验证后的远端文件"), nil,
contract.ParamDecl{Name: "folder", Property: "parentId"},
contract.ParamDecl{Name: "node", Property: "overwriteFileId"},
),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内的相对文件路径", Required: true},
{Name: "file-name", Type: shortcut.FlagString, Desc: "远端显示名称,默认使用本地文件名"},
{Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"},
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
{Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"},
{Name: "node", Type: shortcut.FlagString, Desc: "覆盖目标文件 ID"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"folder", "node"}}},
Tips: []string{`dws drive +upload --file report.pdf`, `dws drive +upload --file report.pdf --folder <dentryUuid>`},
Execute: func(rt *shortcut.RuntimeContext) error {
path, info, err := resolveDriveUploadInput(rt.Str("file"))
if err != nil {
return err
}
name := rt.Str("file-name")
if name == "" {
name = info.Name()
}
if rt.DryRun() {
return rt.Output(map[string]any{"dry_run": true, "executed": false, "operation": "drive.upload", "file": rt.Str("file"), "fileName": name, "sizeBytes": info.Size()})
}
committed, err := uploadDriveFile(rt.Command().Context(), helpers.DriveUploadRequest{
FilePath: path, FileName: name, FileSize: info.Size(), SpaceID: rt.Str("space-id"), ParentID: rt.Str("folder"), OverwriteFile: rt.Str("node"), MIMEType: rt.Str("mime-type"),
})
if err != nil {
return err
}
committed, err = requireDriveWrite(committed, "drive/commit_upload")
if err != nil {
return err
}
nodeID := rt.Str("node")
if nodeID == "" {
nodeID = nestedString(committed, "fileId", "dentryUuid", "nodeId", "id")
}
if nodeID == "" {
return driveResponseError("drive/commit_upload", "missing_created_id", "上传提交没有返回文件 ID;远端效果未知")
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
if remoteName := firstString(verified, "name", "fileName"); remoteName == "" || !strings.HasPrefix(remoteName, strings.TrimSuffix(name, filepath.Ext(name))) {
return driveResponseError("drive/commit_upload", "readback_mismatch", fmt.Sprintf("上传后读回名称 %q 与请求 %q 不一致", remoteName, name))
}
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "sizeBytes": info.Size(), "file": verified})
},
}
func resolveDriveUploadInput(raw string) (string, os.FileInfo, error) {
if strings.TrimSpace(raw) == "" || filepath.IsAbs(raw) {
return "", nil, fmt.Errorf("--file 只接受工作目录内的相对文件路径")
}
cwd, err := driveGetwd()
if err != nil {
return "", nil, err
}
base, err := driveEvalSymlinks(cwd)
if err != nil {
return "", nil, err
}
path, err := driveEvalSymlinks(filepath.Join(base, filepath.Clean(raw)))
if err != nil {
return "", nil, fmt.Errorf("读取上传文件失败: %w", err)
}
rel, err := driveRel(base, path)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return "", nil, fmt.Errorf("--file 不能逃逸工作目录")
}
info, err := driveStat(path)
if err != nil {
return "", nil, err
}
if info.IsDir() || info.Size() <= 0 {
return "", nil, fmt.Errorf("--file 必须是非空普通文件")
}
return path, info, nil
}
+227
View File
@@ -0,0 +1,227 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package drive
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const driveCompositeInterfaceReason = "Reviewed Drive Shortcut composite: the executable CLI owns strict response validation, optional multi-step orchestration, local I/O, read-back verification, output projection, and confirmation; no single MCP interface represents the complete command contract."
func driveContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "drive " + command
return corecmd.ContractDecl{
Description: description,
Parameters: params,
Result: result,
Pagination: pagination,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: driveCompositeInterfaceReason,
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{useWhen},
AvoidWhen: avoidWhen,
Examples: examples,
},
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: name,
CanonicalPath: "drive." + name,
CLIPath: cliPath,
PrimaryCLIPath: cliPath,
},
}
}
func driveObjectResult(description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"additionalProperties":true}`,
description,
)),
}
}
func driveCollectionResult(collection, description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"本页有效结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Drive 资源条目","additionalProperties":true}},"nextCursor":{"type":"string","description":"下一页游标"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页"}},"required":["count",%q],"additionalProperties":true}`,
description, collection, description, collection,
)),
}
}
func driveCursorPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func requireDriveResponse(data map[string]any, operation string) (map[string]any, error) {
if len(data) == 0 {
return nil, driveResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功")
}
if success, present := data["success"]; present {
value, ok := success.(bool)
if !ok {
return nil, driveResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
}
if !value {
message := firstString(data, "errorMsg", "message", "error")
if message == "" {
message = "服务明确返回 success=false"
}
return nil, driveResponseError(operation, "remote_failure", message)
}
}
return data, nil
}
func requireDriveObject(data map[string]any, operation string) (map[string]any, error) {
data, err := requireDriveResponse(data, operation)
if err != nil {
return nil, err
}
if value, present := data["result"]; present {
result, ok := value.(map[string]any)
if !ok || len(result) == 0 {
return nil, driveResponseError(operation, "malformed_result", "响应 result 缺失有效对象")
}
return result, nil
}
if value, present := data["data"]; present {
result, ok := value.(map[string]any)
if !ok || len(result) == 0 {
return nil, driveResponseError(operation, "malformed_data", "响应 data 缺失有效对象")
}
return result, nil
}
if len(data) == 1 {
return nil, driveResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
}
return data, nil
}
func requireDriveWrite(data map[string]any, operation string) (map[string]any, error) {
data, err := requireDriveResponse(data, operation)
if err != nil {
return nil, err
}
success, ok := data["success"].(bool)
if !ok || !success {
return nil, driveResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据")
}
return data, nil
}
func requireDriveCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
data, err := requireDriveResponse(data, operation)
if err != nil {
return nil, nil, err
}
containers := []map[string]any{data}
for _, wrapper := range []string{"result", "data"} {
if value, present := data[wrapper]; present {
inner, ok := value.(map[string]any)
if !ok {
return nil, nil, driveResponseError(operation, "malformed_envelope", fmt.Sprintf("响应 %s 字段不是对象", wrapper))
}
containers = append(containers, inner)
}
}
for _, container := range containers {
for _, key := range keys {
value, present := container[key]
if !present {
continue
}
items, ok := value.([]any)
if !ok {
return nil, nil, driveResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
}
for index, item := range items {
if _, ok := item.(map[string]any); !ok {
return nil, nil, driveResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index))
}
}
return items, container, nil
}
}
return nil, nil, driveResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段投影成空结果")
}
func projectDriveRows(items []any, aliases map[string][]string) []map[string]any {
rows := make([]map[string]any, 0, len(items))
for _, item := range items {
source := item.(map[string]any)
row := make(map[string]any)
for canonical, candidates := range aliases {
for _, candidate := range candidates {
if value, ok := source[candidate]; ok && value != nil {
row[canonical] = value
break
}
}
}
rows = append(rows, row)
}
return rows
}
func addDrivePagination(out map[string]any, container map[string]any) {
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"hasMore", "hasMore"}} {
if value, ok := container[pair[0]]; ok && value != nil {
out[pair[1]] = value
}
}
}
func firstString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func nestedString(data map[string]any, keys ...string) string {
if value := firstString(data, keys...); value != "" {
return value
}
for _, wrapper := range []string{"result", "data"} {
if inner, ok := data[wrapper].(map[string]any); ok {
if value := firstString(inner, keys...); value != "" {
return value
}
}
}
return ""
}
func driveResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
+235 -239
View File
@@ -20,11 +20,17 @@
package drive
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var driveDownload = localio.Download
// ── 钉盘文件(drive MCP server)────────────────────────────────
// List → list_files
@@ -32,9 +38,20 @@ var List = shortcut.Shortcut{
Service: "drive",
Command: "+list",
Product: "drive",
Description: "列出钉盘文件/文件夹",
Intent: "当你想浏览钉盘某个空间或文件夹下有哪些文件和子文件夹、需要拿到文件的 dentryUuid 以便后续下载/移动/删除时使用;可指定 space-id、folder 逐层进入,支持分页和按创建/修改时间、名称排序,返回文件列表(含 ID、名称、类型等)。",
Description: "严格分页列出钉盘文件和文件夹",
Intent: "浏览钉盘根目录或已知文件夹时使用;服务端明确空数组才表示空目录,缺字段、坏元素或空响应都会失败。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+list", "严格分页列出钉盘文件和文件夹",
"浏览钉盘根目录或已知文件夹时使用;服务端明确空数组才表示空目录,缺字段、坏元素或空响应都会失败。",
[]string{"按关键词定位文件改用 drive +search;查看单个节点详情改用 drive +inspect"},
[]string{`dws drive +list --limit 20`, `dws drive +list --folder <dentryUuid> --limit 20`},
driveCollectionResult("files", "严格校验并投影的钉盘目录页"), driveCursorPagination(),
contract.ParamDecl{Name: "space-id", Property: "spaceId"},
contract.ParamDecl{Name: "folder", Property: "parentId"},
contract.ParamDecl{Name: "cursor", Property: "nextToken"},
),
Flags: []shortcut.Flag{
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID (纯数字),不传则使用「我的文件」"},
{Name: "folder", Type: shortcut.FlagString, Desc: "父节点 ID (dentryUuid),不传则列出空间根目录"},
@@ -72,68 +89,23 @@ var List = shortcut.Shortcut{
if err != nil {
return err
}
files := listFilesProject(data)
return rt.Output(map[string]any{"count": len(files), "files": files})
items, page, err := requireDriveCollection(data, "drive/list_files", "items", "files", "dentries", "entries", "nodes", "list")
if err != nil {
return err
}
files := projectDriveRows(items, map[string][]string{
"name": {"name", "fileName", "dentryName", "title"},
"type": {"type", "dentryType", "fileType", "spaceType"},
"nodeId": {"fileId", "dentryUuid", "nodeId", "id"},
"dentryId": {"dentryId"},
"fileSize": {"fileSize", "size", "byteSize", "length"},
})
out := map[string]any{"count": len(files), "files": files}
addDrivePagination(out, page)
return rt.Output(out)
},
}
// listFilesProject reshapes the raw list_files response into a clean,
// stable 钉盘 file/folder list ({name,type,dentryId,fileSize}) — the
// output-projection fidelity applied to every list shortcut. Both the list
// container and each field are probed defensively across candidate keys, so a
// missing container or unknown alias simply yields an empty list rather than a
// fabricated value.
func listFilesProject(data map[string]any) []map[string]any {
raw := listFilesContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
listFilesPick(row, m, "name", "name", "fileName", "dentryName", "title")
listFilesPick(row, m, "type", "type", "dentryType", "fileType", "spaceType")
listFilesPick(row, m, "dentryId", "dentryId", "dentryUuid", "id", "fileId", "nodeId")
listFilesPick(row, m, "fileSize", "fileSize", "size", "byteSize", "length")
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// listFilesContainer locates the file list array inside the response by trying
// the common container keys emitted across drive backends; the payload itself
// may also already be the array.
func listFilesContainer(data map[string]any) []any {
for _, k := range []string{"result", "data", "list", "items", "files", "dentries", "entries", "nodes"} {
if v, ok := data[k].([]any); ok {
return v
}
// The container may be nested one level (e.g. {"data":{"list":[...]}}).
if inner, ok := data[k].(map[string]any); ok {
for _, ik := range []string{"list", "items", "files", "dentries", "entries", "nodes", "result"} {
if v, ok := inner[ik].([]any); ok {
return v
}
}
}
}
return nil
}
// listFilesPick copies the first matching alias from src into dst under the
// canonical key, leaving dst untouched when no alias is present.
func listFilesPick(dst, src map[string]any, canonical string, aliases ...string) {
for _, a := range aliases {
if v, ok := src[a]; ok {
dst[canonical] = v
return
}
}
}
// Info → get_file_info
var Info = shortcut.Shortcut{
Service: "drive",
@@ -177,7 +149,15 @@ var Info = shortcut.Shortcut{
if rt.Changed("space-id") {
params["spaceId"] = rt.Str("space-id")
}
return rt.CallMCP("get_file_info", params)
data, err := rt.CallMCPData("drive", "get_file_info", params)
if err != nil {
return err
}
result, err := requireDriveObject(data, "drive/get_file_info")
if err != nil {
return err
}
return rt.Output(result)
},
}
@@ -186,20 +166,55 @@ var Download = shortcut.Shortcut{
Service: "drive",
Command: "+download",
Product: "drive",
Description: "获取钉盘文件下载链接",
Intent: "当你需要把钉盘里某个文件下载到本地或转给他人时使用;输入文件的 dentryUuid,返回带签名的临时下载 URL 和请求头,用它去真正拉取文件内容(本命令本身只取链接、不落盘)。",
Description: "安全下载钉盘文件到工作目录",
Intent: "下载普通钉盘文件并要求验证本地字节产物时使用;不是只返回临时 URL。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+download", "安全下载钉盘文件到工作目录",
"下载普通钉盘文件并要求验证本地字节产物时使用;不是只返回临时 URL。",
[]string{"在线文档导出为 docx/pdf 使用 doc +export;只查元数据使用 drive +inspect"},
[]string{`dws drive +download --node <dentryUuid> --output downloads/report.pdf`},
driveObjectResult("已验证的本地下载产物"), nil,
contract.ParamDecl{Name: "node", Property: "fileId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文件 ID (dentryUuid)", Required: true},
{Name: "space-id", Type: shortcut.FlagString, Desc: "文件所属空间 ID"},
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "工作目录内的相对输出路径", Required: true},
},
Tips: []string{`dws drive +download --node <dentryUuid>`},
Tips: []string{`dws drive +download --node <dentryUuid> --output downloads/report.pdf`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"fileId": rt.Str("node")}
if rt.Changed("space-id") {
params["spaceId"] = rt.Str("space-id")
}
return rt.CallMCP("download_file", params)
data, err := rt.CallMCPData("drive", "download_file", params)
if err != nil {
return err
}
payload, err := requireDriveObject(data, "drive/download_file")
if err != nil {
return err
}
url, preferredName, headers, err := driveDownloadPayload(payload, "drive/download_file")
if err != nil {
return err
}
cwd, err := driveGetwd()
if err != nil {
return err
}
artifact, err := driveDownload(rt.Command().Context(), url, localio.DownloadOptions{
BaseDir: cwd, Output: rt.Str("output"), PreferredName: preferredName, Headers: headers,
})
if err != nil {
return err
}
if artifact.SizeBytes <= 0 {
return driveResponseError("drive/download_file", "empty_download_artifact", "下载完成但本地产物为 0 字节")
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "savedPath": artifact.RelativePath, "sizeBytes": artifact.SizeBytes})
},
}
@@ -296,68 +311,24 @@ var Search = shortcut.Shortcut{
if err != nil {
return err
}
files := searchFilesProject(data)
return rt.Output(map[string]any{"count": len(files), "files": files})
items, page, err := requireDriveCollection(data, "drive/search_files", "items", "files", "dentries", "entries", "nodes", "list")
if err != nil {
return err
}
files := projectDriveRows(items, map[string][]string{
"name": {"name", "fileName", "dentryName", "title"},
"type": {"type", "dentryType", "fileType", "spaceType"},
"nodeId": {"fileId", "dentryUuid", "nodeId", "id"},
"dentryId": {"dentryId"},
"fileSize": {"fileSize", "size", "byteSize", "length"},
"creatorId": {"creatorId", "creatorUserId", "creator", "creatorUid"},
})
out := map[string]any{"count": len(files), "files": files}
addDrivePagination(out, page)
return rt.Output(out)
},
}
// searchFilesProject reshapes the raw search_files response into a clean,
// stable 钉盘 file list ({name,type,dentryId,fileSize,creatorId}) — the
// output-projection fidelity applied to every list/search shortcut. Both the
// list container and each field are probed defensively across candidate keys,
// so a missing container or unknown alias yields an empty list rather than a
// fabricated value.
func searchFilesProject(data map[string]any) []map[string]any {
raw := searchFilesContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
searchFilesPick(row, m, "name", "name", "fileName", "dentryName", "title")
searchFilesPick(row, m, "type", "type", "dentryType", "fileType", "spaceType")
searchFilesPick(row, m, "dentryId", "dentryId", "dentryUuid", "id", "fileId", "nodeId")
searchFilesPick(row, m, "fileSize", "fileSize", "size", "byteSize", "length")
searchFilesPick(row, m, "creatorId", "creatorId", "creatorUserId", "creator", "creatorUid")
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// searchFilesContainer locates the file list array inside the response by
// trying the common container keys emitted across drive backends; the payload
// itself may also already wrap the array one level deeper.
func searchFilesContainer(data map[string]any) []any {
for _, k := range []string{"result", "data", "list", "items", "files", "dentries", "entries", "nodes"} {
if v, ok := data[k].([]any); ok {
return v
}
if inner, ok := data[k].(map[string]any); ok {
for _, ik := range []string{"list", "items", "files", "dentries", "entries", "nodes", "result"} {
if v, ok := inner[ik].([]any); ok {
return v
}
}
}
}
return nil
}
// searchFilesPick copies the first matching alias from src into dst under the
// canonical key, leaving dst untouched when no alias is present.
func searchFilesPick(dst, src map[string]any, canonical string, aliases ...string) {
for _, a := range aliases {
if v, ok := src[a]; ok {
dst[canonical] = v
return
}
}
}
// RecycleList → list_recycle_items
// RecycleRestore → restore_recycle_item
// PublishSet → set_file_publish (published=true)
@@ -413,66 +384,22 @@ var SearchDocs = shortcut.Shortcut{
if err != nil {
return err
}
docs := searchDocsProject(data)
return rt.Output(map[string]any{"count": len(docs), "docs": docs})
items, page, err := requireDriveCollection(data, "doc/search_documents", "documents", "docs", "nodes", "items", "list")
if err != nil {
return err
}
docs := projectDriveRows(items, map[string][]string{
"name": {"name", "title", "docName", "nodeName", "fileName"},
"nodeId": {"nodeId", "id", "docId", "dentryUuid", "fileId"},
"type": {"type", "docType", "nodeType", "fileType"},
"url": {"url", "docUrl", "link", "webUrl"},
})
out := map[string]any{"count": len(docs), "docs": docs}
addDrivePagination(out, page)
return rt.Output(out)
},
}
// searchDocsProject reshapes the raw search_documents response into a clean,
// stable document list ({name,nodeId,type,url}) — the output-projection
// fidelity applied to every list/search shortcut. Both the list container and
// each field are probed defensively across candidate keys, so a missing
// container or unknown alias yields an empty list rather than fabricated data.
func searchDocsProject(data map[string]any) []map[string]any {
raw := searchDocsContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
searchDocsPick(row, m, "name", "name", "title", "docName", "nodeName", "fileName")
searchDocsPick(row, m, "nodeId", "nodeId", "id", "docId", "dentryUuid", "fileId")
searchDocsPick(row, m, "type", "type", "docType", "nodeType", "fileType")
searchDocsPick(row, m, "url", "url", "docUrl", "link", "webUrl")
if len(row) > 0 {
out = append(out, row)
}
}
return out
}
// searchDocsContainer locates the document list array inside the response by
// trying the common container keys; the payload may also wrap the array one
// level deeper under a common envelope.
func searchDocsContainer(data map[string]any) []any {
for _, k := range []string{"result", "data", "list", "items", "documents", "docs", "nodes"} {
if v, ok := data[k].([]any); ok {
return v
}
if inner, ok := data[k].(map[string]any); ok {
for _, ik := range []string{"list", "items", "documents", "docs", "nodes", "result"} {
if v, ok := inner[ik].([]any); ok {
return v
}
}
}
}
return nil
}
// searchDocsPick copies the first matching alias from src into dst under the
// canonical key, leaving dst untouched when no alias is present.
func searchDocsPick(dst, src map[string]any, canonical string, aliases ...string) {
for _, a := range aliases {
if v, ok := src[a]; ok {
dst[canonical] = v
return
}
}
}
// Delete → delete_document (doc)
// Copy → copy_document (doc)
var Copy = shortcut.Shortcut{
@@ -514,6 +441,17 @@ var Copy = shortcut.Shortcut{
},
Tips: []string{`dws drive +copy --node <nodeId> --folder <targetFolderId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
preflight, err = requireDriveObject(preflight, "doc/get_document_info")
if err != nil {
return err
}
if !isOnlineDriveObject(preflight) {
return driveResponseError("doc/copy_document", "ordinary_file_copy_unsupported", "普通钉盘文件当前没有独立副本接口;doc/copy_document 会生成 .dlink 快捷方式。需要快捷入口请用 +create-shortcut;需要独立副本请先 +download 再 +upload")
}
params := map[string]any{"nodeId": rt.Str("node")}
if rt.Changed("folder") {
params["targetFolderId"] = rt.Str("folder")
@@ -521,7 +459,27 @@ var Copy = shortcut.Shortcut{
if rt.Changed("workspace") {
params["workspaceId"] = rt.Str("workspace")
}
return rt.CallMCP("copy_document", params)
written, err := rt.CallMCPWriteDataStrict("doc", "copy_document", params)
if err != nil {
return err
}
written, err = requireDriveWrite(written, "doc/copy_document")
if err != nil {
return err
}
createdID := nestedString(written, "nodeId", "fileId", "dentryUuid", "id")
if createdID == "" {
return driveResponseError("doc/copy_document", "missing_created_id", "复制响应没有新节点 ID;远端效果未知")
}
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": createdID})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "doc/get_document_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "sourceNodeId": rt.Str("node"), "nodeId": createdID, "copy": verified})
},
}
@@ -572,7 +530,23 @@ var Move = shortcut.Shortcut{
if rt.Changed("workspace") {
params["workspaceId"] = rt.Str("workspace")
}
return rt.CallMCP("move_document", params)
written, err := rt.CallMCPWriteDataStrict("doc", "move_document", params)
if err != nil {
return err
}
written, err = requireDriveWrite(written, "doc/move_document")
if err != nil {
return err
}
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "doc/get_document_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
},
}
@@ -647,65 +621,57 @@ var Recent = shortcut.Shortcut{
if err != nil {
return err
}
return rt.Output(recentListProject(data))
items, page, err := requireDriveCollection(data, "doc/get_recent_list", "recentItems")
if err != nil {
return err
}
rows := projectDriveRows(items, map[string][]string{
"name": {"name"},
"nodeType": {"nodeType"},
"contentType": {"contentType"},
"accessTime": {"accessTime"},
"docUrl": {"docUrl"},
"nodeId": {"nodeId"},
})
out := map[string]any{"count": len(rows), "items": rows}
addDrivePagination(out, page)
return rt.Output(out)
},
}
// recentListProject reshapes a get_recent_list response into a clean paginated
// document list, dropping transport noise (logId) while keeping the pagination
// cursor.
func recentListProject(data map[string]any) map[string]any {
// get_recent_list nests its payload under result.recentItems; earlier this
// read data["recentItems"] at the top level only, so the whole list silently
// projected to empty. Resolve the payload container (top-level or one level
// under result/data) before reading recentItems / pagination fields.
payload := data
if inner, ok := recentListPayload(data); ok {
payload = inner
}
items := []map[string]any{}
raw, _ := payload["recentItems"].([]any)
for _, it := range raw {
m, ok := it.(map[string]any)
if !ok {
continue
}
items = append(items, map[string]any{
"name": m["name"],
"nodeType": m["nodeType"],
"contentType": m["contentType"],
"accessTime": m["accessTime"],
"docUrl": m["docUrl"],
"nodeId": m["nodeId"],
})
}
out := map[string]any{"count": len(items), "items": items}
if nc, ok := payload["nextCursor"]; ok && nc != nil {
out["nextCursor"] = nc
}
if hm, ok := payload["hasMore"]; ok {
out["hasMore"] = hm
}
return out
}
// recentListPayload returns the map that actually holds recentItems, tolerating
// a {result|data:{recentItems:[...]}} envelope as well as a bare top-level shape.
func recentListPayload(data map[string]any) (map[string]any, bool) {
if _, ok := data["recentItems"]; ok {
return data, true
}
for _, k := range []string{"result", "data"} {
if inner, ok := data[k].(map[string]any); ok {
if _, ok := inner["recentItems"]; ok {
return inner, true
}
}
}
return nil, false
}
func init() {
Copy.Description = "复制在线文档到指定位置并读回验证"
Copy.Intent = "复制钉钉在线文档到新位置并保留原件时使用;先验证对象类型,普通文件不伪装成复制成功。"
Copy.Contract = driveContract(
"+copy", Copy.Description,
"复制钉钉在线文档到新位置并保留原件时使用;先验证对象类型,普通文件不伪装成复制成功。",
[]string{"普通钉盘文件独立复制当前无等价接口:需要快捷入口用 +create-shortcut,需要独立字节副本用 +download 后 +upload;移动原件用 +move"},
[]string{`dws drive +copy --node <ONLINE_DOC_ID> --folder <TARGET_FOLDER_ID>`},
driveObjectResult("在线文档复制并读回后的新节点"), nil,
// Preserve the historical public Schema properties. Execute translates
// these CLI concepts to nodeId/targetFolderId/workspaceId for the RPC.
contract.ParamDecl{Name: "node", Property: "node"},
contract.ParamDecl{Name: "folder", Property: "folder"},
contract.ParamDecl{Name: "workspace", Property: "workspace"},
)
Copy.Tips = []string{`dws drive +copy --node <ONLINE_DOC_ID> --folder <TARGET_FOLDER_ID>`}
Info.Contract.Result = driveObjectResult("钉盘节点元数据")
Search.Contract.Result = driveCollectionResult("files", "严格校验并投影的钉盘搜索结果页")
Search.Contract.Pagination = driveCursorPagination()
SearchDocs.Contract.Result = driveCollectionResult("docs", "兼容入口的在线文档搜索结果页")
Copy.Contract.Result = driveObjectResult("复制操作的终态证据")
Move.Contract.Result = driveObjectResult("移动操作的终态证据")
Recent.Contract.Result = driveCollectionResult("items", "严格校验的最近访问或编辑结果页")
Recent.Contract.Pagination = driveCursorPagination()
for _, declaration := range []*shortcut.Shortcut{
&List, &Info, &Download, &Search, &SearchDocs, &Copy, &Move, &Recent,
&Inspect, &Upload, &CreateFolder, &CreateShortcut, &Rename, &Delete, &Stats,
&RecycleList, &RecycleRestore, &StarList, &StarAdd, &StarRemove,
&PublishGet, &PublishSet, &PublishUnset, &Cover,
&VersionHistory, &VersionGet, &VersionDownload, &VersionRevert,
} {
declaration.OutputRollout = output.RolloutUnifiedActive
}
shortcut.Register(
List,
Info,
@@ -715,5 +681,35 @@ func init() {
Copy,
Move,
Recent,
Inspect,
Upload,
CreateFolder,
CreateShortcut,
Rename,
Delete,
Stats,
RecycleList,
RecycleRestore,
StarList,
StarAdd,
StarRemove,
PublishGet,
PublishSet,
PublishUnset,
Cover,
VersionHistory,
VersionGet,
VersionDownload,
VersionRevert,
)
}
func isOnlineDriveObject(info map[string]any) bool {
extension := strings.ToLower(firstString(info, "extension", "fileExtension", "ext"))
switch extension {
case "adoc", "axls", "able", "amind", "adraw":
return true
}
contentType := strings.ToUpper(firstString(info, "contentType", "docType"))
return contentType == "DOC" || contentType == "SHEET" || contentType == "TABLE" || contentType == "MIND" || contentType == "DRAW"
}
File diff suppressed because it is too large Load Diff
+335
View File
@@ -0,0 +1,335 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package drive
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var Inspect = shortcut.Shortcut{
Service: "drive", Command: "+inspect", Product: "drive",
Description: "聚合检查节点元数据及可选统计、公开状态和封面",
Intent: "已知节点 ID,需要一次确认名称、类型、路径,并可附带统计、公开状态或封面时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+inspect", "聚合检查节点元数据及可选统计、公开状态和封面",
"已知节点 ID,需要一次确认名称、类型、路径,并可附带统计、公开状态或封面时使用。",
[]string{"读取在线文档正文使用 doc +fetch;浏览目录使用 drive +list"},
[]string{`dws drive +inspect --node <dentryUuid>`, `dws drive +inspect --node <dentryUuid> --include-stats --include-publish`},
driveObjectResult("Drive 节点聚合检查结果"), nil,
contract.ParamDecl{Name: "node", Property: "fileId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true},
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
{Name: "include-stats", Type: shortcut.FlagBool, Desc: "附带阅读、编辑、下载等统计"},
{Name: "include-publish", Type: shortcut.FlagBool, Desc: "附带互联网公开状态"},
{Name: "include-cover", Type: shortcut.FlagBool, Desc: "附带封面或缩略图地址"},
},
Tips: []string{`dws drive +inspect --node <dentryUuid>`, `dws drive +inspect --node <dentryUuid> --include-stats --include-publish`},
Execute: func(rt *shortcut.RuntimeContext) error {
node := rt.Str("node")
params := map[string]any{"fileId": node}
if rt.Str("space-id") != "" {
params["spaceId"] = rt.Str("space-id")
}
data, err := rt.CallMCPData("drive", "get_file_info", params)
if err != nil {
return err
}
info, err := requireDriveObject(data, "drive/get_file_info")
if err != nil {
return err
}
result := map[string]any{"file": info}
steps := []map[string]any{{"tool": "get_file_info", "status": "success"}}
reads := []struct {
flag, key, tool string
params map[string]any
}{
{"include-stats", "stats", "get_node_stats", map[string]any{"nodeId": node}},
{"include-publish", "publish", "get_file_publish_status", map[string]any{"fileId": node}},
{"include-cover", "cover", "get_cover", map[string]any{"nodeId": node}},
}
failures := []map[string]any{}
for _, read := range reads {
if !rt.Bool(read.flag) {
continue
}
value, callErr := rt.CallMCPReadData("drive", read.tool, read.params)
if callErr == nil {
value, callErr = requireDriveObject(value, "drive/"+read.tool)
}
if callErr != nil {
steps = append(steps, map[string]any{"tool": read.tool, "status": "failed"})
failures = append(failures, map[string]any{"tool": read.tool, "error": callErr.Error()})
continue
}
result[read.key] = value
steps = append(steps, map[string]any{"tool": read.tool, "status": "success"})
}
if len(failures) > 0 {
return apperrors.NewAPI("Drive 聚合检查只完成了部分读取",
apperrors.WithOperation("drive.inspect"),
apperrors.WithReason("drive_inspect_partial"),
apperrors.WithFailureStage("optional_reads"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithDetails(map[string]any{"status": "partial_success", "complete": false, "data": result, "steps": steps, "failures": failures}),
)
}
return rt.Output(map[string]any{"status": "success", "complete": true, "data": result, "steps": steps})
},
}
var CreateFolder = shortcut.Shortcut{
Service: "drive", Command: "+create-folder", Product: "drive",
Description: "创建钉盘文件夹并读回验证",
Intent: "在钉盘中创建普通文件夹并需要拿到经过读回验证的 fileId 时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: driveContract(
"+create-folder", "创建钉盘文件夹并读回验证",
"在钉盘中创建普通文件夹并需要拿到经过读回验证的 fileId 时使用。",
[]string{"知识库目录使用 wiki node create;创建在线文档使用 doc +create"},
[]string{`dws drive +create-folder --name "项目资料"`, `dws drive +create-folder --name "子目录" --folder <dentryUuid>`},
driveObjectResult("创建并验证后的文件夹"), nil,
contract.ParamDecl{Name: "folder", Property: "parentId"},
),
Flags: []shortcut.Flag{
{Name: "name", Type: shortcut.FlagString, Desc: "文件夹名称", Required: true},
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
{Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"},
},
Tips: []string{`dws drive +create-folder --name "项目资料"`, `dws drive +create-folder --name "子目录" --folder <dentryUuid>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"name": rt.Str("name")}
if rt.Str("space-id") != "" {
params["spaceId"] = rt.Str("space-id")
}
if rt.Str("folder") != "" {
params["parentId"] = rt.Str("folder")
}
created, err := rt.CallMCPWriteDataStrict("drive", "create_folder", params)
if err != nil {
return err
}
created, err = requireDriveWrite(created, "drive/create_folder")
if err != nil {
return err
}
nodeID := nestedString(created, "fileId", "dentryUuid", "nodeId", "id")
if nodeID == "" {
return driveResponseError("drive/create_folder", "missing_created_id", "创建响应没有文件夹 fileId;远端效果未知")
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
if name := firstString(verified, "name", "fileName"); name != rt.Str("name") {
return driveResponseError("drive/create_folder", "readback_mismatch", fmt.Sprintf("创建后读回名称 %q 与请求 %q 不一致", name, rt.Str("name")))
}
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "folder": verified})
},
}
var CreateShortcut = shortcut.Shortcut{
Service: "drive", Command: "+create-shortcut", Product: "drive",
Description: "为已有节点创建快捷方式并验证新节点",
Intent: "给已有文件或文档创建快捷入口,并需要区分 shortcut 与独立副本时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent"},
Contract: driveContract(
"+create-shortcut", "为已有节点创建快捷方式并验证新节点",
"给已有文件或文档创建快捷入口,并需要区分 shortcut 与独立副本时使用。",
[]string{"需要独立副本使用 drive +copy;需要迁移原节点使用 drive +move"},
[]string{`dws drive +create-shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>`},
driveObjectResult("创建并验证后的快捷方式节点"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
contract.ParamDecl{Name: "folder", Property: "targetFolderId"},
contract.ParamDecl{Name: "workspace", Property: "workspaceId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
},
Tips: []string{`dws drive +create-shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if rt.Str("folder") != "" {
params["targetFolderId"] = rt.Str("folder")
}
if rt.Str("workspace") != "" {
params["workspaceId"] = rt.Str("workspace")
}
created, err := rt.CallMCPWriteDataStrict("drive", "create_shortcut", params)
if err != nil {
return err
}
created, err = requireDriveWrite(created, "drive/create_shortcut")
if err != nil {
return err
}
nodeID := nestedString(created, "fileId", "dentryUuid", "nodeId", "id")
if nodeID == "" {
return driveResponseError("drive/create_shortcut", "missing_created_id", "创建快捷方式未返回新节点 ID")
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "shortcut": verified})
},
}
var Rename = shortcut.Shortcut{
Service: "drive", Command: "+rename", Product: "doc",
Description: "重命名文件或文件夹并读回验证",
Intent: "改变已有节点名称并要求验证最终名称时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: driveContract(
"+rename", "重命名文件或文件夹并读回验证",
"改变已有节点名称并要求验证最终名称时使用。",
[]string{"改变位置使用 drive +move;替换文件内容使用 drive +upload --node"},
[]string{`dws drive +rename --node <dentryUuid> --name "新名称"`},
driveObjectResult("重命名后的节点元数据"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
contract.ParamDecl{Name: "name", Property: "newName"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新名称", Required: true},
},
Tips: []string{`dws drive +rename --node <dentryUuid> --name "新名称"`},
Execute: func(rt *shortcut.RuntimeContext) error {
preflight, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
if err != nil {
return err
}
preflight, err = requireDriveObject(preflight, "drive/get_file_info")
if err != nil {
return err
}
requestName, expectedNames := normalizedDriveRename(rt.Str("name"), preflight)
written, err := rt.CallMCPWriteDataStrict("doc", "rename_document", map[string]any{"nodeId": rt.Str("node"), "newName": requestName})
if err != nil {
return err
}
if _, err := requireDriveWrite(written, "doc/rename_document"); err != nil {
return err
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
name := firstString(verified, "name", "fileName")
if name == "" || !expectedNames[name] {
return driveResponseError("doc/rename_document", "readback_mismatch", fmt.Sprintf("重命名读回名称 %q 与请求 %q 不一致", name, rt.Str("name")))
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
},
}
func normalizedDriveRename(name string, info map[string]any) (string, map[string]bool) {
name = strings.TrimSpace(name)
expected := map[string]bool{name: true}
nodeType := strings.ToLower(firstString(info, "type", "nodeType", "fileType"))
if nodeType == "folder" || nodeType == "dir" || nodeType == "directory" {
return name, expected
}
extension := strings.TrimLeft(strings.ToLower(firstString(info, "extension", "fileExtension", "ext")), ".")
if extension == "" {
return name, expected
}
suffix := "." + extension
if len(name) > len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) {
return name[:len(name)-len(suffix)], expected
}
expected[name+suffix] = true
return name, expected
}
var Delete = shortcut.Shortcut{
Service: "drive", Command: "+delete", Product: "doc",
Description: "将已确认节点移入回收站",
Intent: "用户明确要求删除已确认钉盘节点,并理解它会进入回收站时使用。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
Contract: driveContract(
"+delete", "将已确认节点移入回收站",
"用户明确要求删除已确认钉盘节点,并理解它会进入回收站时使用。",
[]string{"只是调整位置使用 drive +move;目标不明确时先 drive +inspect"},
[]string{`dws drive +delete --node <dentryUuid>`},
driveObjectResult("删除到回收站的终态证据"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
Tips: []string{`dws drive +delete --node <dentryUuid>`},
Execute: func(rt *shortcut.RuntimeContext) error {
written, err := rt.CallMCPWriteDataStrict("doc", "delete_document", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
written, err = requireDriveWrite(written, "doc/delete_document")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "result": written})
},
}
var Stats = driveReadObjectShortcut(
"+stats", "读取节点访问和协作统计", "get_node_stats", "nodeId",
"用户要查看指定节点阅读、编辑、评论、点赞、预览或下载统计时使用。",
[]string{"只要文件名称和类型使用 drive +inspect"},
)
var Cover = driveReadObjectShortcut(
"+cover", "读取节点封面或缩略图地址", "get_cover", "nodeId",
"用户需要节点封面、首图或缩略图 URL 时使用。",
[]string{"这是封面资源,不等于 Lark 服务端多格式预览转换"},
)
func driveReadObjectShortcut(command, description, tool, property, useWhen string, avoidWhen []string) shortcut.Shortcut {
return shortcut.Shortcut{
Service: "drive", Command: command, Product: "drive", Description: description, Intent: useWhen,
Risk: shortcut.RiskRead, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(command, description, useWhen, avoidWhen,
[]string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
driveObjectResult(description), nil, contract.ParamDecl{Name: "node", Property: property}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
Tips: []string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("drive", tool, map[string]any{property: rt.Str("node")})
if err != nil {
return err
}
data, err = requireDriveObject(data, "drive/"+tool)
if err != nil {
return err
}
return rt.Output(data)
},
}
}
@@ -1,66 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drive
import (
"encoding/json"
"testing"
)
// TestRecentListProjectResultWrapper guards against projection-data-loss:
// get_recent_list nests its payload under result.recentItems; the projection
// must descend into result or +recent silently returns empty despite backend
// records.
func TestRecentListProjectResultWrapper(t *testing.T) {
const raw = `{"result":{"hasMore":true,"nextCursor":"c2","recentItems":[
{"name":"weekly report","nodeType":"doc","nodeId":"n1","docUrl":"https://x/1"},
{"name":"budget sheet","nodeType":"sheet","nodeId":"n2","docUrl":"https://x/2"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
out := recentListProject(data)
if got, _ := out["count"].(int); got != 2 {
t.Fatalf("lower/upper mismatch: result.recentItems has 2 entries, projection count=%v (%v)", out["count"], out)
}
if out["hasMore"] != true || out["nextCursor"] != "c2" {
t.Fatalf("pagination fields lost from result wrapper: %v", out)
}
}
// TestRecentListProjectNoItems covers the no-recentItems branch (payload not
// found under any wrapper), which must yield an empty list, not a panic.
func TestRecentListProjectNoItems(t *testing.T) {
const raw = `{"result":{"totalCount":0},"success":true}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if out := recentListProject(data); out["count"].(int) != 0 {
t.Fatalf("no recentItems: want count 0, got %v", out["count"])
}
}
// TestRecentListProjectTopLevel covers the already-unwrapped shape.
func TestRecentListProjectTopLevel(t *testing.T) {
const raw = `{"recentItems":[{"name":"weekly report","nodeId":"n1"}],"hasMore":false}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if out := recentListProject(data); out["count"].(int) != 1 {
t.Fatalf("top-level recentItems: want count 1, got %v", out["count"])
}
}
@@ -0,0 +1,292 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package drive
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var VersionHistory = shortcut.Shortcut{
Service: "drive", Command: "+version-history", Product: "drive",
Description: "严格分页列出普通文件历史版本",
Intent: "普通文件需要浏览历史版本并获取明确版本号时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+version-history", "严格分页列出普通文件历史版本",
"普通文件需要浏览历史版本并获取明确版本号时使用。",
[]string{"在线文档版本使用 doc +version-list;在线表格版本使用 sheet version"},
[]string{`dws drive +version-history --node <dentryUuid> --limit 20`},
driveCollectionResult("versions", "严格校验的普通文件历史版本页"), driveCursorPagination(),
contract.ParamDecl{Name: "node", Property: "nodeId"},
contract.ParamDecl{Name: "cursor", Property: "nextCursor"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
},
Tips: []string{`dws drive +version-history --node <dentryUuid> --limit 20`},
Execute: func(rt *shortcut.RuntimeContext) error {
page, err := versionPage(rt, rt.Str("node"), rt.Int("limit"), rt.Str("cursor"))
if err != nil {
return err
}
return rt.Output(page)
},
}
var VersionGet = shortcut.Shortcut{
Service: "drive", Command: "+version-get", Product: "drive",
Description: "按版本号精确读取普通文件版本元数据",
Intent: "已知普通文件版本号,需要确认该版本存在并读取其元数据时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+version-get", "按版本号精确读取普通文件版本元数据",
"已知普通文件版本号,需要确认该版本存在并读取其元数据时使用。",
[]string{"尚未确定版本号时使用 drive +version-history;需要版本字节使用 drive +version-download"},
[]string{`dws drive +version-get --node <dentryUuid> --version 3`},
driveObjectResult("精确匹配的普通文件版本元数据"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
{Name: "version", Type: shortcut.FlagInt, Desc: "版本号;--version 必须为正整数", Required: true},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"version"}, Description: "--version 必须为正整数"}},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("version") <= 0 {
return fmt.Errorf("--version 必须为正整数")
}
return nil
},
Tips: []string{`dws drive +version-get --node <dentryUuid> --version 3`},
Execute: func(rt *shortcut.RuntimeContext) error {
version, err := findVersion(rt, rt.Str("node"), rt.Int("version"))
if err != nil {
return err
}
return rt.Output(map[string]any{"nodeId": rt.Str("node"), "version": version})
},
}
var VersionDownload = shortcut.Shortcut{
Service: "drive", Command: "+version-download", Product: "drive",
Description: "安全下载普通文件指定历史版本",
Intent: "已确认普通文件版本号,需要安全落盘并验证实际字节时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: driveContract(
"+version-download", "安全下载普通文件指定历史版本",
"已确认普通文件版本号,需要安全落盘并验证实际字节时使用。",
[]string{"下载最新版本使用 drive +download;在线文档版本使用 doc +version-list/+export"},
[]string{`dws drive +version-download --node <dentryUuid> --version 3 --output downloads/report-v3.pdf`},
driveObjectResult("已验证的历史版本本地产物"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
{Name: "version", Type: shortcut.FlagInt, Desc: "版本号;--version 必须为正整数", Required: true},
{Name: "output", Type: shortcut.FlagString, Desc: "工作目录内相对输出路径", Required: true},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"version"}, Description: "--version 必须为正整数"}},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("version") <= 0 {
return fmt.Errorf("--version 必须为正整数")
}
return nil
},
Tips: []string{`dws drive +version-download --node <dentryUuid> --version 3 --output downloads/report-v3.pdf`},
Execute: func(rt *shortcut.RuntimeContext) error {
if _, err := findVersion(rt, rt.Str("node"), rt.Int("version")); err != nil {
return err
}
data, err := rt.CallMCPData("drive", "download_file_version", map[string]any{"nodeId": rt.Str("node"), "version": rt.Int("version")})
if err != nil {
return err
}
payload, err := requireDriveObject(data, "drive/download_file_version")
if err != nil {
return err
}
url, name, headers, err := driveDownloadPayload(payload, "drive/download_file_version")
if err != nil {
return err
}
cwd, err := driveGetwd()
if err != nil {
return err
}
artifact, err := driveDownload(rt.Command().Context(), url, localio.DownloadOptions{BaseDir: cwd, Output: rt.Str("output"), PreferredName: name, Headers: headers})
if err != nil {
return err
}
if artifact.SizeBytes <= 0 {
return driveResponseError("drive/download_file_version", "empty_download_artifact", "历史版本下载产物为 0 字节")
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "version": rt.Int("version"), "savedPath": artifact.RelativePath, "sizeBytes": artifact.SizeBytes})
},
}
var VersionRevert = shortcut.Shortcut{
Service: "drive", Command: "+version-revert", Product: "drive",
Description: "预检并回滚普通文件到指定历史版本",
Intent: "用户明确选定普通文件历史版本并要求回滚时使用。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "non_idempotent"},
Contract: driveContract(
"+version-revert", "预检并回滚普通文件到指定历史版本",
"用户明确选定普通文件历史版本并要求回滚时使用。",
[]string{"尚未确认版本号时先 drive +version-history;在线文档使用 doc +version-revert"},
[]string{`dws drive +version-revert --node <dentryUuid> --version 3`},
driveObjectResult("版本回滚终态及节点读回"), nil,
contract.ParamDecl{Name: "node", Property: "nodeId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "普通文件节点 ID", Required: true},
{Name: "version", Type: shortcut.FlagInt, Desc: "版本号;--version 必须为正整数", Required: true},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"version"}, Description: "--version 必须为正整数"}},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("version") <= 0 {
return fmt.Errorf("--version 必须为正整数")
}
return nil
},
Tips: []string{`dws drive +version-revert --node <dentryUuid> --version 3`},
Execute: func(rt *shortcut.RuntimeContext) error {
if _, err := findVersion(rt, rt.Str("node"), rt.Int("version")); err != nil {
return err
}
written, err := rt.CallMCPWriteDataStrict("drive", "revert_file_version", map[string]any{"nodeId": rt.Str("node"), "version": rt.Int("version")})
if err != nil {
return err
}
if _, err := requireDriveWrite(written, "drive/revert_file_version"); err != nil {
return err
}
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
if err != nil {
return err
}
verified, err = requireDriveObject(verified, "drive/get_file_info")
if err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "revertedTo": rt.Int("version"), "file": verified})
},
}
func versionPage(rt *shortcut.RuntimeContext, nodeID string, limit int, cursor string) (map[string]any, error) {
params := map[string]any{"nodeId": nodeID, "maxResults": limit}
if cursor != "" {
params["nextCursor"] = cursor
}
data, err := rt.CallMCPData("drive", "list_file_versions", params)
if err != nil {
return nil, err
}
items, page, err := requireDriveCollection(data, "drive/list_file_versions", "versions", "versionList", "items")
if err != nil {
return nil, err
}
versions := projectDriveRows(items, map[string][]string{
"version": {"version", "versionNumber", "versionNum"},
"createTime": {"createTime", "createdTime"},
"creatorId": {"creatorId", "creatorUserId", "operatorId"},
"fileSize": {"fileSize", "size"},
"name": {"name", "fileName"},
})
out := map[string]any{"count": len(versions), "versions": versions}
addDrivePagination(out, page)
return out, nil
}
func findVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (map[string]any, error) {
const maxPages = 20
cursor := ""
seenCursors := map[string]bool{}
for pageNumber := 1; pageNumber <= maxPages; pageNumber++ {
page, err := versionPage(rt, nodeID, 50, cursor)
if err != nil {
return nil, err
}
for _, version := range page["versions"].([]map[string]any) {
if number, ok := versionNumber(version); ok && number == target {
return version, nil
}
}
nextCursor := strings.TrimSpace(nestedString(page, "nextCursor", "nextToken", "nextPageToken"))
hasMore, hasMoreKnown := boolField(page, "hasMore")
if hasMoreKnown && !hasMore {
return nil, driveResponseError("drive/list_file_versions", "version_not_found", fmt.Sprintf("完整版本历史中不存在版本 %d", target))
}
if nextCursor == "" {
if hasMoreKnown && hasMore {
return nil, versionPaginationError("missing_next_cursor", pageNumber, cursor)
}
return nil, driveResponseError("drive/list_file_versions", "version_not_found", fmt.Sprintf("完整版本历史中不存在版本 %d", target))
}
if seenCursors[nextCursor] {
return nil, versionPaginationError("stalled_cursor", pageNumber, nextCursor)
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
return nil, versionPaginationError("max_pages", maxPages, cursor)
}
func versionPaginationError(reason string, page int, cursor string) error {
return driveResponseError(
"drive/list_file_versions",
"version_pagination_"+reason,
fmt.Sprintf("版本预检无法证明分页已经完整,已停止操作(page=%d, cursor=%q)", page, cursor),
)
}
func versionNumber(version map[string]any) (int, bool) {
switch value := version["version"].(type) {
case float64:
return int(value), value == float64(int(value))
case int:
return value, true
case string:
var number int
if _, err := fmt.Sscanf(value, "%d", &number); err == nil {
return number, true
}
}
return 0, false
}
func driveDownloadPayload(payload map[string]any, operation string) (string, string, map[string]string, error) {
url := firstString(payload, "downloadUrl", "resourceUrl", "url")
if url == "" {
if resources, ok := payload["resourceUrls"].([]any); ok && len(resources) > 0 {
if first, ok := resources[0].(map[string]any); ok {
url = firstString(first, "url", "downloadUrl", "resourceUrl")
payload = first
}
}
}
if url == "" {
return "", "", nil, driveResponseError(operation, "missing_download_url", "下载响应没有有效 URL")
}
headers := map[string]string{}
if raw, ok := payload["headers"].(map[string]any); ok {
for key, value := range raw {
if text, ok := value.(string); ok {
headers[key] = text
}
}
}
return url, firstString(payload, "fileName", "name"), headers, nil
}
+758
View File
@@ -0,0 +1,758 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
var (
minutesPutFile = localio.PutFile
minutesDownload = localio.Download
)
var Search = shortcut.Shortcut{
Service: "minutes", Command: "+search", Product: "minutes",
Description: "按范围、标题关键词和时间搜索听记,支持安全全量翻页",
Intent: "需要按标题关键词或时间范围搜索自己创建、他人共享或全部可访问听记时使用;对后端返回再做确定性标题匹配,返回稳定 taskUuid 投影与完整性信息。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: minutesContract("+search", "按范围、标题关键词和时间搜索听记,支持安全全量翻页",
"需要按标题关键词或时间范围搜索自己创建、他人共享或全部可访问听记,并拿到可继续操作的 taskUuid 时使用",
[]string{"只想无条件浏览一页时可用 +list-mine/+list-shared/+list-all", "DWS 后端不支持 owner/participant 精确过滤,不要伪造该条件"},
[]string{`dws minutes +search --query "周会" --scope all`, `dws minutes +search --start "2026-08-01T00:00:00+08:00" --scope mine --page-all`}),
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "标题关键词;shortcut 会对后端结果再次精确包含过滤"},
{Name: "scope", Type: shortcut.FlagString, Default: "all", Desc: "搜索范围", Enum: []string{"mine", "shared", "all"}},
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 RFC3339"},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 RFC3339"},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "起始 nextToken"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部页"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "自动翻页安全上限"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "start", "end"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit", "page-limit"}, Description: "--limit/--page-limit 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "时间必须是 RFC3339 且开始时间不能晚于结束时间"},
},
Tips: []string{`dws minutes +search --query "周会" --scope all`, `dws minutes +search --start "2026-08-01T00:00:00+08:00" --scope mine --page-all`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("limit") <= 0 || rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--limit 和 --page-limit 必须大于 0")
}
_, _, err := minutesTimeRange(rt.Str("start"), rt.Str("end"))
return err
},
Execute: executeMinutesSearch,
}
var Download = shortcut.Shortcut{
Service: "minutes", Command: "+download", Product: "minutes",
Description: "批量取得听记音视频地址并安全下载到本地",
Intent: "已知一个或多个 taskUuid,需要取得音视频 OSS 地址或以 no-clobber、原子发布方式保存到工作目录时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: minutesContract("+download", "批量取得听记音视频地址并安全下载到本地",
"已知一个或多个听记 taskUuid,需要下载其音频/视频或显式获取临时 URL 时使用",
[]string{"只要查看摘要或逐字稿时不要下载媒体", "媒体未就绪、已过期或无痕模式时会明确失败,不返回空成功"},
[]string{`dws minutes +download --id <taskUuid> --output ./meeting-media`, `dws minutes +download --ids <uuid1,uuid2> --output-dir ./minutes-downloads`}),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "单个 taskUuid"},
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个 taskUuid,最多 50 个"},
{Name: "url-only", Type: shortcut.FlagBool, Desc: "只返回临时签名 URL,不下载"},
{Name: "output", Type: shortcut.FlagString, Desc: "单个目标的工作目录内相对输出路径"},
{Name: "output-dir", Type: shortcut.FlagString, Default: "minutes-downloads", Desc: "批量下载目录"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "去重后 taskUuid 数量必须为 1..50"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"ids", "output"}, Description: "批量下载不能使用 --output"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"output", "output-dir"}, Description: "输出必须是安全的工作目录相对路径"},
},
Tips: []string{`dws minutes +download --id <taskUuid> --output ./meeting-media`, `dws minutes +download --ids <uuid1,uuid2> --output-dir ./minutes-downloads`},
Validate: func(rt *shortcut.RuntimeContext) error {
ids := minutesIDs(rt)
if len(ids) == 0 || len(ids) > 50 {
return apperrors.NewValidation("taskUuid 数量必须为 1..50")
}
if len(ids) > 1 && rt.Changed("output") {
return apperrors.NewValidation("批量下载不能使用 --output,请使用 --output-dir")
}
if rt.Changed("output") {
return localio.ValidateOutput(rt.Str("output"))
}
return localio.ValidateOutput(rt.Str("output-dir"))
},
Execute: executeMinutesDownload,
}
var Upload = shortcut.Shortcut{
Service: "minutes", Command: "+upload", Product: "minutes",
Description: "把本地音视频完整上传并创建听记,失败时取消会话",
Intent: "需要从本地音视频直接创建听记时使用;完成 create、预签名 PUT、complete 和最终详情读回,不需要中转 Drive。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+upload", "把本地音视频完整上传并创建听记,失败时取消会话",
"用户有本地音视频,希望直接上传生成听记并取得可读回的 taskUuid 时使用",
[]string{"只需要管理已有 upload session 时使用原子 upload create/complete/cancel", "文件为空、过大或不希望创建远端听记时不要执行"},
[]string{`dws minutes +upload --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload --file ./meeting.mp4 --input-language zh`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "本地音视频文件", Required: true},
{Name: "title", Type: shortcut.FlagString, Desc: "听记标题"},
{Name: "template-id", Type: shortcut.FlagString, Desc: "纪要模板 ID"},
{Name: "input-language", Type: shortcut.FlagString, Desc: "ASR 输入语言"},
{Name: "enable-message-card", Type: shortcut.FlagBool, Desc: "生成后推送闪记卡片"},
{Name: "complete-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待服务端确认上传完成的秒数"},
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "2", Desc: "complete 重试间隔秒数"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"complete-timeout", "poll-interval"}, Description: "--complete-timeout 和 --poll-interval 必须大于 0"}},
Tips: []string{`dws minutes +upload --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload --file ./meeting.mp4 --input-language zh`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("complete-timeout") <= 0 || rt.Int("poll-interval") <= 0 {
return apperrors.NewValidation("--complete-timeout 和 --poll-interval 必须大于 0")
}
return nil
},
Execute: executeMinutesUpload,
}
var Update = shortcut.Shortcut{
Service: "minutes", Command: "+update", Product: "minutes",
Description: "读取现状、预览差异、更新听记标题并读回验证",
Intent: "已知 taskUuid,需要安全重命名听记标题并确认远端最终值时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withMinutesDryRun(minutesContract("+update", "读取现状、更新听记标题并读回验证;dry-run 输出不访问远端的执行计划",
"需要修改一条听记的标题,并通过最终读回证明修改生效时使用",
[]string{"要修改纪要正文时使用 +summary"},
[]string{`dws minutes +update --id <taskUuid> --title "Q3 项目复盘"`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "title", Type: shortcut.FlagString, Desc: "新标题", Required: true},
},
Tips: []string{`dws minutes +update --id <taskUuid> --title "Q3 项目复盘"`},
Execute: executeMinutesUpdate,
}
var ApplyPermission = shortcut.Shortcut{
Service: "minutes", Command: "+apply-permission", Product: "minutes",
Description: "为当前用户申请语义化的听记访问权限",
Intent: "打开听记无权限时,用 view/download/edit 语义申请对应权限,不需要记 policy 数字。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+apply-permission", "为当前用户申请语义化的听记访问权限",
"当前用户访问某条听记被拒绝,需要向所有者申请查看、下载或编辑权限时使用",
[]string{"所有者给其他成员授权时使用 +share", "要移除权限时使用 +unshare"},
[]string{`dws minutes +apply-permission --id <taskUuid> --permission view`, `dws minutes +apply-permission --id <taskUuid> --permission edit`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "permission", Type: shortcut.FlagString, Desc: "申请权限", Required: true, Enum: []string{"view", "download", "edit"}},
},
Tips: []string{`dws minutes +apply-permission --id <taskUuid> --permission view`, `dws minutes +apply-permission --id <taskUuid> --permission edit`},
Execute: executeMinutesApplyPermission,
}
var Summary = shortcut.Shortcut{
Service: "minutes", Command: "+summary", Product: "minutes",
Description: "读取当前纪要、校验图片引用、全量覆盖并读回验证",
Intent: "需要安全编辑听记纪要正文时使用;支持字面量、@file 和 stdin,保护现有 Markdown 图片并验证最终全文。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withMinutesDryRun(minutesContract("+summary", "校验本地纪要输入、全量覆盖并读回验证;dry-run 不访问远端",
"需要用确认后的完整 Markdown 覆盖一条听记纪要,并保护已有图片引用时使用",
[]string{"只改标题时使用 +update", "没有完整目标内容时不要执行全量覆盖"},
[]string{`dws minutes +summary --id <taskUuid> --content "项目周会纪要"`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "完整纪要字面量、@相对文件或 - 表示 stdin", Required: true},
},
Tips: []string{`dws minutes +summary --id <taskUuid> --content @summary.md`, `dws minutes +summary --id <taskUuid> --content -`},
Execute: executeMinutesSummary,
}
var SpeakerReplace = shortcut.Shortcut{
Service: "minutes", Command: "+speaker-replace", Product: "minutes",
Description: "预检逐字稿中的发言人昵称,替换后重新读回验证",
Intent: "需要把听记中的一个发言人昵称替换为目标昵称/UID 时使用;这是昵称替换,不是 Lark speaker_id 身份重绑。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withMinutesDryRun(minutesContract("+speaker-replace", "预检逐字稿中的发言人昵称,替换后重新读回验证",
"逐字稿已经生成且用户明确要把一个源发言人昵称批量替换为目标昵称时使用",
[]string{"DWS 不是 speaker_id 到用户 open_id 的身份重绑;需要该语义时当前无法对齐", "源昵称不存在或目标身份不确定时不要写"},
[]string{`dws minutes +speaker-replace --id <taskUuid> --from "发言人1" --to "张三"`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "from", Type: shortcut.FlagString, Desc: "源发言人昵称", Required: true},
{Name: "to", Type: shortcut.FlagString, Desc: "目标发言人昵称", Required: true},
{Name: "target-uid", Type: shortcut.FlagString, Desc: "目标钉钉 UID"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿预检翻页上限"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"from", "to"}, Description: "--from/--to 去空白后不能相同"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
},
Tips: []string{`dws minutes +speaker-replace --id <taskUuid> --from "发言人1" --to "张三"`},
Validate: func(rt *shortcut.RuntimeContext) error {
if strings.TrimSpace(rt.Str("from")) == strings.TrimSpace(rt.Str("to")) {
return apperrors.NewValidation("--from 与 --to 不能相同")
}
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
return nil
},
Execute: executeMinutesSpeakerReplace,
}
func executeMinutesSearch(rt *shortcut.RuntimeContext) error {
start, end, err := minutesTimeRange(rt.Str("start"), rt.Str("end"))
if err != nil {
return err
}
belonging := map[string]string{"mine": "createdByMe", "shared": "sharedToMe", "all": "noLimit"}[rt.Str("scope")]
token := rt.Str("cursor")
seenTokens := map[string]bool{}
seenIDs := map[string]bool{}
rows := make([]map[string]any, 0)
pages := 0
complete := false
nextToken := ""
for {
if seenTokens[token] {
return fmt.Errorf("minutes search cursor stalled or cycled")
}
seenTokens[token] = true
params := map[string]any{"belongingConditionId": belonging, "maxResults": rt.Int("limit")}
if query := strings.TrimSpace(rt.Str("query")); query != "" {
params["keyword"] = query
}
if start != 0 {
params["createTimeStart"] = float64(start)
}
if end != 0 {
params["createTimeEnd"] = float64(end)
}
if token != "" {
params["nextToken"] = token
}
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", params)
if err != nil {
return err
}
page, err := minutesdata.ParseListPage(data)
if err != nil {
return err
}
projected, err := minutesdata.ProjectList(page)
if err != nil {
return err
}
for _, row := range projected {
id := row["taskUuid"].(string)
if seenIDs[id] {
continue
}
seenIDs[id] = true
rows = append(rows, row)
}
pages++
nextToken = page.NextToken
if !page.HasMore {
complete = true
nextToken = ""
break
}
if !rt.Bool("page-all") {
break
}
if pages >= rt.Int("page-limit") {
return fmt.Errorf("minutes search exceeded page safety limit %d", rt.Int("page-limit"))
}
token = page.NextToken
}
scanned := len(rows)
if query := strings.TrimSpace(rt.Str("query")); query != "" {
needle := strings.ToLower(query)
filtered := make([]map[string]any, 0, len(rows))
for _, row := range rows {
title, _ := row["title"].(string)
if strings.Contains(strings.ToLower(title), needle) {
filtered = append(filtered, row)
}
}
rows = filtered
}
payload := map[string]any{"count": len(rows), "scannedCount": scanned, "minutes": rows, "pages": pages, "complete": complete}
if nextToken != "" {
payload["nextToken"] = nextToken
}
return rt.Output(payload)
}
func executeMinutesDownload(rt *shortcut.RuntimeContext) error {
ids := minutesIDs(rt)
results := make([]map[string]any, 0, len(ids))
failures := make([]map[string]any, 0)
for _, id := range ids {
data, err := rt.CallMCPData("minutes", "query_minutes_audio_url", map[string]any{"taskUuid": id})
if err != nil {
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
continue
}
mediaURL, err := minutesdata.MediaURL(data)
if err != nil {
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
continue
}
if rt.Bool("url-only") {
results = append(results, map[string]any{"taskUuid": id, "url": mediaURL})
continue
}
output := rt.Str("output-dir") + "/"
if len(ids) == 1 && rt.Changed("output") {
output = rt.Str("output")
}
preferred := id + mediaExtension(mediaURL)
download, err := minutesDownload(rt.Command().Context(), mediaURL, localio.DownloadOptions{Output: output, PreferredName: preferred})
if err != nil {
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
continue
}
results = append(results, map[string]any{"taskUuid": id, "path": download.RelativePath, "sizeBytes": download.SizeBytes})
}
payload := map[string]any{"ok": len(failures) == 0, "requested": len(ids), "succeeded": len(results), "failed": len(failures), "results": results}
if len(failures) > 0 {
payload["failures"] = failures
}
if err := rt.Output(payload); err != nil {
return err
}
if len(failures) > 0 {
return minutesCompositeError("minutes_download_incomplete", "download", payload)
}
return nil
}
func executeMinutesUpload(rt *shortcut.RuntimeContext) error {
payload, err := performMinutesUpload(rt)
if err != nil {
return err
}
return rt.Output(payload)
}
func performMinutesUpload(rt *shortcut.RuntimeContext) (map[string]any, error) {
info, err := os.Stat(rt.Str("file"))
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 {
return nil, apperrors.NewValidation("--file 必须是非空普通文件")
}
plan := map[string]any{"operation": "minutes.upload", "fileName": info.Name(), "sizeBytes": info.Size(), "title": rt.Str("title")}
if rt.DryRun() {
return minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.upload", plan), nil
}
params := map[string]any{"fileName": info.Name(), "fileSize": info.Size()}
if value := strings.TrimSpace(rt.Str("title")); value != "" {
params["title"] = value
}
option := map[string]any{}
if value := strings.TrimSpace(rt.Str("template-id")); value != "" {
option["templateId"] = value
}
if value := strings.TrimSpace(rt.Str("input-language")); value != "" {
option["inputLanguage"] = value
}
if rt.Changed("enable-message-card") {
option["enableMessageCard"] = rt.Bool("enable-message-card")
}
if len(option) > 0 {
params["minutesOption"] = option
}
created, err := rt.CallMCPWriteDataStrict("minutes", "create_upload_session", params)
if err != nil {
return nil, err
}
sessionID, presignedURL, err := minutesdata.UploadSession(created)
if err != nil {
return nil, err
}
transfer, err := minutesPutFile(rt.Command().Context(), presignedURL, rt.Str("file"), 0)
if err != nil {
cancelled, cancelErr := cancelMinutesUpload(rt, sessionID)
return nil, minutesUploadFailure("put", sessionID, cancelled, cancelErr, err)
}
completed, completeAttempts, err := completeMinutesUpload(rt, sessionID, time.Duration(rt.Int("complete-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
if err != nil {
return nil, minutesUploadCompletionUnknown(sessionID, completeAttempts, err)
}
taskUUID, err := minutesdata.CompletedTaskUUID(completed)
if err != nil {
return nil, minutesUploadFailure("complete_response", sessionID, false, nil, err)
}
readBack, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": taskUUID})
if err != nil {
return nil, minutesUploadFailure("verify", sessionID, false, nil, err)
}
if _, err := minutesdata.Basic(taskUUID, readBack); err != nil {
return nil, minutesUploadFailure("verify", sessionID, false, nil, err)
}
return map[string]any{"operation": "minutes.upload", "complete": true, "sessionId": sessionID, "taskUuid": taskUUID, "sizeBytes": transfer.SizeBytes, "uploadAttempts": transfer.Attempts, "completeAttempts": completeAttempts, "verified": true}, nil
}
func executeMinutesUpdate(rt *shortcut.RuntimeContext) error {
id, title := rt.Str("id"), strings.TrimSpace(rt.Str("title"))
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.update", map[string]any{
"taskUuid": id, "after": title, "remotePreconditions": []string{"read current title", "write title", "read back exact title"},
}))
}
beforeData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
if err != nil {
return err
}
before, err := minutesdata.Basic(id, beforeData)
if err != nil {
return err
}
oldTitle, _ := before["title"].(string)
if oldTitle == title {
return rt.Output(map[string]any{"taskUuid": id, "changed": false, "verified": true, "title": title})
}
if _, err := rt.CallMCPWriteDataStrict("minutes", "update_minutes_title", map[string]any{"taskUuid": id, "title": title}); err != nil {
return err
}
afterData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
if err != nil {
return err
}
after, err := minutesdata.Basic(id, afterData)
if err != nil || after["title"] != title {
return minutesCompositeError("minutes_title_verification_failed", "verify", map[string]any{"taskUuid": id, "expectedTitle": title})
}
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "before": oldTitle, "after": title})
}
func executeMinutesApplyPermission(rt *shortcut.RuntimeContext) error {
policy := map[string]float64{"edit": 2, "download": 3, "view": 4}[rt.Str("permission")]
plan := map[string]any{"taskUuid": rt.Str("id"), "permission": rt.Str("permission"), "policyId": policy}
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.apply_permission", plan))
}
data, err := rt.CallMCPWriteDataStrict("minutes", "apply_minutes_permission", map[string]any{"taskUuid": rt.Str("id"), "policyId": policy})
if err != nil {
return err
}
if err := minutesdata.RequireWriteAcknowledgement("apply permission", data); err != nil {
return err
}
plan["requested"], plan["result"] = true, data["result"]
return rt.Output(plan)
}
func executeMinutesSummary(rt *shortcut.RuntimeContext) error {
id := rt.Str("id")
after, err := localio.ReadTextInput(rt.Str("content"), rt.Command().InOrStdin(), 8<<20)
if err != nil {
return apperrors.NewValidation(err.Error())
}
if strings.TrimSpace(after) == "" {
return apperrors.NewValidation("纪要内容不能为空")
}
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.summary", map[string]any{
"taskUuid": id, "afterBytes": len(after), "remotePreconditions": []string{"read current summary", "preserve existing Markdown images", "write summary", "read back exact summary"},
}))
}
beforeData, err := rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
if err != nil {
return err
}
before, err := minutesdata.SummaryText(beforeData)
if err != nil {
return err
}
if missing := missingMarkdownImages(before, after); len(missing) > 0 {
return apperrors.NewValidation(fmt.Sprintf("新纪要丢失 %d 个原有 Markdown 图片引用", len(missing)))
}
if before == after {
return rt.Output(map[string]any{"taskUuid": id, "changed": false, "verified": true})
}
if _, err := rt.CallMCPWriteDataStrict("minutes", "update_minutes_summary", map[string]any{"taskUuid": id, "summaryText": after}); err != nil {
return err
}
readBackData, err := rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
if err != nil {
return err
}
readBack, err := minutesdata.SummaryText(readBackData)
if err != nil || readBack != after {
return minutesCompositeError("minutes_summary_verification_failed", "verify", map[string]any{"taskUuid": id, "expectedBytes": len(after)})
}
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "beforeBytes": len(before), "afterBytes": len(after), "preservedImages": true})
}
func executeMinutesSpeakerReplace(rt *shortcut.RuntimeContext) error {
id, from, to := rt.Str("id"), rt.Str("from"), rt.Str("to")
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.speaker_replace", map[string]any{
"taskUuid": id, "from": from, "to": to, "targetUid": strings.TrimSpace(rt.Str("target-uid")),
"remotePreconditions": []string{"read full transcript", "verify source speaker exists", "replace speaker", "read back full transcript"},
}))
}
before, err := collectTranscriptForMinutes(rt, id, rt.Int("page-limit"))
if err != nil {
return err
}
beforeSpeakers := speakerCounts(before.Paragraphs)
if beforeSpeakers[from] == 0 {
return apperrors.NewValidation(fmt.Sprintf("逐字稿中不存在发言人 %q", from))
}
params := map[string]any{"taskUuid": id, "speakerNick": from, "targetNickName": to}
if uid := strings.TrimSpace(rt.Str("target-uid")); uid != "" {
params["targetUid"] = uid
}
if _, err := rt.CallMCPWriteDataStrict("minutes", "replace_speaker", params); err != nil {
return err
}
after, err := collectTranscriptForMinutes(rt, id, rt.Int("page-limit"))
if err != nil {
return err
}
afterSpeakers := speakerCounts(after.Paragraphs)
if afterSpeakers[from] != 0 || afterSpeakers[to] < beforeSpeakers[from] {
return minutesCompositeError("minutes_speaker_verification_failed", "verify", map[string]any{"taskUuid": id, "fromRemaining": afterSpeakers[from], "targetCount": afterSpeakers[to]})
}
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "from": from, "to": to, "affectedParagraphs": beforeSpeakers[from]})
}
func minutesTimeRange(startRaw, endRaw string) (int64, int64, error) {
parse := func(name, raw string) (int64, error) {
if strings.TrimSpace(raw) == "" {
return 0, nil
}
value, err := time.Parse(time.RFC3339, raw)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间: %v", name, err))
}
return value.UnixMilli(), nil
}
start, err := parse("start", startRaw)
if err != nil {
return 0, 0, err
}
end, err := parse("end", endRaw)
if err != nil {
return 0, 0, err
}
if start != 0 && end != 0 && start > end {
return 0, 0, apperrors.NewValidation("--start 不能晚于 --end")
}
return start, end, nil
}
func minutesIDs(rt *shortcut.RuntimeContext) []string {
values := []string{}
if id := strings.TrimSpace(rt.Str("id")); id != "" {
values = append(values, id)
}
values = append(values, rt.StrSlice("ids")...)
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, value := range values {
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item != "" && !seen[item] {
seen[item] = true
out = append(out, item)
}
}
}
return out
}
func mediaExtension(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
ext := strings.ToLower(filepath.Ext(parsed.Path))
if len(ext) > 10 {
return ""
}
return ext
}
func cancelMinutesUpload(rt *shortcut.RuntimeContext, sessionID string) (bool, error) {
data, err := rt.CallMCPWriteDataStrict("minutes", "cancel_upload_session", map[string]any{"sessionId": sessionID})
if err != nil {
return false, err
}
if err := minutesdata.RequireWriteAcknowledgement("cancel upload", data); err != nil {
return false, err
}
return true, nil
}
func completeMinutesUpload(rt *shortcut.RuntimeContext, sessionID string, timeout, interval time.Duration) (map[string]any, int, error) {
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
data, err := rt.CallMCPWriteDataStrict("minutes", "complete_upload_session", map[string]any{"sessionId": sessionID})
if err == nil {
return data, attempts, nil
}
message := strings.ToLower(err.Error())
retryable := strings.Contains(message, "still uploading") || strings.Contains(message, "wait and retry") || strings.Contains(message, "正在上传")
if !retryable || time.Now().Add(interval).After(deadline) {
return nil, attempts, err
}
timer := time.NewTimer(interval)
select {
case <-rt.Command().Context().Done():
timer.Stop()
return nil, attempts, rt.Command().Context().Err()
case <-timer.C:
}
}
}
func minutesUploadFailure(stage, sessionID string, cancelled bool, cancelErr, cause error) error {
details := map[string]any{"stage": stage, "sessionId": sessionID, "cancelled": cancelled}
if cause != nil {
details["cause"] = cause.Error()
}
if cancelErr != nil {
details["cancelError"] = cancelErr.Error()
}
return apperrors.NewAPI(
fmt.Sprintf("听记上传在 %s 阶段失败", stage),
apperrors.WithOperation("minutes/+upload"),
apperrors.WithOrigin("shortcut"),
apperrors.WithFailureStage(stage),
apperrors.WithReason("minutes_upload_failed"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithDetails(details),
)
}
func minutesUploadCompletionUnknown(sessionID string, attempts int, cause error) error {
details := map[string]any{
"stage": "complete",
"sessionId": sessionID,
"completeAttempts": attempts,
"cancelled": false,
"remoteEffect": "unknown",
"recovery": map[string]any{
"action": "verify_before_cancel",
"nextAction": "保留 sessionId;先重试 complete_upload_session 或查询最近听记确认服务端状态,只有明确证明未完成时才取消会话",
},
}
if cause != nil {
details["cause"] = cause.Error()
}
return apperrors.NewAPI(
"听记上传 complete 请求的远端结果未知;为避免破坏可能已完成的上传,已保留会话且未自动取消",
apperrors.WithOperation("minutes/+upload"),
apperrors.WithOrigin("shortcut"),
apperrors.WithFailureStage("complete"),
apperrors.WithReason("minutes_upload_completion_unknown"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithHint("不要重新上传或直接取消;先使用错误详情中的 sessionId 核验服务端状态"),
apperrors.WithActions("retry complete_upload_session with the preserved sessionId", "query recent Minutes before cancelling the upload session"),
apperrors.WithDetails(details),
)
}
func minutesCompositeError(reason, stage string, details map[string]any) error {
return apperrors.NewAPI(
"Minutes shortcut 未完成,详见结构化错误",
apperrors.WithOperation("minutes/shortcut"),
apperrors.WithOrigin("shortcut"),
apperrors.WithFailureStage(stage),
apperrors.WithReason(reason),
apperrors.WithRetryable(false),
apperrors.WithDetails(details),
)
}
var markdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
func missingMarkdownImages(before, after string) []string {
remaining := map[string]bool{}
for _, match := range markdownImageRE.FindAllStringSubmatch(after, -1) {
remaining[match[1]] = true
}
missing := []string{}
for _, match := range markdownImageRE.FindAllStringSubmatch(before, -1) {
if !remaining[match[1]] {
missing = append(missing, match[1])
}
}
sort.Strings(missing)
return missing
}
func collectTranscriptForMinutes(rt *shortcut.RuntimeContext, taskUUID string, pageLimit int) (minutesdata.TranscriptResult, error) {
return minutesdata.CollectTranscript(func(nextToken string) (map[string]any, error) {
params := map[string]any{"taskUuid": taskUUID, "direction": "0"}
if nextToken != "" {
params["nextToken"] = nextToken
}
return rt.CallMCPData("minutes", "get_minutes_transcription", params)
}, "", false, pageLimit)
}
func speakerCounts(paragraphs []map[string]any) map[string]int {
counts := map[string]int{}
var visit func(any, map[string]bool)
visit = func(value any, seen map[string]bool) {
switch typed := value.(type) {
case map[string]any:
local := map[string]bool{}
for _, key := range []string{"speakerNick", "nickName", "nickname", "subSpeakerNickname"} {
if name, ok := typed[key].(string); ok && strings.TrimSpace(name) != "" {
local[strings.TrimSpace(name)] = true
}
}
for name := range local {
seen[name] = true
}
for _, child := range typed {
visit(child, seen)
}
case []any:
for _, child := range typed {
visit(child, seen)
}
}
}
for _, paragraph := range paragraphs {
seen := map[string]bool{}
visit(paragraph, seen)
for name := range seen {
counts[name]++
}
}
return counts
}
func init() {
shortcut.Register(finalizeMinutesShortcuts(Search, Download, Upload, Update, ApplyPermission, Summary, SpeakerReplace)...)
}
@@ -0,0 +1,517 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
type minutesFailWriter struct{}
func (minutesFailWriter) Write([]byte) (int, error) { return 0, errors.New("fixture output failure") }
func runMinutesAlignmentCLIWithWriter(t *testing.T, caller *minutesE2ECaller, writer io.Writer, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
root.SetOut(writer)
root.SetErr(&bytes.Buffer{})
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMinutesAlignmentValidationAndHelpers(t *testing.T) {
caller := &minutesE2ECaller{}
manyIDs := make([]string, 51)
for index := range manyIDs {
manyIDs[index] = fmt.Sprintf("u%d", index)
}
invalid := [][]string{
{"minutes", "+search", "--query", "x", "--limit", "0"},
{"minutes", "+search", "--query", "x", "--start", "bad"},
{"minutes", "+search", "--query", "x", "--end", "bad"},
{"minutes", "+search", "--query", "x", "--start", "2026-08-02T00:00:00Z", "--end", "2026-08-01T00:00:00Z"},
{"minutes", "+download", "--id", "u1", "--ids", "u2"},
{"minutes", "+download", "--ids", strings.Join(manyIDs, ",")},
{"minutes", "+download", "--ids", "u1,u2", "--output", "one"},
{"minutes", "+download", "--id", "u1", "--output", "../escape"},
{"minutes", "+upload", "--file", "missing", "--complete-timeout", "0"},
{"minutes", "+speaker-replace", "--id", "u1", "--from", "same", "--to", " same "},
{"minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--page-limit", "0"},
}
for _, args := range invalid {
if payload, output, err := runMinutesAlignmentCLI(t, caller, args...); err == nil || payload != nil || output != "" {
t.Fatalf("invalid argv accepted: %v payload=%#v output=%q err=%v", args, payload, output, err)
}
}
if start, end, err := minutesTimeRange("2026-08-01T00:00:00Z", "2026-08-02T00:00:00Z"); err != nil || start == 0 || end <= start {
t.Fatalf("valid range = %d %d %v", start, end, err)
}
if got := mediaExtension("https://example.invalid/a.MP3?token=x"); got != ".mp3" {
t.Fatalf("extension = %q", got)
}
if got := mediaExtension("://bad"); got != "" {
t.Fatalf("bad extension = %q", got)
}
if got := mediaExtension("https://example.invalid/a.12345678901"); got != "" {
t.Fatalf("long extension = %q", got)
}
counts := speakerCounts([]map[string]any{{
"speakerNick": " A ", "nested": []any{map[string]any{"nickName": "A", "subSpeakerNickname": "B"}},
}})
if counts["A"] != 1 || counts["B"] != 1 {
t.Fatalf("speaker counts = %#v", counts)
}
decl := corecmd.ContractDecl{Parameters: []contract.ParamDecl{
{Name: "a", Description: "parameter"},
{Name: "a", Description: "already evidence"},
{Name: "other", Description: "other"},
}}
value := shortcut.Shortcut{Description: "desc", Intent: "intent", Contract: decl,
Flags: []shortcut.Flag{{Name: "a", Desc: "flag"}},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"a"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}},
},
}
finalized := finalizeMinutesShortcuts(value)[0]
if finalized.Contract.Selection.AgentSummary != "desc" || strings.Contains(finalized.Flags[0].Desc, "约束") {
t.Fatalf("finalized = %#v", finalized)
}
withEvidence := value
withEvidence.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}, Description: "evidence"}}
withEvidence.Flags[0].Desc = "flag evidence"
withEvidence.Contract.Parameters[1].Description = "already evidence"
finalized = finalizeMinutesShortcuts(withEvidence)[0]
if !strings.Contains(finalized.Contract.Parameters[0].Description, "约束:evidence") || finalized.Contract.Parameters[1].Description != "already evidence" || finalized.Contract.Parameters[2].Description != "other" {
t.Fatalf("parameter evidence = %#v", finalized.Contract.Parameters)
}
payload := minutesDryRunPayload("plan", "op", nil)
if payload["operation"] != "op" || payload["executed"] != false {
t.Fatalf("dry-run payload = %#v", payload)
}
}
func TestCrossPlatformCoverageMinutesSearchFailureAndCursorBranchesE2E(t *testing.T) {
cases := []struct {
name string
caller *minutesE2ECaller
args []string
wantError bool
wantNext string
}{
{name: "call", caller: &minutesE2ECaller{failAt: map[string]int{"minutes/list_by_keyword_and_time_range": 1}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
{name: "parse", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
{name: "project", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"title":"x"}],"hasNext":false}}`}}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
{name: "single page", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"x"}],"hasNext":true,"nextToken":"n2"}}`}}}, args: []string{"minutes", "+search", "--start", "2026-08-01T00:00:00Z", "--end", "2026-08-02T00:00:00Z", "--cursor", "n1"}, wantNext: "n2"},
{name: "limit", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":true,"nextToken":"n2"}}`}}}, args: []string{"minutes", "+search", "--query", "x", "--page-all", "--page-limit", "1"}, wantError: true},
{name: "cycle", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":true,"nextToken":"n1"}}`}}}, args: []string{"minutes", "+search", "--query", "x", "--page-all", "--cursor", "n1"}, wantError: true},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
payload, _, err := runMinutesAlignmentCLI(t, test.caller, test.args...)
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if test.wantNext != "" && payload["nextToken"] != test.wantNext {
t.Fatalf("payload=%#v", payload)
}
})
}
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{responses: map[string][]string{
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":false}}`},
}}, minutesFailWriter{}, "minutes", "+search", "--query", "x"); err == nil {
t.Fatal("output failure accepted")
}
// Execute is defensive even when invoked without the Shortcut validator.
cmd := &cobra.Command{Use: "search"}
cmd.Flags().String("start", "bad", "")
cmd.Flags().String("end", "", "")
cmd.Flags().String("scope", "all", "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().String("query", "x", "")
cmd.Flags().Int("limit", 20, "")
cmd.Flags().Int("page-limit", 100, "")
cmd.Flags().Bool("page-all", false, "")
if err := executeMinutesSearch(shortcut.RuntimeContextForTest(cmd, Search)); err == nil {
t.Fatal("direct invalid time accepted")
}
}
func TestCrossPlatformCoverageMinutesDownloadBranchesE2E(t *testing.T) {
testseam.Swap(t, &minutesDownload, func(_ context.Context, rawURL string, options localio.DownloadOptions) (localio.DownloadResult, error) {
if strings.Contains(rawURL, "fail") {
return localio.DownloadResult{}, errors.New("fixture download failure")
}
return localio.DownloadResult{RelativePath: filepath.ToSlash(filepath.Join(options.Output, options.PreferredName)), SizeBytes: 12}, nil
})
caller := &minutesE2ECaller{
responses: map[string][]string{"minutes/query_minutes_audio_url": {
`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`,
`{"success":true,"result":{"downloadUrl":"https://example.invalid/fail.mp4"}}`,
}},
failAt: map[string]int{"minutes/query_minutes_audio_url": 3},
}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+download", "--ids", "u1,u2,u3", "--output-dir", "media")
if err == nil || output == "" || payload["succeeded"] != float64(1) || payload["failed"] != float64(2) {
t.Fatalf("download payload=%#v err=%v", payload, err)
}
urlOnly := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`}}}
if payload, _, err = runMinutesAlignmentCLI(t, urlOnly, "minutes", "+download", "--id", "u1", "--url-only"); err != nil || payload["succeeded"] != float64(1) {
t.Fatalf("url-only payload=%#v err=%v", payload, err)
}
if err := runMinutesAlignmentCLIWithWriter(t, urlOnly, minutesFailWriter{}, "minutes", "+download", "--id", "u1", "--url-only"); err == nil {
t.Fatal("download output failure accepted")
}
single := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`}}}
if payload, _, err = runMinutesAlignmentCLI(t, single, "minutes", "+download", "--id", "u1", "--output", "single.mp3"); err != nil || payload["succeeded"] != float64(1) {
t.Fatalf("single payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesUploadFailureAndSuccessBranchesE2E(t *testing.T) {
file := filepath.Join(t.TempDir(), "source.wav")
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
t.Fatal(err)
}
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload", "--file", t.TempDir(), "--yes"); err == nil || payload != nil || output != "" {
t.Fatalf("directory upload accepted: %#v %q %v", payload, output, err)
}
createFail := &minutesE2ECaller{failAt: map[string]int{"minutes/create_upload_session": 1}}
if _, _, err := runMinutesAlignmentCLI(t, createFail, "minutes", "+upload", "--file", file, "--yes"); err == nil {
t.Fatal("create failure accepted")
}
badSession := &minutesE2ECaller{responses: map[string][]string{"minutes/create_upload_session": {`{"success":true,"result":{}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, badSession, "minutes", "+upload", "--file", file, "--yes"); err == nil {
t.Fatal("bad session accepted")
}
for _, test := range []struct {
name string
cancelRaw string
cancelFailAt int
}{
{name: "cancel success", cancelRaw: `{"success":true,"result":{}}`},
{name: "cancel call failure", cancelFailAt: 1},
{name: "cancel acknowledgement failure", cancelRaw: `{"result":{}}`},
} {
t.Run(test.name, func(t *testing.T) {
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
return localio.UploadResult{}, errors.New("put failed")
})
caller := &minutesE2ECaller{
responses: map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
"minutes/cancel_upload_session": {test.cancelRaw},
},
failAt: map[string]int{"minutes/cancel_upload_session": test.cancelFailAt},
}
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes"); err == nil {
t.Fatal("put failure accepted")
}
})
}
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
return localio.UploadResult{SizeBytes: 5, Attempts: 2}, nil
})
base := map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u1"}}`},
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`},
}
success := &minutesE2ECaller{responses: base}
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+upload", "--file", file, "--title", "T", "--template-id", "tpl", "--input-language", "zh", "--enable-message-card", "--yes")
if err != nil || payload["complete"] != true || payload["verified"] != true {
t.Fatalf("upload payload=%#v err=%v", payload, err)
}
cases := []struct {
name string
responses map[string][]string
failAt map[string]int
}{
{name: "invalid completion", responses: map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
"minutes/complete_upload_session": {`{"success":true,"result":{}}`},
}},
{name: "verify call", responses: base, failAt: map[string]int{"minutes/get_minutes_basic_info": 1}},
{name: "verify parse", responses: map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u1"}}`},
"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`},
}},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
caller := &minutesE2ECaller{responses: test.responses, failAt: test.failAt}
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes"); err == nil {
t.Fatal("upload failure accepted")
}
})
}
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+upload", "--file", file, "--dry-run"); err == nil {
t.Fatal("upload output failure accepted")
}
}
func TestCrossPlatformCoverageMinutesUploadErrorConstructors(t *testing.T) {
if err := minutesUploadFailure("put", "s", false, nil, nil); err == nil {
t.Fatal("nil-cause failure missing")
}
if err := minutesUploadCompletionUnknown("s", 1, nil); err == nil {
t.Fatal("nil-cause unknown completion missing")
}
}
func TestCrossPlatformCoverageMinutesUpdatePermissionSummarySpeakerFailuresE2E(t *testing.T) {
// Dry-run branches must not access MCP and must still propagate output errors.
dryCases := [][]string{
{"minutes", "+update", "--id", "u1", "--title", "new", "--dry-run"},
{"minutes", "+summary", "--id", "u1", "--content", "new", "--dry-run"},
{"minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--dry-run"},
}
for _, args := range dryCases {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, args...); err == nil {
t.Fatalf("output failure accepted: %v", args)
}
}
updateCases := []struct {
name string
responses map[string][]string
failAt map[string]int
wantError bool
}{
{name: "before call", failAt: map[string]int{"minutes/get_minutes_basic_info": 1}, wantError: true},
{name: "before parse", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}, wantError: true},
{name: "unchanged", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"new"}}`}}},
{name: "write", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`}}, failAt: map[string]int{"minutes/update_minutes_title": 1}, wantError: true},
{name: "after call", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`}}, failAt: map[string]int{"minutes/get_minutes_basic_info": 2}, wantError: true},
{name: "after parse", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`, `{"success":true,"result":{}}`}, "minutes/update_minutes_title": {`{"success":true,"result":{}}`}}, wantError: true},
}
for _, test := range updateCases {
t.Run("update "+test.name, func(t *testing.T) {
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+update", "--id", "u1", "--title", "new", "--yes")
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
permissionCases := []struct {
name string
responses map[string][]string
failAt map[string]int
writer io.Writer
}{
{name: "call", failAt: map[string]int{"minutes/apply_minutes_permission": 1}},
{name: "ack", responses: map[string][]string{"minutes/apply_minutes_permission": {`{"result":{}}`}}},
{name: "success output", responses: map[string][]string{"minutes/apply_minutes_permission": {`{"success":true,"result":{}}`}}, writer: minutesFailWriter{}},
}
for _, test := range permissionCases {
t.Run("permission "+test.name, func(t *testing.T) {
writer := test.writer
if writer == nil {
writer = &bytes.Buffer{}
}
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, writer, "minutes", "+apply-permission", "--id", "u1", "--permission", "view", "--yes"); err == nil {
t.Fatal("permission failure accepted")
}
})
}
permissionSuccess := &minutesE2ECaller{responses: map[string][]string{"minutes/apply_minutes_permission": {`{"success":true,"result":{}}`}}}
if payload, _, err := runMinutesAlignmentCLI(t, permissionSuccess, "minutes", "+apply-permission", "--id", "u1", "--permission", "view", "--yes"); err != nil || payload["requested"] != true {
t.Fatalf("permission payload=%#v err=%v", payload, err)
}
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+summary", "--id", "u1", "--content", "@missing", "--yes"); err == nil {
t.Fatal("missing input accepted")
}
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+summary", "--id", "u1", "--content", " ", "--yes"); err == nil {
t.Fatal("empty summary accepted")
}
cmd := &cobra.Command{Use: "summary"}
cmd.Flags().String("id", "u1", "")
cmd.Flags().String("content", " ", "")
if err := executeMinutesSummary(shortcut.RuntimeContextForTest(cmd, Summary)); err == nil {
t.Fatal("direct empty summary accepted")
}
summaryCases := []struct {
name string
responses map[string][]string
failAt map[string]int
wantError bool
}{
{name: "before call", failAt: map[string]int{"minutes/get_minutes_ai_summary": 1}, wantError: true},
{name: "before parse", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{}}`}}, wantError: true},
{name: "unchanged", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"new"}}`}}},
{name: "write", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`}}, failAt: map[string]int{"minutes/update_minutes_summary": 1}, wantError: true},
{name: "readback call", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`}}, failAt: map[string]int{"minutes/get_minutes_ai_summary": 2}, wantError: true},
{name: "readback parse", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`, `{"success":true,"result":{}}`}, "minutes/update_minutes_summary": {`{"success":true,"result":{}}`}}, wantError: true},
}
for _, test := range summaryCases {
t.Run("summary "+test.name, func(t *testing.T) {
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+summary", "--id", "u1", "--content", "new", "--yes")
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
speakerCases := []struct {
name string
responses map[string][]string
failAt map[string]int
}{
{name: "before", failAt: map[string]int{"minutes/get_minutes_transcription": 1}},
{name: "missing", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"x"}],"hasNext":false}}`}}},
{name: "write", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}}, failAt: map[string]int{"minutes/replace_speaker": 1}},
{name: "after", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}}, failAt: map[string]int{"minutes/get_minutes_transcription": 2}},
{name: "verify", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`, `{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}, "minutes/replace_speaker": {`{"success":true,"result":{}}`}}},
}
for _, test := range speakerCases {
t.Run("speaker "+test.name, func(t *testing.T) {
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--target-uid", "uid", "--yes"); err == nil {
t.Fatal("speaker failure accepted")
}
})
}
}
func TestCrossPlatformCoverageMinutesCompleteUploadRetryAndContext(t *testing.T) {
// Use the real runtime/caller path so retry classification is tested, while
// zero intervals keep the fault-injection cases deterministic and fast.
for _, test := range []struct {
name string
responses []string
failAt int
failError error
cancel bool
timeout time.Duration
interval time.Duration
wantCalls int
wantError bool
}{
{name: "success", responses: []string{`{"success":true,"result":{"taskUuid":"u1"}}`}, timeout: time.Second, wantCalls: 1},
{name: "retry success", responses: []string{`{"success":true,"result":{"taskUuid":"u1"}}`}, failAt: 1, failError: errors.New("still uploading"), timeout: time.Second, wantCalls: 2},
{name: "nonretryable", failAt: 1, failError: errors.New("denied"), timeout: time.Second, wantCalls: 1, wantError: true},
{name: "timeout", failAt: 1, failError: errors.New("wait and retry"), wantCalls: 1, wantError: true},
{name: "context", failAt: 1, failError: errors.New("正在上传"), cancel: true, timeout: 2 * time.Hour, interval: time.Hour, wantCalls: 1, wantError: true},
} {
t.Run(test.name, func(t *testing.T) {
key := "minutes/complete_upload_session"
caller := &minutesE2ECaller{
responses: map[string][]string{key: test.responses},
failAt: map[string]int{key: test.failAt},
failErrors: map[string]error{key: test.failError},
}
helpers.InitDepsForTest(t, caller)
ctx := context.Background()
if test.cancel {
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
cancel()
}
cmd := &cobra.Command{Use: "test"}
cmd.SetContext(ctx)
rt := shortcut.RuntimeContextForTest(cmd, Upload)
_, attempts, err := completeMinutesUpload(rt, "s1", test.timeout, test.interval)
if (err != nil) != test.wantError || attempts != test.wantCalls || caller.counts[key] != test.wantCalls {
t.Fatalf("attempts=%d calls=%d err=%v", attempts, caller.counts[key], err)
}
})
}
}
func TestCrossPlatformCoverageMinutesLegacyListsAndRecordCommandsE2E(t *testing.T) {
listSuccess := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"weekly"}],"hasNext":false}}`}}}
for _, command := range []string{"+list-mine", "+list-shared", "+list-all"} {
payload, _, err := runMinutesAlignmentCLI(t, listSuccess, "minutes", command, "--query", "weekly", "--limit", "5", "--cursor", "n1")
if err != nil || payload["count"] != float64(1) {
t.Fatalf("%s payload=%#v err=%v", command, payload, err)
}
}
if calls := listSuccess.arguments["minutes/list_by_keyword_and_time_range"]; len(calls) != 3 || calls[0]["maxResults"] != 5 || calls[0]["keyword"] != "weekly" || calls[0]["nextToken"] != "n1" {
t.Fatalf("list args=%#v", calls)
}
listCall := &minutesE2ECaller{failAt: map[string]int{"minutes/list_by_keyword_and_time_range": 1}}
if _, _, err := runMinutesAlignmentCLI(t, listCall, "minutes", "+list-all"); err == nil {
t.Fatal("list call failure accepted")
}
listParse := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, listParse, "minutes", "+list-all"); err == nil {
t.Fatal("list parse failure accepted")
}
listProject := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"title":"missing id"}]}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, listProject, "minutes", "+list-all"); err == nil {
t.Fatal("list projection failure accepted")
}
if err := runMinutesAlignmentCLIWithWriter(t, listSuccess, minutesFailWriter{}, "minutes", "+list-all"); err == nil {
t.Fatal("list output failure accepted")
}
for _, test := range []struct {
command string
cmd string
id string
}{
{command: "+record-start", cmd: "create"},
{command: "+record-pause", cmd: "pause", id: "u1"},
{command: "+record-resume", cmd: "resume", id: "u1"},
{command: "+record-stop", cmd: "end", id: "u1"},
} {
t.Run(test.command, func(t *testing.T) {
result := fmt.Sprintf(`{"success":true,"result":{"cmd":%q,"uuid":%q}}`, test.cmd, test.id)
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {result}}}
args := []string{"minutes", test.command, "--session-id", "session", "--yes"}
if test.id != "" {
args = append(args, "--id", test.id)
}
payload, _, err := runMinutesAlignmentCLI(t, caller, args...)
if err != nil || payload["accepted"] != true || payload["command"] != test.cmd {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if caller.arguments["minutes/"+listeningNoteCmdTool][0]["sessionId"] != "session" {
t.Fatalf("args=%#v", caller.arguments)
}
})
}
recordCall := &minutesE2ECaller{failAt: map[string]int{"minutes/" + listeningNoteCmdTool: 1}}
if _, _, err := runMinutesAlignmentCLI(t, recordCall, "minutes", "+record-start", "--yes"); err == nil {
t.Fatal("record call failure accepted")
}
recordParse := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"pause","uuid":"u1"}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, recordParse, "minutes", "+record-stop", "--id", "u1", "--yes"); err == nil {
t.Fatal("record result mismatch accepted")
}
recordOutput := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`}}}
if err := runMinutesAlignmentCLIWithWriter(t, recordOutput, minutesFailWriter{}, "minutes", "+record-stop", "--id", "u1", "--yes"); err == nil {
t.Fatal("record output failure accepted")
}
}
@@ -0,0 +1,394 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type minutesE2ECaller struct {
responses map[string][]string
failAt map[string]int
counts map[string]int
arguments map[string][]map[string]any
beforeFail map[string]func()
failErrors map[string]error
}
func (c *minutesE2ECaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
if c.counts == nil {
c.counts = map[string]int{}
}
if c.arguments == nil {
c.arguments = map[string][]map[string]any{}
}
key := product + "/" + tool
c.counts[key]++
c.arguments[key] = append(c.arguments[key], args)
if c.failAt[key] == c.counts[key] {
if hook := c.beforeFail[key]; hook != nil {
hook()
}
if err := c.failErrors[key]; err != nil {
return nil, err
}
return nil, errors.New("fixture failure")
}
responses := c.responses[key]
text := `{"success":true,"result":{}}`
if len(responses) > 0 {
index := c.counts[key] - 1
if index >= len(responses) {
index = len(responses) - 1
}
text = responses[index]
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (c *minutesE2ECaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.CallTool(ctx, product, tool, args)
}
func (*minutesE2ECaller) Format() string { return "json" }
func (*minutesE2ECaller) DryRun() bool { return false }
func (*minutesE2ECaller) Fields() string { return "" }
func (*minutesE2ECaller) JQ() string { return "" }
func runMinutesAlignmentCLI(t *testing.T, caller *minutesE2ECaller, args ...string) (map[string]any, string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&bytes.Buffer{})
root.SetArgs(args)
err := root.Execute()
if stdout.Len() == 0 {
return nil, "", err
}
var payload map[string]any
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
}
return payload, stdout.String(), err
}
func TestCrossPlatformCoverageMinutesSearchPaginatesFiltersAndRejectsUnknownE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/list_by_keyword_and_time_range": {
`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"周会 A","startTime":1},{"uuid":"u2","title":"其他","startTime":2}],"hasNext":true,"nextToken":"n2"}}`,
`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"周会 A","startTime":1},{"uuid":"u3","title":"周会 B","startTime":3}],"hasNext":false}}`,
},
}}
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+search", "--query", "周会", "--page-all")
if err != nil || payload["count"] != float64(2) || payload["scannedCount"] != float64(3) || payload["pages"] != float64(2) || payload["complete"] != true {
t.Fatalf("search payload=%#v err=%v", payload, err)
}
if calls := caller.arguments["minutes/list_by_keyword_and_time_range"]; len(calls) != 2 || calls[1]["nextToken"] != "n2" {
t.Fatalf("search calls=%#v", calls)
}
for _, test := range []struct {
scope string
belonging string
}{
{scope: "mine", belonging: "createdByMe"},
{scope: "shared", belonging: "sharedToMe"},
{scope: "all", belonging: "noLimit"},
} {
t.Run("scope "+test.scope, func(t *testing.T) {
scoped := &minutesE2ECaller{responses: map[string][]string{
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":false}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, scoped, "minutes", "+search", "--query", "needle", "--scope", test.scope, "--limit", "1")
if err != nil || payload["count"] != float64(0) || payload["complete"] != true {
t.Fatalf("scope %s payload=%#v err=%v", test.scope, payload, err)
}
calls := scoped.arguments["minutes/list_by_keyword_and_time_range"]
want := map[string]any{"belongingConditionId": test.belonging, "keyword": "needle", "maxResults": 1}
if len(calls) != 1 || !reflect.DeepEqual(calls[0], want) {
t.Fatalf("scope %s calls=%#v, want exactly %#v", test.scope, calls, want)
}
})
}
unknown := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}
if payload, output, err := runMinutesAlignmentCLI(t, unknown, "minutes", "+search", "--query", "周会"); err == nil || payload != nil || output != "" {
t.Fatalf("unknown list accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesDownloadEmptyMediaIsPartialFailureE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{}}`}}}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+download", "--id", "u1", "--url-only")
if err == nil || output == "" || payload["ok"] != false || payload["failed"] != float64(1) || payload["succeeded"] != float64(0) {
t.Fatalf("empty media accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesUpdateRequiresVerifiedReadbackE2E(t *testing.T) {
success := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"旧标题"}}`, `{"success":true,"result":{"taskUuid":"u1","title":"新标题"}}`},
"minutes/update_minutes_title": {`{"success":true,"result":{"updated":true}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+update", "--id", "u1", "--title", "新标题", "--yes")
if err != nil || payload["changed"] != true || payload["verified"] != true {
t.Fatalf("verified update payload=%#v err=%v", payload, err)
}
mismatch := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"旧标题"}}`, `{"success":true,"result":{"taskUuid":"u1","title":"仍是旧标题"}}`},
"minutes/update_minutes_title": {`{"success":true,"result":{"updated":true}}`},
}}
if payload, output, err := runMinutesAlignmentCLI(t, mismatch, "minutes", "+update", "--id", "u1", "--title", "新标题", "--yes"); err == nil || payload != nil || output != "" {
t.Fatalf("readback mismatch accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesSummaryProtectsImagesAndVerifiesE2E(t *testing.T) {
missingImage := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"旧内容\n![图](https://example.invalid/a.png)"}}`},
}}
if payload, output, err := runMinutesAlignmentCLI(t, missingImage, "minutes", "+summary", "--id", "u1", "--content", "新内容", "--yes"); err == nil || payload != nil || output != "" || missingImage.counts["minutes/update_minutes_summary"] != 0 {
t.Fatalf("missing image accepted: payload=%#v output=%q err=%v calls=%#v", payload, output, err, missingImage.counts)
}
content := "新内容\n![图](https://example.invalid/a.png)"
success := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"旧内容\n![图](https://example.invalid/a.png)"}}`, `{"success":true,"result":{"fullSummary":"新内容\n![图](https://example.invalid/a.png)"}}`},
"minutes/update_minutes_summary": {`{"success":true,"result":{"updated":true}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+summary", "--id", "u1", "--content", content, "--yes")
if err != nil || payload["changed"] != true || payload["verified"] != true || payload["preservedImages"] != true {
t.Fatalf("summary payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesSpeakerReplacePaginatesAndVerifiesE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","speakerNick":"甲"}],"hasNext":true,"nextToken":"n2"}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","speakerNick":"甲"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","speakerNick":"乙"}],"hasNext":true,"nextToken":"n2"}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","speakerNick":"乙"}],"hasNext":false}}`,
},
"minutes/replace_speaker": {`{"success":true,"result":{"updated":true}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+speaker-replace", "--id", "u1", "--from", "甲", "--to", "乙", "--yes")
if err != nil || payload["verified"] != true || payload["affectedParagraphs"] != float64(2) || caller.counts["minutes/get_minutes_transcription"] != 4 {
t.Fatalf("speaker payload=%#v err=%v calls=%#v", payload, err, caller.counts)
}
}
func TestCrossPlatformCoverageMinutesUploadAndPermissionDryRunDoNotWriteE2E(t *testing.T) {
work := t.TempDir()
file := filepath.Join(work, "source.wav")
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
t.Fatal(err)
}
caller := &minutesE2ECaller{}
upload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--title", "E2E", "--dry-run")
if err != nil || upload["dryRun"] != true || upload["executed"] != false || len(caller.counts) != 0 {
t.Fatalf("upload dry-run=%#v err=%v calls=%#v", upload, err, caller.counts)
}
permission, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+apply-permission", "--id", "u1", "--permission", "edit", "--dry-run")
if err != nil || permission["policyId"] != float64(2) || permission["executed"] != false || len(caller.counts) != 0 {
t.Fatalf("permission dry-run=%#v err=%v calls=%#v", permission, err, caller.counts)
}
}
func TestCrossPlatformCoverageMinutesUploadUnknownCompletionPreservesSessionE2E(t *testing.T) {
work := t.TempDir()
file := filepath.Join(work, "response-loss.wav")
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
return localio.UploadResult{SizeBytes: 23, Attempts: 1}, nil
})
serverCompleted := false
caller := &minutesE2ECaller{
responses: map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"session-redacted","presignedUrl":"https://upload.example.invalid/object"}}`},
},
failAt: map[string]int{"minutes/complete_upload_session": 1},
beforeFail: map[string]func(){
"minutes/complete_upload_session": func() { serverCompleted = true },
},
}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes")
if err == nil || payload != nil || output != "" || !serverCompleted {
t.Fatalf("response-loss outcome = payload:%#v output:%q err:%v completed:%v", payload, output, err, serverCompleted)
}
if caller.counts["minutes/cancel_upload_session"] != 0 {
t.Fatalf("unknown remote completion was cancelled: calls=%#v", caller.counts)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "minutes_upload_completion_unknown" || typed.Retryable || typed.FailureStage != "complete" {
t.Fatalf("unknown completion error = %#v", err)
}
if typed.Details["sessionId"] != "session-redacted" || typed.Details["cancelled"] != false || typed.Details["remoteEffect"] != "unknown" {
t.Fatalf("unknown completion recovery details = %#v", typed.Details)
}
}
func TestCrossPlatformCoverageMinutesMindmapExplicitStatusControlsExitE2E(t *testing.T) {
for _, test := range []struct {
name string
status string
wantErr bool
wantResult bool
}{
{name: "success", status: `{"success":true,"result":{"taskStatus":1,"mindGraph":"ready"}}`, wantResult: true},
{name: "platform failed", status: `{"success":true,"result":{"taskStatus":2}}`, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
"minutes/query_mind_graph_status": {test.status},
}}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+mindmap", "--id", "u1", "--timeout", "2", "--interval", "1", "--yes")
if output == "" || (err != nil) != test.wantErr || payload["complete"] != test.wantResult {
t.Fatalf("mindmap payload=%#v output=%q err=%v", payload, output, err)
}
if caller.counts["minutes/create_mind_graph"] != 1 {
t.Fatalf("mindmap create repeated: %#v", caller.counts)
}
})
}
}
func TestCrossPlatformCoverageMinutesSpeakerInsightsRequiresTaskAndResultE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job-1","status":"processing"}}`},
"minutes/get_speaker_summary": {
`{"success":false,"errorMsg":"downstream query empty"}`,
`{"success":true,"result":{"summaries":[{"speaker":"甲","summary":"结论"}]}}`,
},
}}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+speaker-insights", "--id", "u1", "--timeout", "3", "--interval", "1", "--yes")
if err != nil || output == "" || payload["complete"] != true || payload["taskId"] != "job-1" || payload["attempts"] != float64(2) {
t.Fatalf("speaker insights payload=%#v output=%q err=%v", payload, output, err)
}
missingTask := &minutesE2ECaller{responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"status":"processing"}}`}}}
if payload, output, err := runMinutesAlignmentCLI(t, missingTask, "minutes", "+speaker-insights", "--id", "u1", "--timeout", "2", "--interval", "1", "--yes"); err == nil || output == "" || payload["complete"] != false {
t.Fatalf("missing task accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesPrepareASRDiffWritesAndReadbackE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/list_my_hotwords": {
`{"success":true,"result":{"hotWordList":["已有"],"currentCount":1}}`,
`{"success":true,"result":{"hotWordList":["已有","DWS"],"currentCount":2}}`,
},
"minutes/add_personal_hot_word": {`{"success":true,"result":{}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+prepare-asr", "--words", "已有,DWS", "--yes")
if err != nil || payload["complete"] != true || payload["verified"] != true || caller.counts["minutes/add_personal_hot_word"] != 1 || caller.counts["minutes/delete_personal_hotword"] != 0 {
t.Fatalf("prepare asr payload=%#v err=%v calls=%#v", payload, err, caller.counts)
}
unknown := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{}}`}}}
if payload, output, err := runMinutesAlignmentCLI(t, unknown, "minutes", "+prepare-asr", "--words", "DWS", "--yes"); err == nil || payload != nil || output != "" {
t.Fatalf("unknown hotword list accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesRecordWrapUpStopSuccessArtifactFailureIsPartialE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`},
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{}}`},
}}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+record-wrap-up", "--id", "u1", "--artifacts", "summary", "--wait-timeout", "1", "--poll-interval", "1", "--yes")
if err == nil || output == "" || payload["complete"] != false || payload["taskUuid"] != "u1" || payload["recovery"] == nil || caller.counts["minutes/"+listeningNoteCmdTool] != 1 {
t.Fatalf("wrap-up partial payload=%#v output=%q err=%v calls=%#v", payload, output, err, caller.counts)
}
}
func TestCrossPlatformCoverageMinutesExportPackPublishesOnlyCompleteArtifactsE2E(t *testing.T) {
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
work := t.TempDir()
if err := os.Chdir(work); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"E2E"}}`},
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"完整纪要"}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic,summary")
if err != nil || payload["complete"] != true || payload["published"] != true {
t.Fatalf("export payload=%#v err=%v", payload, err)
}
manifestRaw, err := os.ReadFile(filepath.Join(work, "pack", "manifest.json"))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(manifestRaw, []byte("https://")) || !bytes.Contains(manifestRaw, []byte(`"complete": true`)) {
t.Fatalf("manifest contains secret URL or is incomplete: %s", manifestRaw)
}
}
func TestCrossPlatformCoverageMinutesSharePartialWriteIsNonZeroE2E(t *testing.T) {
caller := &minutesE2ECaller{
responses: map[string][]string{"minutes/add_member_permission": {`{"success":true,"result":{}}`, `{"success":false,"errorMsg":"denied"}`}},
}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+share", "--id", "u1", "--member-uids", "m1,m2,m3", "--permission", "view", "--yes")
if err == nil || output == "" || payload["complete"] != false || payload["succeeded"] != float64(1) || payload["failed"] != float64(1) || len(payload["unattempted"].([]any)) != 1 {
t.Fatalf("share partial payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesUploadAndAnalyzeDryRunDoesNotWriteE2E(t *testing.T) {
file := filepath.Join(t.TempDir(), "source.wav")
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
t.Fatal(err)
}
caller := &minutesE2ECaller{}
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload-and-analyze", "--file", file, "--artifacts", "summary,transcript", "--mindmap", "--dry-run")
if err != nil || payload["dryRun"] != true || payload["executed"] != false || len(caller.counts) != 0 {
t.Fatalf("upload-and-analyze dry-run=%#v err=%v calls=%#v", payload, err, caller.counts)
}
}
func TestCrossPlatformCoverageMinutesArtifactWaitDoesNotTreatEmptyAnalysisAsReadyE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[],"hasNext":false}}`},
}}
// Exercise the same readiness collector used after upload/record stop. An
// explicit [] is a valid transport shape, but cannot prove asynchronous ASR
// has finished when the workflow promised transcript analysis.
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "+export-pack"}, ExportPack)
bundle, failures := collectMinutesArtifactsOnce(rt, "u1", []string{"transcript"}, 10)
if len(failures) != 1 || bundle["transcript"] != nil {
t.Fatalf("empty transcript accepted: bundle=%#v failures=%#v", bundle, failures)
}
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func minutesContract(command, description, useWhen string, avoidWhen []string, examples []string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
return corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: name,
CanonicalPath: "minutes." + name,
CLIPath: "minutes " + command,
PrimaryCLIPath: "minutes " + command,
},
Description: description,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: "The executable Shortcut owns validation, orchestration, completeness and verification across one or more Minutes RPCs; no single RPC represents the final command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{useWhen},
AvoidWhen: avoidWhen,
Examples: examples,
},
}
}
func withMinutesDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl {
decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads}
return decl
}
func minutesDryRunPayload(kind, operation string, payload map[string]any) map[string]any {
if payload == nil {
payload = map[string]any{}
}
payload["operation"] = operation
payload["dry_run"] = true
payload["dryRun"] = true
payload["preview_kind"] = kind
payload["executed"] = false
return payload
}
// finalizeMinutesShortcuts keeps the human Shortcut declaration and the final
// Agent contract on one source of truth. Custom validation is prose-only in the
// Schema wire, so publish its exact evidence on every affected flag as well.
func finalizeMinutesShortcuts(values ...shortcut.Shortcut) []shortcut.Shortcut {
finalized := make([]shortcut.Shortcut, len(values))
for index, value := range values {
value.Contract.Selection.AgentSummary = value.Description
value.Contract.Selection.UseWhen = []string{value.Intent}
for _, constraint := range value.Constraints {
if constraint.Kind != shortcut.ConstraintCustom {
continue
}
evidence := strings.TrimSpace(constraint.Description)
if evidence == "" {
continue
}
for flagIndex := range value.Flags {
flag := &value.Flags[flagIndex]
if !containsString(constraint.Flags, flag.Name) || strings.Contains(flag.Desc, evidence) {
continue
}
flag.Desc = strings.TrimRight(flag.Desc, ";。 ") + ";约束:" + evidence
}
for parameterIndex := range value.Contract.Parameters {
parameter := &value.Contract.Parameters[parameterIndex]
if !containsString(constraint.Flags, parameter.Name) || strings.Contains(parameter.Description, evidence) {
continue
}
parameter.Description = strings.TrimRight(parameter.Description, ";。 ") + ";约束:" + evidence
}
}
finalized[index] = value
}
return finalized
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
@@ -32,7 +32,10 @@ func TestCallListProjectItemListShape(t *testing.T) {
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := callListProject(data)
got, err := callListProject(data)
if err != nil {
t.Fatalf("project itemList: %v", err)
}
if len(got) != 2 {
t.Fatalf("lower/upper mismatch: itemList has 2 entries, projection returned %d", len(got))
}
@@ -40,3 +43,20 @@ func TestCallListProjectItemListShape(t *testing.T) {
t.Fatalf("taskUuid not projected from taskUuid/task_uuid keys: %v", got)
}
}
func TestCrossPlatformCoverageMinutesUnknownListIsNotEmptySuccessE2E(t *testing.T) {
for _, raw := range []string{
`{}`,
`{"success":true}`,
`{"success":true,"result":{"itemList":null}}`,
`{"success":false,"errorMsg":"denied"}`,
} {
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got, err := callListProject(data); err == nil || got != nil {
t.Fatalf("unknown list response accepted: got=%#v err=%v raw=%s", got, err, raw)
}
}
}
+65 -85
View File
@@ -19,6 +19,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// listeningNoteCmdTool is the gateway-registered name of the 听记指令 tool.
@@ -173,7 +174,10 @@ func callList(rt *shortcut.RuntimeContext, belonging string) error {
if err != nil {
return err
}
minutes := callListProject(data)
minutes, err := callListProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(minutes), "minutes": minutes})
}
@@ -183,83 +187,12 @@ func callList(rt *shortcut.RuntimeContext, belonging string) error {
// The list container and field names are probed defensively across candidate
// keys so the projection tolerates response-shape drift; unknown keys are never
// invented.
func callListProject(data map[string]any) []map[string]any {
raw := callListResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
// Only real taskUuid spellings map to taskUuid: the downstream
// +record-pause/resume/stop commands feed this value straight into the
// recording-control tool as the task uuid, so a minutesId (the minutes
// document id, a different identifier) must not be substituted here or
// record control would fail with a wrong id. The backend list already
// returns taskUuid.
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid"); ok {
row["taskUuid"] = v
}
if v, ok := callListFirst(m, "title", "name"); ok {
row["title"] = v
}
if v, ok := callListFirst(m, "creator", "creatorName", "createUserName", "creatorNick"); ok {
row["creator"] = v
}
if v, ok := callListFirst(m, "startTime", "gmtStart", "beginTime"); ok {
row["startTime"] = v
}
if v, ok := callListFirst(m, "endTime", "gmtEnd", "deadline"); ok {
row["endTime"] = v
}
if v, ok := callListFirst(m, "url", "shareUrl", "link"); ok {
row["url"] = v
}
if v, ok := callListFirst(m, "status", "taskStatus", "state"); ok {
row["status"] = v
}
if len(row) > 0 {
out = append(out, row)
}
func callListProject(data map[string]any) ([]map[string]any, error) {
page, err := minutesdata.ParseListPage(data)
if err != nil {
return nil, err
}
return out
}
// callListResolveList locates the list payload inside the response, tolerating a
// bare top-level array or nesting under result/data/list/items/records containers.
func callListResolveList(data map[string]any) []any {
// list_by_keyword_and_time_range nests the minutes under result.itemList;
// "itemList" MUST be probed or +list-all/+list-mine/+list-shared silently
// return empty despite the backend returning minutes.
for _, key := range []string{"result", "data", "list", "items", "itemList", "records", "dataList"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
// container may itself wrap the list one level deeper
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "itemList", "records", "dataList", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// callListFirst returns the first present candidate key's value.
func callListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
return minutesdata.ProjectList(page)
}
// ── get ─────────────────────────────────────────────────────────────────────
@@ -273,7 +206,7 @@ var RecordStart = shortcut.Shortcut{
Command: "+record-start",
Product: "minutes",
Description: "发起听记(开始录音)",
Intent: "当你要开始一场实时会议/通话的 AI 听记、立刻启动录音并生成一条新听记任务时使用;可选传入 AI 助理会话 ID,会真实发起录音,返回新建听记的 taskUuid 供后续暂停/恢复/结束。",
Intent: "当你要开始一场实时会议/通话的 AI 听记、立刻启动录音时使用;可选传入 AI 助理会话 ID。当前网关 create 回执不返回 taskUuid,只能证明录音指令已被接受;随后需用 +latest/+search 定位新听记。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -295,7 +228,7 @@ var RecordStart = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "发起听记(开始录音)",
UseWhen: []string{"当你要开始一场实时会议/通话的 AI 听记、立刻启动录音并生成一条新听记任务时使用;可选传入 AI 助理会话 ID,会真实发起录音,返回新建听记的 taskUuid 供后续暂停/恢复/结束。"},
UseWhen: []string{"当你要开始一场实时会议/通话的 AI 听记、立刻启动录音时使用;网关 create 只返回已接受回执,不返回 taskUuid,随后需用 +latest/+search 定位新听记。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws minutes +record-start --session-id <sessionId>"},
},
@@ -309,7 +242,7 @@ var RecordStart = shortcut.Shortcut{
if rt.Changed("session-id") {
params["sessionId"] = rt.Str("session-id")
}
return rt.CallMCP(listeningNoteCmdTool, params)
return executeRecordCommand(rt, "create", "", params)
},
}
@@ -320,6 +253,16 @@ var RecordPause = shortcut.Shortcut{
Description: "暂停听记录音",
Intent: "录音进行中想临时中断(如中场休息、切换话题)又不想结束整条听记时使用;传入正在录音的听记 taskUuid,会真实暂停该次录音,之后可用 +record-resume 继续。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
},
Contract: minutesContract(
"+record-pause",
"暂停听记录音",
"实时听记仍在录制,但要临时中断且保留后续恢复能力时使用;必须传当前录制任务的 taskUuid。",
[]string{"会议已经结束时使用 +record-stop;只是查看听记状态或内容时使用只读命令"},
[]string{"dws minutes +record-pause --id <taskUuid>"},
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
@@ -330,7 +273,7 @@ var RecordPause = shortcut.Shortcut{
if rt.Changed("session-id") {
params["sessionId"] = rt.Str("session-id")
}
return rt.CallMCP(listeningNoteCmdTool, params)
return executeRecordCommand(rt, "pause", rt.Str("id"), params)
},
}
@@ -341,6 +284,16 @@ var RecordResume = shortcut.Shortcut{
Description: "恢复听记录音",
Intent: "之前用 +record-pause 暂停过的听记,现在想接着录时使用;传入该听记 taskUuid,会真实恢复录音,继续追加到同一条听记中。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
},
Contract: minutesContract(
"+record-resume",
"恢复听记录音",
"实时听记此前已暂停、现在要继续向同一 taskUuid 追加录音时使用。",
[]string{"已经结束的听记不能恢复;要新建录制时使用 +record-start"},
[]string{"dws minutes +record-resume --id <taskUuid>"},
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
@@ -351,7 +304,7 @@ var RecordResume = shortcut.Shortcut{
if rt.Changed("session-id") {
params["sessionId"] = rt.Str("session-id")
}
return rt.CallMCP(listeningNoteCmdTool, params)
return executeRecordCommand(rt, "resume", rt.Str("id"), params)
},
}
@@ -362,6 +315,16 @@ var RecordStop = shortcut.Shortcut{
Description: "结束听记录音",
Intent: "会议开完、想彻底停止录音并让系统开始生成转写与 AI 纪要时使用;传入正在录音的听记 taskUuid,会真实结束该次录音,结束后无法再恢复到这条听记继续录。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
},
Contract: minutesContract(
"+record-stop",
"结束听记录音",
"会议结束且要永久停止指定 taskUuid 的实时录制,让服务进入转写和纪要处理阶段时使用。",
[]string{"仍计划继续录音时使用 +record-pause;结束后不能通过 +record-resume 继续同一条录制"},
[]string{"dws minutes +record-stop --id <taskUuid>"},
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
@@ -372,10 +335,27 @@ var RecordStop = shortcut.Shortcut{
if rt.Changed("session-id") {
params["sessionId"] = rt.Str("session-id")
}
return rt.CallMCP(listeningNoteCmdTool, params)
return executeRecordCommand(rt, "end", rt.Str("id"), params)
},
}
func executeRecordCommand(rt *shortcut.RuntimeContext, expectedCmd, taskUUID string, params map[string]any) error {
data, err := rt.CallMCPWriteDataStrict("minutes", listeningNoteCmdTool, params)
if err != nil {
return err
}
result, err := minutesdata.RecordResult(expectedCmd, taskUUID, data)
if err != nil {
return err
}
return rt.Output(map[string]any{
"accepted": true,
"command": expectedCmd,
"taskUuid": taskUUID,
"result": result,
})
}
// ── mind-graph ──────────────────────────────────────────────────────────────
// ── speaker ─────────────────────────────────────────────────────────────────
@@ -391,7 +371,7 @@ var RecordStop = shortcut.Shortcut{
// ── tag ─────────────────────────────────────────────────────────────────────
func init() {
shortcut.Register(
shortcut.Register(finalizeMinutesShortcuts(
ListMine,
ListShared,
ListAll,
@@ -399,5 +379,5 @@ func init() {
RecordPause,
RecordResume,
RecordStop,
)
)...)
}
+836
View File
@@ -0,0 +1,836 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
var minutesWorkflowArtifacts = []string{"basic", "summary", "keywords", "transcript", "todos"}
var (
minutesMkdirTemp = os.MkdirTemp
minutesRemoveAll = os.RemoveAll
minutesWriteFile = os.WriteFile
minutesStat = os.Stat
minutesRename = os.Rename
minutesGetwd = os.Getwd
minutesEvalSymlinks = filepath.EvalSymlinks
minutesRel = filepath.Rel
minutesLstat = os.Lstat
minutesMkdir = os.Mkdir
minutesMarshalIndent = json.MarshalIndent
)
var RecordWrapUp = shortcut.Shortcut{
Service: "minutes", Command: "+record-wrap-up", Product: "minutes",
Description: "停止实时录音并有界等待听记产物,失败时保留恢复句柄",
Intent: "会议结束时一次完成 stop 与 basic/summary/keywords/transcript/todos 读取;停止已生效但产物未就绪时返回非零 partial 结果和 taskUuid,不会诱导重复 stop。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+record-wrap-up", "停止实时录音并有界等待听记产物,失败时保留恢复句柄",
"已知正在录制的 taskUuid,会议结束后要停止录制并立即收口转写、纪要、关键词和行动项时使用",
[]string{"仍要继续录音时使用 +record-pause;只停止、不等待产物时使用 +record-stop"},
[]string{`dws minutes +record-wrap-up --id <taskUuid>`, `dws minutes +record-wrap-up --id <taskUuid> --artifacts transcript,summary --wait-timeout 120`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "正在录制的听记 taskUuid", Required: true},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "停止后等待的产物", Enum: minutesWorkflowArtifacts},
{Name: "wait-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待产物秒数"},
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"wait-timeout", "poll-interval", "page-limit"}, Description: "超时、轮询间隔和逐字稿页数上限必须大于 0"}},
Tips: []string{`dws minutes +record-wrap-up --id <taskUuid>`, `dws minutes +record-wrap-up --id <taskUuid> --artifacts transcript,summary --wait-timeout 120`},
Validate: validateMinutesWorkflowWait,
Execute: executeMinutesRecordWrapUp,
}
var UploadAndAnalyze = shortcut.Shortcut{
Service: "minutes", Command: "+upload-and-analyze", Product: "minutes",
Description: "本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察",
Intent: "从本地媒体一次完成 upload create/PUT/complete/read-back,再等待选定听记产物;后续分析失败仍返回已创建 taskUuid 和恢复动作。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+upload-and-analyze", "本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察",
"有本地音视频且希望一次创建听记、等待基础分析,并可继续生成思维导图或发言人洞察时使用",
[]string{"只需上传时使用 +upload;已有 taskUuid 时分别使用 +detail/+mindmap/+speaker-insights"},
[]string{`dws minutes +upload-and-analyze --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload-and-analyze --file ./meeting.mp4 --artifacts transcript,summary --mindmap`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "本地音视频文件;与 --resume-id 二选一"},
{Name: "resume-id", Type: shortcut.FlagString, Desc: "先前已成功上传的 taskUuid;只恢复分析、不重复上传"},
{Name: "title", Type: shortcut.FlagString, Desc: "听记标题"},
{Name: "template-id", Type: shortcut.FlagString, Desc: "纪要模板 ID"},
{Name: "input-language", Type: shortcut.FlagString, Desc: "ASR 输入语言"},
{Name: "enable-message-card", Type: shortcut.FlagBool, Desc: "生成后推送闪记卡片"},
{Name: "complete-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "上传 complete 超时秒数"},
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "wait-timeout", Type: shortcut.FlagInt, Default: "180", Desc: "等待分析产物秒数"},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "等待的分析产物", Enum: minutesWorkflowArtifacts},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
{Name: "mindmap", Type: shortcut.FlagBool, Desc: "继续创建并等待思维导图"},
{Name: "speaker-insights", Type: shortcut.FlagBool, Desc: "继续创建并等待发言人总结"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"file", "resume-id"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"complete-timeout", "wait-timeout", "poll-interval", "page-limit"}, Description: "上传超时、分析超时、轮询间隔和逐字稿页数上限必须大于 0"},
},
Tips: []string{`dws minutes +upload-and-analyze --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload-and-analyze --file ./meeting.mp4 --artifacts transcript,summary --mindmap`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("complete-timeout") <= 0 {
return apperrors.NewValidation("--complete-timeout 必须大于 0")
}
return validateMinutesWorkflowWait(rt)
},
Execute: executeMinutesUploadAndAnalyze,
}
var Mindmap = shortcut.Shortcut{
Service: "minutes", Command: "+mindmap", Product: "minutes",
Description: "创建听记思维导图并轮询到明确成功、失败或超时",
Intent: "已有 taskUuid 且需要思维导图时使用;创建只执行一次,随后只轮询 taskStatus,失败/超时均返回非零和可继续查询的 taskUuid。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+mindmap", "创建听记思维导图并轮询到明确成功、失败或超时",
"听记内容已就绪,需要触发并等待平台思维导图产物时使用",
[]string{"只查询已经触发的任务状态时使用原子 mind-graph status;短音频或无有效内容可能明确失败"},
[]string{`dws minutes +mindmap --id <taskUuid>`, `dws minutes +mindmap --id <taskUuid> --timeout 120 --interval 3`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待秒数"},
{Name: "interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "resume", Type: shortcut.FlagBool, Desc: "只继续轮询,不重复创建任务"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"timeout", "interval"}, Description: "--timeout 和 --interval 必须大于 0"}},
Tips: []string{`dws minutes +mindmap --id <taskUuid>`, `dws minutes +mindmap --id <taskUuid> --timeout 120 --interval 3`},
Validate: validateMinutesPolling,
Execute: executeMinutesMindmap,
}
var SpeakerInsights = shortcut.Shortcut{
Service: "minutes", Command: "+speaker-insights", Product: "minutes",
Description: "创建发言人段落总结并轮询结果,保留异步任务恢复句柄",
Intent: "需要按发言人汇总听记内容时使用;严格要求 create 返回 taskId,读取未就绪时有界重试,失败或超时返回 taskId/taskUuid。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+speaker-insights", "创建发言人段落总结并轮询结果,保留异步任务恢复句柄",
"逐字稿已有多位发言人,需要触发并读取平台发言人段落总结时使用",
[]string{"只改发言人昵称时使用 +speaker-replace;无有效发言内容时平台可能不生成结果"},
[]string{`dws minutes +speaker-insights --id <taskUuid>`, `dws minutes +speaker-insights --id <taskUuid> --timeout 180`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "timeout", Type: shortcut.FlagInt, Default: "180", Desc: "等待秒数"},
{Name: "interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "resume", Type: shortcut.FlagBool, Desc: "只继续轮询,不重复创建任务"},
{Name: "task-id", Type: shortcut.FlagString, Desc: "先前 create 返回的异步 taskId,恢复时用于输出追踪"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"timeout", "interval"}, Description: "--timeout 和 --interval 必须大于 0"}},
Tips: []string{`dws minutes +speaker-insights --id <taskUuid>`, `dws minutes +speaker-insights --id <taskUuid> --timeout 180`},
Validate: validateMinutesPolling,
Execute: executeMinutesSpeakerInsights,
}
var PrepareASR = shortcut.Shortcut{
Service: "minutes", Command: "+prepare-asr", Product: "minutes",
Description: "读取个人热词、计算差异、按需新增并可显式同步删除后读回验证",
Intent: "录音/上传前准备 ASR 专有词表;默认只增加缺失热词,只有 --sync 才删除未出现在目标集合中的现有热词。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withMinutesDryRun(minutesContract("+prepare-asr", "按需新增个人热词并可显式同步删除后读回验证;dry-run 不访问远端",
"录音或上传前要补充人名、项目名等 ASR 热词,并希望看到差异和最终读回验证时使用",
[]string{"只查看现有热词时使用原子 hot-word list;不明确接受删除时不要使用 --sync"},
[]string{`dws minutes +prepare-asr --words "DWS,听记"`, `dws minutes +prepare-asr --words "DWS,听记" --sync`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "words", Type: shortcut.FlagStringSlice, Desc: "目标热词,逗号分隔", Required: true},
{Name: "sync", Type: shortcut.FlagBool, Desc: "删除目标集合之外的现有热词"},
},
Tips: []string{`dws minutes +prepare-asr --words "DWS,听记"`, `dws minutes +prepare-asr --words "DWS,听记" --sync`},
Execute: executeMinutesPrepareASR,
}
var ExportPack = shortcut.Shortcut{
Service: "minutes", Command: "+export-pack", Product: "minutes",
Description: "把完整听记产物写入受控目录并生成不含签名 URL 的 manifest",
Intent: "需要离线归档 basic/summary/keywords/transcript/todos,可选媒体文件时使用;全部必需产物验证通过后才原子发布目录。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: minutesContract("+export-pack", "把完整听记产物写入受控目录并生成不含签名 URL 的 manifest",
"已知 taskUuid,需要把多个已验证产物和完整性 manifest 安全归档到工作目录时使用",
[]string{"只下载媒体时使用 +download;目标目录已存在时本命令拒绝覆盖"},
[]string{`dws minutes +export-pack --id <taskUuid> --output ./minutes-export`, `dws minutes +export-pack --id <taskUuid> --output ./minutes-export --include-media`}),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "output", Type: shortcut.FlagString, Desc: "工作目录内的新归档目录", Required: true},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要导出的产物", Enum: minutesWorkflowArtifacts},
{Name: "include-media", Type: shortcut.FlagBool, Desc: "同时下载音视频"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "输出必须是安全的工作目录相对路径"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
},
Tips: []string{`dws minutes +export-pack --id <taskUuid> --output ./minutes-export`, `dws minutes +export-pack --id <taskUuid> --output ./minutes-export --include-media`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
return localio.ValidateOutput(rt.Str("output"))
},
Execute: executeMinutesExportPack,
}
var Share = shortcut.Shortcut{
Service: "minutes", Command: "+share", Product: "minutes",
Description: "按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger",
Intent: "所有者已确认成员钉钉 UID 和权限,需批量授权时使用;逐成员调用以区分成功/失败,默认首错停止。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+share", "按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger",
"听记所有者已确认稳定 member UID,需要授予 view/download/edit 权限并审计每个成员结果时使用",
[]string{"当前用户自己申请权限时使用 +apply-permission;只有姓名而无稳定 UID 时先用 contact 命令消歧"},
[]string{`dws minutes +share --ids <uuid1,uuid2> --member-uids <uid1,uid2> --permission view`, `dws minutes +share --id <uuid> --member-uids <uid> --permission edit --cover`}), contract.DryRunPreviewPlan, false),
Flags: minutesShareFlags(true),
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "听记去重后必须为 1..50 个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-uids"}, Description: "成员 UID 去重后必须为 1..50 个"},
},
Tips: []string{`dws minutes +share --ids <uuid1,uuid2> --member-uids <uid1,uid2> --permission view`, `dws minutes +share --id <uuid> --member-uids <uid> --permission edit --cover`},
Validate: validateMinutesShare,
Execute: executeMinutesShare,
}
var Unshare = shortcut.Shortcut{
Service: "minutes", Command: "+unshare", Product: "minutes",
Description: "按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger",
Intent: "所有者明确要撤销稳定成员 UID 的听记访问时使用;默认首错停止,任何部分失败都返回非零。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+unshare", "按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger",
"听记所有者已确认稳定 member UID,需要撤销其对一个或多个听记的访问权限时使用",
[]string{"要授权时使用 +share;成员或听记 ID 未确认时不要撤销"},
[]string{`dws minutes +unshare --ids <uuid1,uuid2> --member-uids <uid1,uid2>`, `dws minutes +unshare --id <uuid> --member-uids <uid> --failure-policy continue`}), contract.DryRunPreviewPlan, false),
Flags: minutesShareFlags(false),
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "听记去重后必须为 1..50 个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-uids"}, Description: "成员 UID 去重后必须为 1..50 个"},
},
Tips: []string{`dws minutes +unshare --ids <uuid1,uuid2> --member-uids <uid1,uid2>`, `dws minutes +unshare --id <uuid> --member-uids <uid> --failure-policy continue`},
Validate: validateMinutesShare,
Execute: executeMinutesUnshare,
}
func validateMinutesWorkflowWait(rt *shortcut.RuntimeContext) error {
if rt.Int("wait-timeout") <= 0 || rt.Int("poll-interval") <= 0 || rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("等待超时、轮询间隔和逐字稿页数上限必须大于 0")
}
return nil
}
func validateMinutesPolling(rt *shortcut.RuntimeContext) error {
if rt.Int("timeout") <= 0 || rt.Int("interval") <= 0 {
return apperrors.NewValidation("--timeout 和 --interval 必须大于 0")
}
return nil
}
func selectedWorkflowArtifacts(rt *shortcut.RuntimeContext) []string {
selected := rt.StrSlice("artifacts")
if len(selected) == 0 {
return append([]string(nil), minutesWorkflowArtifacts...)
}
return uniqueStrings(selected)
}
func executeMinutesRecordWrapUp(rt *shortcut.RuntimeContext) error {
id := rt.Str("id")
artifacts := selectedWorkflowArtifacts(rt)
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.record_wrap_up", map[string]any{"taskUuid": id, "stages": []string{"record.stop", "artifacts.wait"}, "artifacts": artifacts}))
}
stopData, err := rt.CallMCPWriteDataStrict("minutes", listeningNoteCmdTool, map[string]any{"cmd": "end", "uuid": id})
if err != nil {
return err
}
if _, err := minutesdata.RecordResult("end", id, stopData); err != nil {
return err
}
bundle, failures, attempts := waitMinutesArtifacts(rt, id, artifacts, rt.Int("page-limit"), time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
payload := map[string]any{
"operation": "minutes.record_wrap_up", "complete": len(failures) == 0, "taskUuid": id,
"stages": []map[string]any{{"name": "record.stop", "complete": true}, {"name": "artifacts.wait", "complete": len(failures) == 0, "attempts": attempts}},
"artifacts": bundle, "failures": failures,
}
if len(failures) > 0 {
payload["recovery"] = map[string]any{"taskUuid": id, "nextAction": "dws minutes +detail --id <taskUuid>"}
}
return outputWorkflowResult(rt, payload, len(failures) > 0, "minutes_record_wrap_up_incomplete", "artifacts")
}
func executeMinutesUploadAndAnalyze(rt *shortcut.RuntimeContext) error {
artifacts := selectedWorkflowArtifacts(rt)
if rt.DryRun() {
upload := map[string]any{"operation": "minutes.upload", "resume": rt.Str("resume-id") != "", "taskUuid": rt.Str("resume-id"), "executed": false}
if rt.Str("resume-id") == "" {
var err error
upload, err = performMinutesUpload(rt)
if err != nil {
return err
}
}
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.upload_and_analyze", map[string]any{"upload": upload, "artifacts": artifacts, "mindmap": rt.Bool("mindmap"), "speakerInsights": rt.Bool("speaker-insights")}))
}
id := strings.TrimSpace(rt.Str("resume-id"))
upload := map[string]any{"operation": "minutes.upload", "complete": true, "resumed": true, "taskUuid": id, "verified": true}
if id == "" {
var err error
upload, err = performMinutesUpload(rt)
if err != nil {
return err
}
id, _ = upload["taskUuid"].(string)
}
bundle, failures, attempts := waitMinutesArtifacts(rt, id, artifacts, rt.Int("page-limit"), time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
stages := []map[string]any{{"name": "upload", "complete": true, "result": upload}, {"name": "artifacts.wait", "complete": len(failures) == 0, "attempts": attempts}}
if rt.Bool("mindmap") {
result, runErr := runMinutesMindmap(rt, id, time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second, true)
stage := map[string]any{"name": "mindmap", "complete": runErr == nil, "result": result}
if runErr != nil {
stage["error"] = runErr.Error()
failures = append(failures, map[string]any{"artifact": "mindmap", "error": runErr.Error()})
}
stages = append(stages, stage)
}
if rt.Bool("speaker-insights") {
result, runErr := runMinutesSpeakerInsights(rt, id, time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second, true, "")
stage := map[string]any{"name": "speaker-insights", "complete": runErr == nil, "result": result}
if runErr != nil {
stage["error"] = runErr.Error()
failures = append(failures, map[string]any{"artifact": "speaker-insights", "error": runErr.Error()})
}
stages = append(stages, stage)
}
payload := map[string]any{"operation": "minutes.upload_and_analyze", "complete": len(failures) == 0, "taskUuid": id, "stages": stages, "artifacts": bundle, "failures": failures}
if len(failures) > 0 {
payload["recovery"] = map[string]any{"taskUuid": id, "nextAction": "resume analysis with +detail/+mindmap/+speaker-insights; do not upload again"}
}
return outputWorkflowResult(rt, payload, len(failures) > 0, "minutes_upload_analysis_incomplete", "analysis")
}
func executeMinutesMindmap(rt *shortcut.RuntimeContext) error {
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.mindmap", map[string]any{"taskUuid": rt.Str("id"), "stages": []string{"create", "poll"}}))
}
payload, err := runMinutesMindmap(rt, rt.Str("id"), time.Duration(rt.Int("timeout"))*time.Second, time.Duration(rt.Int("interval"))*time.Second, !rt.Bool("resume"))
if err := rt.Output(payload); err != nil {
return err
}
return err
}
func runMinutesMindmap(rt *shortcut.RuntimeContext, id string, timeout, interval time.Duration, create bool) (map[string]any, error) {
createAcknowledged := false
if create {
// create_mind_graph is observed to return either an explicit success
// envelope or an empty acknowledgement. Empty alone is never success;
// continue only so the status read-back can prove the final effect.
created, err := rt.CallMCPWriteData("minutes", "create_mind_graph", map[string]any{"taskUuid": id})
if err != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "create"}, err
}
createAcknowledged = minutesdata.RequireWriteAcknowledgement("mind graph create", created) == nil
}
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
data, callErr := rt.CallMCPData("minutes", "query_mind_graph_status", map[string]any{"taskUuid": id})
if callErr != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "poll", "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}, callErr
}
status, result, parseErr := minutesdata.MindGraphStatus(data)
if parseErr != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "poll", "attempts": attempts}, parseErr
}
if status == 1 {
return map[string]any{"operation": "minutes.mindmap", "complete": true, "taskUuid": id, "taskStatus": status, "attempts": attempts, "createAttempted": create, "createAcknowledged": createAcknowledged, "verified": true, "result": result}, nil
}
if status == 2 {
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "result": result, "recovery": map[string]any{"taskUuid": id, "nextAction": "inspect source transcript; do not assume an empty mind map"}}
return payload, minutesCompositeError("minutes_mindmap_failed", "poll", payload)
}
if time.Now().Add(interval).After(deadline) {
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}
return payload, minutesCompositeError("minutes_mindmap_timeout", "poll", payload)
}
if err := waitMinutesInterval(rt, interval); err != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "attempts": attempts}, err
}
}
}
func executeMinutesSpeakerInsights(rt *shortcut.RuntimeContext) error {
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.speaker_insights", map[string]any{"taskUuid": rt.Str("id"), "stages": []string{"create", "poll"}}))
}
payload, err := runMinutesSpeakerInsights(rt, rt.Str("id"), time.Duration(rt.Int("timeout"))*time.Second, time.Duration(rt.Int("interval"))*time.Second, !rt.Bool("resume"), rt.Str("task-id"))
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
return err
}
func runMinutesSpeakerInsights(rt *shortcut.RuntimeContext, id string, timeout, interval time.Duration, create bool, taskID string) (map[string]any, error) {
status := "resume"
if create {
created, err := rt.CallMCPWriteDataStrict("minutes", "create_speaker_summary", map[string]any{"uuids": []string{id}})
if err != nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "stage": "create"}, err
}
var parseErr error
taskID, status, parseErr = minutesdata.SpeakerSummaryTask(created)
if parseErr != nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "stage": "create"}, parseErr
}
}
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
data, callErr := rt.CallMCPData("minutes", "get_speaker_summary", map[string]any{"uuids": []string{id}})
if callErr == nil {
result, resultErr := minutesdata.SpeakerSummaryResult(data)
if resultErr == nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": true, "taskUuid": id, "taskId": taskID, "createStatus": status, "attempts": attempts, "result": result}, nil
}
callErr = resultErr
}
if !speakerSummaryPending(callErr) {
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
return payload, callErr
}
if time.Now().Add(interval).After(deadline) {
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
return payload, minutesCompositeError("minutes_speaker_insights_timeout", "poll", payload)
}
if err := waitMinutesInterval(rt, interval); err != nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts}, err
}
}
}
func speakerSummaryPending(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "query empty") || strings.Contains(message, "processing") || strings.Contains(message, "not ready") || strings.Contains(message, "result is empty") || strings.Contains(message, "business error: code 000") || strings.Contains(message, "暂无")
}
func executeMinutesPrepareASR(rt *shortcut.RuntimeContext) error {
desired := uniqueStrings(rt.StrSlice("words"))
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.prepare_asr", map[string]any{
"desired": desired, "sync": rt.Bool("sync"), "remotePreconditions": []string{"read current hot words", "compute additions and deletions", "apply writes", "read back exact set"},
}))
}
currentData, err := rt.CallMCPData("minutes", "list_my_hotwords", map[string]any{})
if err != nil {
return err
}
current, err := minutesdata.HotWords(currentData)
if err != nil {
return err
}
toAdd := stringDifference(desired, current)
toDelete := []string{}
if rt.Bool("sync") {
toDelete = stringDifference(current, desired)
}
plan := map[string]any{"operation": "minutes.prepare_asr", "before": current, "desired": desired, "add": toAdd, "delete": toDelete, "sync": rt.Bool("sync")}
stages := []map[string]any{}
if len(toAdd) > 0 {
data, writeErr := rt.CallMCPWriteDataStrict("minutes", "add_personal_hot_word", map[string]any{"hotWordList": toAdd})
if writeErr == nil {
writeErr = minutesdata.RequireWriteAcknowledgement("hot-word add", data)
}
stages = append(stages, map[string]any{"name": "add", "complete": writeErr == nil, "words": toAdd})
if writeErr != nil {
plan["complete"], plan["stages"], plan["recovery"] = false, stages, map[string]any{"nextAction": "review current hot words before retrying"}
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_add_failed", "add")
}
}
if len(toDelete) > 0 {
data, writeErr := rt.CallMCPWriteDataStrict("minutes", "delete_personal_hotword", map[string]any{"hotWordList": toDelete})
if writeErr == nil {
writeErr = minutesdata.RequireWriteAcknowledgement("hot-word delete", data)
}
stages = append(stages, map[string]any{"name": "delete", "complete": writeErr == nil, "words": toDelete})
if writeErr != nil {
plan["complete"], plan["stages"], plan["recovery"] = false, stages, map[string]any{"nextAction": "list hot words; additions may already be applied"}
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_delete_failed", "delete")
}
}
verifiedData, err := rt.CallMCPData("minutes", "list_my_hotwords", map[string]any{})
if err != nil {
plan["complete"], plan["stages"] = false, stages
if outputErr := rt.Output(plan); outputErr != nil {
return outputErr
}
return err
}
verified, err := minutesdata.HotWords(verifiedData)
if err != nil {
return err
}
missing := stringDifference(desired, verified)
unexpected := []string{}
if rt.Bool("sync") {
unexpected = stringDifference(verified, desired)
}
plan["complete"], plan["verified"], plan["after"], plan["stages"] = len(missing) == 0 && len(unexpected) == 0, len(missing) == 0 && len(unexpected) == 0, verified, stages
if len(missing) > 0 || len(unexpected) > 0 {
plan["missing"], plan["unexpected"] = missing, unexpected
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_verification_failed", "verify")
}
return rt.Output(plan)
}
func executeMinutesExportPack(rt *shortcut.RuntimeContext) error {
id := rt.Str("id")
artifacts := selectedWorkflowArtifacts(rt)
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, rt.Int("page-limit"))
if len(failures) > 0 {
payload := map[string]any{"operation": "minutes.export_pack", "complete": false, "taskUuid": id, "failures": failures, "published": false}
return outputWorkflowResult(rt, payload, true, "minutes_export_pack_incomplete", "collect")
}
target, relative, parent, err := prepareExportTarget(rt.Str("output"))
if err != nil {
return err
}
tempDir, err := minutesMkdirTemp(parent, ".dws-minutes-export-*")
if err != nil {
return err
}
cleanup := true
defer func() {
if cleanup {
_ = minutesRemoveAll(tempDir)
}
}()
files := map[string]map[string]any{}
for _, name := range artifacts {
filename := name + ".json"
value := bundle[name]
if name == "summary" {
filename = "summary.md"
if err := minutesWriteFile(filepath.Join(tempDir, filename), []byte(value.(string)), 0o600); err != nil {
return err
}
} else if err := writeJSONFile(filepath.Join(tempDir, filename), value); err != nil {
return err
}
info, err := minutesStat(filepath.Join(tempDir, filename))
if err != nil {
return err
}
files[name] = map[string]any{"file": filename, "sizeBytes": info.Size(), "complete": true}
}
if rt.Bool("include-media") {
mediaData, callErr := rt.CallMCPData("minutes", "query_minutes_audio_url", map[string]any{"taskUuid": id})
if callErr != nil {
return callErr
}
mediaURL, parseErr := minutesdata.MediaURL(mediaData)
if parseErr != nil {
return parseErr
}
download, downloadErr := minutesDownload(rt.Command().Context(), mediaURL, localio.DownloadOptions{BaseDir: tempDir, Output: "media/", PreferredName: id + mediaExtension(mediaURL)})
if downloadErr != nil {
return downloadErr
}
files["media"] = map[string]any{"file": download.RelativePath, "sizeBytes": download.SizeBytes, "complete": true}
}
manifest := map[string]any{"version": 1, "operation": "minutes.export_pack", "taskUuid": id, "complete": true, "generatedAt": time.Now().UTC().Format(time.RFC3339), "files": files}
if err := writeJSONFile(filepath.Join(tempDir, "manifest.json"), manifest); err != nil {
return err
}
if err := minutesRename(tempDir, target); err != nil {
return fmt.Errorf("发布听记归档目录失败: %w", err)
}
cleanup = false
return rt.Output(map[string]any{"operation": "minutes.export_pack", "complete": true, "taskUuid": id, "published": true, "path": filepath.ToSlash(relative), "manifest": filepath.ToSlash(filepath.Join(relative, "manifest.json")), "files": files})
}
func minutesShareFlags(includePermission bool) []shortcut.Flag {
flags := []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "单个听记 taskUuid"},
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个听记 taskUuid,最多 50 个"},
{Name: "member-uids", Type: shortcut.FlagStringSlice, Desc: "稳定成员钉钉 UID,最多 50 个", Required: true},
}
if includePermission {
flags = append(flags,
shortcut.Flag{Name: "permission", Type: shortcut.FlagString, Desc: "授予权限", Required: true, Enum: []string{"view", "download", "edit"}},
shortcut.Flag{Name: "cover", Type: shortcut.FlagBool, Desc: "覆盖已有权限"},
shortcut.Flag{Name: "sub-resources", Type: shortcut.FlagStringSlice, Desc: "可选子资源", Enum: []string{"OrigContent", "Summary", "Analysis", "Note"}},
)
}
return append(flags, shortcut.Flag{Name: "failure-policy", Type: shortcut.FlagString, Default: "stop", Desc: "成员失败策略", Enum: []string{"stop", "continue"}})
}
func validateMinutesShare(rt *shortcut.RuntimeContext) error {
ids, members := minutesIDs(rt), uniqueStrings(rt.StrSlice("member-uids"))
if len(ids) == 0 || len(ids) > 50 || len(members) == 0 || len(members) > 50 {
return apperrors.NewValidation("听记和成员 UID 数量必须各为 1..50")
}
return nil
}
func executeMinutesShare(rt *shortcut.RuntimeContext) error {
policy := map[string]float64{"edit": 2, "download": 3, "view": 4}[rt.Str("permission")]
return executeMinutesPermissionLedger(rt, "share", "add_member_permission", func(member string) map[string]any {
params := map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}, "policyId": policy}
if rt.Changed("cover") {
params["coverPermission"] = fmt.Sprintf("%t", rt.Bool("cover"))
}
if values := rt.StrSlice("sub-resources"); len(values) > 0 {
params["roleSubResourceIds"] = values
}
return params
})
}
func executeMinutesUnshare(rt *shortcut.RuntimeContext) error {
return executeMinutesPermissionLedger(rt, "unshare", "remove_member_permission", func(member string) map[string]any {
return map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}}
})
}
func executeMinutesPermissionLedger(rt *shortcut.RuntimeContext, operation, tool string, params func(string) map[string]any) error {
members := uniqueStrings(rt.StrSlice("member-uids"))
plan := map[string]any{"operation": "minutes." + operation, "taskUuids": minutesIDs(rt), "memberCount": len(members), "members": members}
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes."+operation, plan))
}
results := []map[string]any{}
failures := []map[string]any{}
unattempted := []string{}
for index, member := range members {
data, err := rt.CallMCPWriteDataStrict("minutes", tool, params(member))
if err == nil {
err = minutesdata.RequireWriteAcknowledgement(operation, data)
}
if err != nil {
failures = append(failures, map[string]any{"memberUid": member, "error": err.Error()})
if rt.Str("failure-policy") == "stop" {
unattempted = append(unattempted, members[index+1:]...)
break
}
continue
}
results = append(results, map[string]any{"memberUid": member, "complete": true})
}
plan["complete"], plan["succeeded"], plan["failed"], plan["unattempted"], plan["results"], plan["failures"] = len(failures) == 0, len(results), len(failures), unattempted, results, failures
return outputWorkflowResult(rt, plan, len(failures) > 0, "minutes_permission_partial", operation)
}
func collectMinutesArtifactsOnce(rt *shortcut.RuntimeContext, id string, artifacts []string, pageLimit int) (map[string]any, []map[string]any) {
bundle := map[string]any{}
failures := []map[string]any{}
for _, artifact := range artifacts {
var value any
var err error
switch artifact {
case "basic":
var data map[string]any
data, err = rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
if err == nil {
value, err = minutesdata.Basic(id, data)
}
case "summary":
var data map[string]any
data, err = rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
if err == nil {
value, err = minutesdata.SummaryText(data)
if err == nil && strings.TrimSpace(value.(string)) == "" {
err = fmt.Errorf("minutes summary is explicitly empty; analysis readiness is not proven")
}
}
case "keywords":
var data map[string]any
data, err = rt.CallMCPData("minutes", "get_minutes_keywords", map[string]any{"taskUuid": id})
if err == nil {
err = minutesdata.ValidateArtifact("keywords", id, data)
value = data["result"]
}
case "transcript":
var transcript minutesdata.TranscriptResult
transcript, err = collectTranscriptForMinutes(rt, id, pageLimit)
value = minutesdata.TranscriptPayload(id, "0", transcript)
if err == nil && len(transcript.Paragraphs) == 0 {
err = fmt.Errorf("minutes transcript is explicitly empty; analysis readiness is not proven")
}
case "todos":
var data map[string]any
data, err = rt.CallMCPData("minutes", "list_minutes_todos", map[string]any{"taskUuid": id})
if err == nil {
err = minutesdata.ValidateArtifact("todos", id, data)
value = data["result"]
}
default:
err = fmt.Errorf("unsupported artifact %q", artifact)
}
if err != nil {
failures = append(failures, map[string]any{"artifact": artifact, "error": err.Error()})
continue
}
bundle[artifact] = value
}
return bundle, failures
}
func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []string, pageLimit int, timeout, interval time.Duration) (map[string]any, []map[string]any, int) {
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, pageLimit)
if len(failures) == 0 || time.Now().Add(interval).After(deadline) {
return bundle, failures, attempts
}
if err := waitMinutesInterval(rt, interval); err != nil {
return bundle, append(failures, map[string]any{"artifact": "wait", "error": err.Error()}), attempts
}
}
}
func waitMinutesInterval(rt *shortcut.RuntimeContext, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-rt.Command().Context().Done():
return rt.Command().Context().Err()
case <-timer.C:
return nil
}
}
func outputWorkflowResult(rt *shortcut.RuntimeContext, payload map[string]any, failed bool, reason, stage string) error {
if err := rt.Output(payload); err != nil {
return err
}
if failed {
return minutesCompositeError(reason, stage, payload)
}
return nil
}
func uniqueStrings(values []string) []string {
seen := map[string]bool{}
out := []string{}
for _, value := range values {
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item != "" && !seen[item] {
seen[item] = true
out = append(out, item)
}
}
}
return out
}
func stringDifference(left, right []string) []string {
other := map[string]bool{}
for _, value := range right {
other[value] = true
}
out := []string{}
for _, value := range left {
if !other[value] {
out = append(out, value)
}
}
sort.Strings(out)
return out
}
func prepareExportTarget(output string) (target, relative, parent string, err error) {
if err := localio.ValidateOutput(output); err != nil {
return "", "", "", err
}
cwd, err := minutesGetwd()
if err != nil {
return "", "", "", err
}
realBase, err := minutesEvalSymlinks(cwd)
if err != nil {
return "", "", "", err
}
relative = filepath.Clean(output)
target = filepath.Join(realBase, relative)
parent = filepath.Dir(target)
rel, err := minutesRel(realBase, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", "", "", fmt.Errorf("LOCAL_PATH_UNSAFE: 输出目录逃逸工作目录")
}
if err := ensureExportParent(realBase, filepath.Dir(rel)); err != nil {
return "", "", "", err
}
if _, err := minutesLstat(target); err == nil {
return "", "", "", fmt.Errorf("LOCAL_FILE_EXISTS: 目标目录已存在;请选择新的输出路径")
} else if !os.IsNotExist(err) {
return "", "", "", err
}
return target, relative, parent, nil
}
func ensureExportParent(base, relative string) error {
current := base
if relative == "." || relative == "" {
return nil
}
for _, component := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) {
current = filepath.Join(current, component)
info, err := minutesLstat(current)
if os.IsNotExist(err) {
if err := minutesMkdir(current, 0o700); err != nil {
return err
}
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("LOCAL_PATH_UNSAFE: 输出父路径不是安全目录")
}
}
return nil
}
func writeJSONFile(path string, value any) error {
raw, err := minutesMarshalIndent(value, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
return minutesWriteFile(path, raw, 0o600)
}
func init() {
shortcut.Register(finalizeMinutesShortcuts(RecordWrapUp, UploadAndAnalyze, Mindmap, SpeakerInsights, PrepareASR, ExportPack, Share, Unshare)...)
}
@@ -0,0 +1,667 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageMinutesWorkflowValidationAndDefaults(t *testing.T) {
file := filepath.Join(t.TempDir(), "source.wav")
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
t.Fatal(err)
}
invalid := [][]string{
{"minutes", "+record-wrap-up", "--id", "u1", "--wait-timeout", "0"},
{"minutes", "+upload-and-analyze", "--file", file, "--complete-timeout", "0"},
{"minutes", "+mindmap", "--id", "u1", "--timeout", "0"},
{"minutes", "+speaker-insights", "--id", "u1", "--interval", "0"},
{"minutes", "+export-pack", "--id", "u1", "--output", "pack", "--page-limit", "0"},
{"minutes", "+share", "--id", "u1", "--member-uids", strings.Repeat("m,", 51), "--permission", "view"},
}
for _, args := range invalid {
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, args...); err == nil || payload != nil || output != "" {
t.Fatalf("invalid workflow accepted: %v payload=%#v output=%q err=%v", args, payload, output, err)
}
}
caller := &minutesE2ECaller{}
helpers.InitDepsForTest(t, caller)
cmd := &cobra.Command{Use: "workflow"}
cmd.Flags().StringSlice("artifacts", nil, "")
rt := shortcut.RuntimeContextForTest(cmd, RecordWrapUp)
selected := selectedWorkflowArtifacts(rt)
selected[0] = "mutated"
if minutesWorkflowArtifacts[0] == "mutated" || len(selected) != len(minutesWorkflowArtifacts) {
t.Fatalf("default artifacts alias global: %#v", selected)
}
}
func TestCrossPlatformCoverageMinutesRecordWrapUpBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+record-wrap-up", "--id", "u1", "--dry-run"); err == nil {
t.Fatal("record dry-run output failure accepted")
}
stopFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/" + listeningNoteCmdTool: 1}}
if _, _, err := runMinutesAlignmentCLI(t, stopFailure, "minutes", "+record-wrap-up", "--id", "u1", "--yes"); err == nil {
t.Fatal("record stop failure accepted")
}
badStop := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"pause","uuid":"u1"}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, badStop, "minutes", "+record-wrap-up", "--id", "u1", "--yes"); err == nil {
t.Fatal("record mismatched acknowledgement accepted")
}
success := &minutesE2ECaller{responses: map[string][]string{
"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`},
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
}}
payload, output, err := runMinutesAlignmentCLI(t, success, "minutes", "+record-wrap-up", "--id", "u1", "--artifacts", "basic", "--yes")
if err != nil || output == "" || payload["complete"] != true {
t.Fatalf("record success payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesUploadAndAnalyzeBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--dry-run"); err == nil {
t.Fatal("resume dry-run output failure accepted")
}
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload-and-analyze", "--file", filepath.Join(t.TempDir(), "missing"), "--yes"); err == nil {
t.Fatal("upload-and-analyze invalid file accepted")
}
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload-and-analyze", "--file", filepath.Join(t.TempDir(), "missing"), "--dry-run"); err == nil {
t.Fatal("upload-and-analyze invalid dry-run file accepted")
}
resume := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1,"mindGraph":"ready"}}`},
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`},
"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, resume, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--artifacts", "basic", "--mindmap", "--speaker-insights", "--yes")
if err != nil || payload["complete"] != true || payload["taskUuid"] != "u1" {
t.Fatalf("resume payload=%#v err=%v", payload, err)
}
partial := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":2}}`},
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`},
"minutes/get_speaker_summary": {`{"success":false,"errorMsg":"denied"}`},
}}
payload, output, err := runMinutesAlignmentCLI(t, partial, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--artifacts", "basic", "--mindmap", "--speaker-insights", "--yes")
if err == nil || output == "" || payload["complete"] != false || payload["recovery"] == nil {
t.Fatalf("partial payload=%#v err=%v", payload, err)
}
file := filepath.Join(t.TempDir(), "source.wav")
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
return localio.UploadResult{SizeBytes: 5, Attempts: 1}, nil
})
upload := &minutesE2ECaller{responses: map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u2"}}`},
"minutes/get_minutes_basic_info": {
`{"success":true,"result":{"taskUuid":"u2","title":"uploaded"}}`,
`{"success":true,"result":{"taskUuid":"u2","title":"uploaded"}}`,
},
}}
payload, _, err = runMinutesAlignmentCLI(t, upload, "minutes", "+upload-and-analyze", "--file", file, "--artifacts", "basic", "--yes")
if err != nil || payload["taskUuid"] != "u2" || payload["complete"] != true {
t.Fatalf("fresh upload payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesMindmapBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+mindmap", "--id", "u1", "--dry-run"); err == nil {
t.Fatal("mindmap dry-run output failure accepted")
}
cases := []struct {
name string
responses map[string][]string
failAt map[string]int
args []string
}{
{name: "create call", failAt: map[string]int{"minutes/create_mind_graph": 1}},
{name: "poll call", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}}, failAt: map[string]int{"minutes/query_mind_graph_status": 1}},
{name: "poll parse", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}, "minutes/query_mind_graph_status": {`{"success":true,"result":{}}`}}},
{name: "timeout", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}, "minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":0}}`}}},
{name: "resume", responses: map[string][]string{"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1}}`}}, args: []string{"--resume"}},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
args := []string{"minutes", "+mindmap", "--id", "u1", "--timeout", "1", "--interval", "1", "--yes"}
args = append(args, test.args...)
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
if test.name == "resume" {
if err != nil || payload["complete"] != true {
t.Fatalf("resume payload=%#v err=%v", payload, err)
}
} else if err == nil || output == "" {
t.Fatalf("failure accepted payload=%#v err=%v", payload, err)
}
})
}
outputFail := &minutesE2ECaller{responses: map[string][]string{
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1}}`},
}}
if err := runMinutesAlignmentCLIWithWriter(t, outputFail, minutesFailWriter{}, "minutes", "+mindmap", "--id", "u1", "--resume", "--yes"); err == nil {
t.Fatal("mindmap output failure accepted")
}
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":0}}`}}}
helpers.InitDepsForTest(t, caller)
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd := &cobra.Command{Use: "mindmap"}
cmd.SetContext(ctx)
rt := shortcut.RuntimeContextForTest(cmd, Mindmap)
if _, err := runMinutesMindmap(rt, "u1", 2*time.Hour, time.Hour, false); err == nil {
t.Fatal("mindmap cancelled wait accepted")
}
}
func TestCrossPlatformCoverageMinutesSpeakerInsightsBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+speaker-insights", "--id", "u1", "--dry-run"); err == nil {
t.Fatal("speaker dry-run output failure accepted")
}
cases := []struct {
name string
responses map[string][]string
failAt map[string]int
args []string
}{
{name: "create call", failAt: map[string]int{"minutes/create_speaker_summary": 1}},
{name: "create parse", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{}}`}}},
{name: "poll nonpending", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":false,"errorMsg":"denied"}`}}},
{name: "poll parse", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":true,"result":{}}`}}},
{name: "timeout", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":false,"errorMsg":"processing"}`}}},
{name: "resume", responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`}}, args: []string{"--resume", "--task-id", "job"}},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
args := []string{"minutes", "+speaker-insights", "--id", "u1", "--timeout", "1", "--interval", "1", "--yes"}
args = append(args, test.args...)
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
if test.name == "resume" {
if err != nil || payload["complete"] != true || payload["taskId"] != "job" {
t.Fatalf("resume payload=%#v err=%v", payload, err)
}
} else if err == nil || output == "" {
t.Fatalf("failure accepted payload=%#v err=%v", payload, err)
}
})
}
outputFail := &minutesE2ECaller{responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`}}}
if err := runMinutesAlignmentCLIWithWriter(t, outputFail, minutesFailWriter{}, "minutes", "+speaker-insights", "--id", "u1", "--resume", "--yes"); err == nil {
t.Fatal("speaker output failure accepted")
}
for _, message := range []string{"query empty", "processing", "not ready", "result is empty", "business error: code 000", "暂无"} {
if !speakerSummaryPending(errors.New(message)) {
t.Fatalf("pending message rejected: %q", message)
}
}
if speakerSummaryPending(nil) || speakerSummaryPending(errors.New("denied")) {
t.Fatal("non-pending speaker error accepted")
}
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":false,"errorMsg":"processing"}`}}}
helpers.InitDepsForTest(t, caller)
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd := &cobra.Command{Use: "speaker"}
cmd.SetContext(ctx)
rt := shortcut.RuntimeContextForTest(cmd, SpeakerInsights)
if _, err := runMinutesSpeakerInsights(rt, "u1", 2*time.Hour, time.Hour, false, "job"); err == nil {
t.Fatal("speaker cancelled wait accepted")
}
}
func TestCrossPlatformCoverageMinutesPrepareASRBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+prepare-asr", "--words", "a", "--dry-run"); err == nil {
t.Fatal("ASR dry-run output failure accepted")
}
readFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/list_my_hotwords": 1}}
if _, _, err := runMinutesAlignmentCLI(t, readFailure, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
t.Fatal("ASR current read failure accepted")
}
parseFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, parseFailure, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
t.Fatal("ASR current parse failure accepted")
}
for _, test := range []struct {
name string
responses map[string][]string
failAt map[string]int
}{
{name: "add call", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":[]}}`}}, failAt: map[string]int{"minutes/add_personal_hot_word": 1}},
{name: "add ack", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":[]}}`}, "minutes/add_personal_hot_word": {`{"result":{}}`}}},
{name: "delete call", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`}}, failAt: map[string]int{"minutes/delete_personal_hotword": 1}},
{name: "delete ack", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`}, "minutes/delete_personal_hotword": {`{"result":{}}`}}},
} {
t.Run(test.name, func(t *testing.T) {
args := []string{"minutes", "+prepare-asr", "--words", "a", "--yes"}
if strings.HasPrefix(test.name, "delete") {
args = []string{"minutes", "+prepare-asr", "--words", "a", "--sync", "--yes"}
test.responses["minutes/add_personal_hot_word"] = []string{`{"success":true,"result":{}}`}
}
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("ASR failure accepted payload=%#v err=%v", payload, err)
}
})
}
verifyCall := &minutesE2ECaller{
responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["a"]}}`}},
failAt: map[string]int{"minutes/list_my_hotwords": 2},
}
payload, output, err := runMinutesAlignmentCLI(t, verifyCall, "minutes", "+prepare-asr", "--words", "a", "--yes")
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("ASR verify call payload=%#v err=%v", payload, err)
}
verifyCallOutput := &minutesE2ECaller{
responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["a"]}}`}},
failAt: map[string]int{"minutes/list_my_hotwords": 2},
}
if err := runMinutesAlignmentCLIWithWriter(t, verifyCallOutput, minutesFailWriter{}, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
t.Fatal("ASR verify read output failure accepted")
}
verifyParse := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {
`{"success":true,"result":{"hotWordList":["a"]}}`, `{"success":true,"result":{}}`,
}}}
if _, _, err := runMinutesAlignmentCLI(t, verifyParse, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
t.Fatal("ASR verify parse failure accepted")
}
verifyMismatch := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {
`{"success":true,"result":{"hotWordList":["a","old"]}}`, `{"success":true,"result":{"hotWordList":["old"]}}`,
}, "minutes/delete_personal_hotword": {`{"success":true,"result":{}}`}}}
payload, output, err = runMinutesAlignmentCLI(t, verifyMismatch, "minutes", "+prepare-asr", "--words", "a", "--sync", "--yes")
if err == nil || output == "" || payload["verified"] != false {
t.Fatalf("ASR mismatch payload=%#v err=%v", payload, err)
}
syncSuccess := &minutesE2ECaller{responses: map[string][]string{
"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`, `{"success":true,"result":{"hotWordList":["a"]}}`},
"minutes/add_personal_hot_word": {`{"success":true,"result":{}}`},
"minutes/delete_personal_hotword": {`{"success":true,"result":{}}`},
}}
payload, _, err = runMinutesAlignmentCLI(t, syncSuccess, "minutes", "+prepare-asr", "--words", "a", "--sync", "--yes")
if err != nil || payload["complete"] != true {
t.Fatalf("ASR sync payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesPermissionLedgerBranchesE2E(t *testing.T) {
many := make([]string, 51)
for index := range many {
many[index] = fmt.Sprintf("m%d", index)
}
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+share", "--id", "u1", "--member-uids", strings.Join(many, ","), "--permission", "view", "--yes"); err == nil || payload != nil || output != "" {
t.Fatalf("too many members accepted payload=%#v err=%v", payload, err)
}
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--dry-run"); err == nil {
t.Fatal("unshare dry-run output failure accepted")
}
unshare := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"success":true,"result":{}}`}}}
payload, _, err := runMinutesAlignmentCLI(t, unshare, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--yes")
if err != nil || payload["complete"] != true || payload["succeeded"] != float64(1) {
t.Fatalf("unshare payload=%#v err=%v", payload, err)
}
share := &minutesE2ECaller{responses: map[string][]string{"minutes/add_member_permission": {`{"success":true,"result":{}}`}}}
payload, _, err = runMinutesAlignmentCLI(t, share, "minutes", "+share", "--id", "u1", "--member-uids", "m1", "--permission", "edit", "--cover", "--sub-resources", "Summary,Note", "--yes")
if err != nil || payload["complete"] != true {
t.Fatalf("share payload=%#v err=%v", payload, err)
}
args := share.arguments["minutes/add_member_permission"][0]
if args["coverPermission"] != "true" || len(args["roleSubResourceIds"].([]string)) != 2 {
t.Fatalf("share args=%#v", args)
}
continueFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"result":{}}`, `{"success":true,"result":{}}`}}}
payload, output, err := runMinutesAlignmentCLI(t, continueFailure, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1,m2", "--failure-policy", "continue", "--yes")
if err == nil || output == "" || payload["failed"] != float64(1) || payload["succeeded"] != float64(1) {
t.Fatalf("continue ledger payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesArtifactCollectorBranches(t *testing.T) {
success := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`},
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`},
"minutes/get_minutes_keywords": {`{"success":true,"result":{"keywords":[]}}`},
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`},
"minutes/list_minutes_todos": {`{"success":true,"result":{"actions":[]}}`},
}}
helpers.InitDepsForTest(t, success)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
bundle, failures := collectMinutesArtifactsOnce(rt, "u1", append(minutesWorkflowArtifacts, "unknown"), 10)
if len(bundle) != 5 || len(failures) != 1 {
t.Fatalf("collector bundle=%#v failures=%#v", bundle, failures)
}
for _, artifact := range []string{"basic", "summary", "keywords", "transcript", "todos"} {
t.Run("call "+artifact, func(t *testing.T) {
tool := map[string]string{
"basic": "get_minutes_basic_info", "summary": "get_minutes_ai_summary", "keywords": "get_minutes_keywords",
"transcript": "get_minutes_transcription", "todos": "list_minutes_todos",
}[artifact]
caller := &minutesE2ECaller{failAt: map[string]int{"minutes/" + tool: 1}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
got, failed := collectMinutesArtifactsOnce(rt, "u1", []string{artifact}, 1)
if len(got) != 0 || len(failed) != 1 {
t.Fatalf("got=%#v failed=%#v", got, failed)
}
})
t.Run("parse "+artifact, func(t *testing.T) {
tool := map[string]string{
"basic": "get_minutes_basic_info", "summary": "get_minutes_ai_summary", "keywords": "get_minutes_keywords",
"transcript": "get_minutes_transcription", "todos": "list_minutes_todos",
}[artifact]
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/" + tool: {`{"success":true,"result":{}}`}}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
got, failed := collectMinutesArtifactsOnce(rt, "u1", []string{artifact}, 1)
if len(got) != 0 || len(failed) != 1 {
t.Fatalf("got=%#v failed=%#v", got, failed)
}
})
}
emptySummary := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":""}}`}}}
helpers.InitDepsForTest(t, emptySummary)
rt = shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
if _, failures = collectMinutesArtifactsOnce(rt, "u1", []string{"summary"}, 1); len(failures) != 1 {
t.Fatalf("empty summary failures=%#v", failures)
}
}
func TestCrossPlatformCoverageMinutesArtifactWaitAndOutput(t *testing.T) {
success := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`}}}
helpers.InitDepsForTest(t, success)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "wait"}, ExportPack)
if _, failures, attempts := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, time.Second, 0); len(failures) != 0 || attempts != 1 {
t.Fatalf("success failures=%#v attempts=%d", failures, attempts)
}
failing := &minutesE2ECaller{failAt: map[string]int{"minutes/get_minutes_basic_info": 1}}
helpers.InitDepsForTest(t, failing)
rt = shortcut.RuntimeContextForTest(&cobra.Command{Use: "wait"}, ExportPack)
if _, failures, attempts := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, 0, 0); len(failures) != 1 || attempts != 1 {
t.Fatalf("timeout failures=%#v attempts=%d", failures, attempts)
}
cancelled := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}}
helpers.InitDepsForTest(t, cancelled)
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd := &cobra.Command{Use: "wait"}
cmd.SetContext(ctx)
rt = shortcut.RuntimeContextForTest(cmd, ExportPack)
if _, failures, _ := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, 2*time.Hour, time.Hour); len(failures) != 2 || failures[1]["artifact"] != "wait" {
t.Fatalf("cancel failures=%#v", failures)
}
cmd = &cobra.Command{Use: "wait"}
cmd.SetContext(context.Background())
rt = shortcut.RuntimeContextForTest(cmd, ExportPack)
if err := waitMinutesInterval(rt, 0); err != nil {
t.Fatalf("timer wait: %v", err)
}
cmd.SetOut(minutesFailWriter{})
if err := outputWorkflowResult(rt, map[string]any{"complete": true}, false, "", ""); err == nil {
t.Fatal("workflow output failure accepted")
}
}
func TestCrossPlatformCoverageMinutesExportPackBranchesE2E(t *testing.T) {
withCWD := func(t *testing.T) string {
t.Helper()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
work := t.TempDir()
if err := os.Chdir(work); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
return work
}
basicCaller := func() *minutesE2ECaller {
return &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`}}}
}
t.Run("collect failure", func(t *testing.T) {
withCWD(t)
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic")
if err == nil || output == "" || payload["published"] != false {
t.Fatalf("collect payload=%#v err=%v", payload, err)
}
})
t.Run("target exists", func(t *testing.T) {
work := withCWD(t)
if err := os.Mkdir(filepath.Join(work, "pack"), 0o700); err != nil {
t.Fatal(err)
}
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("existing export accepted")
}
})
t.Run("mkdir temp", func(t *testing.T) {
withCWD(t)
testseam.Swap(t, &minutesMkdirTemp, func(string, string) (string, error) { return "", errors.New("mkdir temp") })
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("mkdir temp failure accepted")
}
})
t.Run("summary write", func(t *testing.T) {
withCWD(t)
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "summary"); err == nil {
t.Fatal("summary write failure accepted")
}
})
t.Run("json write", func(t *testing.T) {
withCWD(t)
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("JSON write failure accepted")
}
})
t.Run("stat", func(t *testing.T) {
withCWD(t)
testseam.Swap(t, &minutesStat, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("stat failure accepted")
}
})
for _, test := range []struct {
name string
responses map[string][]string
failAt map[string]int
download func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error)
wantError bool
}{
{name: "media call", failAt: map[string]int{"minutes/query_minutes_audio_url": 1}, wantError: true},
{name: "media parse", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{}}`}}, wantError: true},
{name: "media download", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/a.mp3"}}`}}, download: func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("download")
}, wantError: true},
{name: "media success", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/a.mp3"}}`}}, download: func(_ context.Context, _ string, options localio.DownloadOptions) (localio.DownloadResult, error) {
path := filepath.Join(options.BaseDir, "media", "u1.mp3")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return localio.DownloadResult{}, err
}
if err := os.WriteFile(path, []byte("media"), 0o600); err != nil {
return localio.DownloadResult{}, err
}
return localio.DownloadResult{RelativePath: "media/u1.mp3", SizeBytes: 5}, nil
}},
} {
t.Run(test.name, func(t *testing.T) {
withCWD(t)
if test.download != nil {
testseam.Swap(t, &minutesDownload, test.download)
}
responses := map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`}}
for key, values := range test.responses {
responses[key] = values
}
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: responses, failAt: test.failAt}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic", "--include-media")
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if !test.wantError && payload["published"] != true {
t.Fatalf("payload=%#v", payload)
}
})
}
t.Run("manifest write", func(t *testing.T) {
withCWD(t)
realWrite := minutesWriteFile
testseam.Swap(t, &minutesWriteFile, func(path string, data []byte, mode os.FileMode) error {
if filepath.Base(path) == "manifest.json" {
return errors.New("manifest")
}
return realWrite(path, data, mode)
})
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("manifest failure accepted")
}
})
t.Run("rename", func(t *testing.T) {
withCWD(t)
testseam.Swap(t, &minutesRename, func(string, string) error { return errors.New("rename") })
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("rename failure accepted")
}
})
t.Run("output", func(t *testing.T) {
withCWD(t)
if err := runMinutesAlignmentCLIWithWriter(t, basicCaller(), minutesFailWriter{}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("export output failure accepted")
}
})
}
func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) {
if _, _, _, err := prepareExportTarget("../escape"); err == nil {
t.Fatal("unsafe target accepted")
}
for _, test := range []struct {
name string
run func(*testing.T)
}{
{name: "getwd", run: func(t *testing.T) {
testseam.Swap(t, &minutesGetwd, func() (string, error) { return "", errors.New("getwd") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("getwd failure accepted")
}
}},
{name: "eval", run: func(t *testing.T) {
testseam.Swap(t, &minutesEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("eval failure accepted")
}
}},
{name: "rel", run: func(t *testing.T) {
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "", errors.New("rel") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("rel failure accepted")
}
}},
{name: "escape", run: func(t *testing.T) {
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "../escape", nil })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("rel escape accepted")
}
}},
{name: "target lstat", run: func(t *testing.T) {
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("lstat") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("lstat failure accepted")
}
}},
{name: "parent", run: func(t *testing.T) {
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "parent/pack", nil })
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("parent") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("unsafe parent accepted")
}
}},
} {
t.Run(test.name, test.run)
}
base := t.TempDir()
if err := ensureExportParent(base, "."); err != nil {
t.Fatal(err)
}
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &minutesMkdir, func(string, os.FileMode) error { return errors.New("mkdir") })
if err := ensureExportParent(base, "a"); err == nil {
t.Fatal("mkdir failure accepted")
}
})
t.Run("lstat", func(t *testing.T) {
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("lstat") })
if err := ensureExportParent(base, "a"); err == nil {
t.Fatal("lstat failure accepted")
}
})
t.Run("file", func(t *testing.T) {
path := filepath.Join(base, "file")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := ensureExportParent(base, "file/child"); err == nil {
t.Fatal("file parent accepted")
}
})
t.Run("create", func(t *testing.T) {
if err := ensureExportParent(base, "created/nested"); err != nil {
t.Fatal(err)
}
})
t.Run("marshal", func(t *testing.T) {
testseam.Swap(t, &minutesMarshalIndent, func(any, string, string) ([]byte, error) { return nil, errors.New("marshal") })
if err := writeJSONFile(filepath.Join(t.TempDir(), "x.json"), map[string]any{}); err == nil {
t.Fatal("marshal failure accepted")
}
})
t.Run("write", func(t *testing.T) {
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
if err := writeJSONFile(filepath.Join(t.TempDir(), "x.json"), map[string]any{}); err == nil {
t.Fatal("write failure accepted")
}
})
path := filepath.Join(t.TempDir(), "ok.json")
if err := writeJSONFile(path, map[string]any{"ok": true}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil || !bytes.HasSuffix(raw, []byte("\n")) || !json.Valid(raw) {
t.Fatalf("JSON output=%q err=%v", raw, err)
}
}
@@ -0,0 +1,290 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import (
"encoding/json"
"math"
"strings"
"testing"
"time"
)
func TestCrossPlatformCoverageMinutesResponseCompletion(t *testing.T) {
for _, data := range []map[string]any{
{},
{"success": false, "message": "denied"},
{"errorCode": "E", "msg": "bad"},
{"dingOpenErrcode": float64(1)},
} {
if err := ValidateEnvelope(data); err == nil {
t.Fatalf("invalid envelope accepted: %#v", data)
}
}
for _, value := range []any{nil, "", "0", float64(0), 0, int64(0), json.Number("0")} {
if !isZeroCode(value) {
t.Fatalf("zero code rejected: %#v", value)
}
}
if isZeroCode(true) {
t.Fatal("unsupported code treated as zero")
}
if got := envelopeMessage(map[string]any{}); got != "no backend error message" {
t.Fatalf("fallback message = %q", got)
}
list, err := ParseListPage(map[string]any{
"hasNext": true, "nextCursor": "outer",
"data": map[string]any{"result": map[string]any{"records": []any{map[string]any{"taskUUID": "u1"}}}},
})
if err != nil || !list.HasMoreKnown || list.NextToken != "outer" || len(list.Items) != 1 {
t.Fatalf("nested list = %#v, %v", list, err)
}
if _, err := ParseListPage(map[string]any{"result": map[string]any{"items": []any{map[string]any{}, "bad"}}}); err == nil {
t.Fatal("non-object list item accepted")
}
transcript, err := ParseTranscriptPage(map[string]any{
"hasMore": false,
"data": map[string]any{"data": map[string]any{"paragraphList": []any{map[string]any{"sentenceId": "p"}}}},
})
if err != nil || !transcript.HasMoreKnown || transcript.HasMore || len(transcript.Items) != 1 {
t.Fatalf("nested transcript = %#v, %v", transcript, err)
}
if _, err := ParseTranscriptPage(map[string]any{"result": map[string]any{"paragraphList": []any{"bad"}}}); err == nil {
t.Fatal("non-object paragraph accepted")
}
for _, tc := range []struct {
name string
data map[string]any
}{
{"no result", map[string]any{"success": true}},
{"basic missing", map[string]any{"result": map[string]any{}}},
{"basic mismatch", map[string]any{"result": map[string]any{"taskUuid": "other"}}},
{"summary missing", map[string]any{"result": map[string]any{}}},
{"keywords wrong", map[string]any{"result": map[string]any{"keywords": "bad"}}},
{"todos wrong", map[string]any{"result": map[string]any{"actions": "bad", "dingtalkTodoList": "bad"}}},
{"other empty", map[string]any{"result": map[string]any{}}},
} {
artifact := map[string]string{"no result": "basic", "basic missing": "basic", "basic mismatch": "basic", "summary missing": "summary", "keywords wrong": "keywords", "todos wrong": "todos", "other empty": "custom"}[tc.name]
if err := ValidateArtifact(artifact, "u1", tc.data); err == nil {
t.Fatalf("%s accepted", tc.name)
}
}
if err := ValidateArtifact("basic", "u1", map[string]any{}); err == nil {
t.Fatal("artifact envelope failure ignored")
}
if err := ValidateArtifact("todos", "", map[string]any{"result": map[string]any{"dingtalkTodoList": []any{}}}); err != nil {
t.Fatal(err)
}
if err := ValidateArtifact("custom", "", map[string]any{"result": map[string]any{"value": true}}); err != nil {
t.Fatal(err)
}
if _, err := Basic("u1", map[string]any{"result": map[string]any{}}); err == nil {
t.Fatal("invalid basic accepted")
}
if basic, err := Basic("u1", map[string]any{"result": map[string]any{"uuid": "u1"}}); err != nil || basic["uuid"] != "u1" {
t.Fatalf("basic = %#v, %v", basic, err)
}
if _, err := SummaryText(map[string]any{"result": map[string]any{}}); err == nil {
t.Fatal("invalid summary accepted")
}
if _, err := SummaryText(map[string]any{"result": map[string]any{"fullSummary": 1}}); err == nil {
t.Fatal("non-string summary accepted")
}
if text, err := SummaryText(map[string]any{"result": map[string]any{"fullSummary": "ok"}}); err != nil || text != "ok" {
t.Fatalf("summary = %q, %v", text, err)
}
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}} {
if _, err := MediaURL(data); err == nil {
t.Fatalf("invalid media accepted: %#v", data)
}
}
if value, err := MediaURL(map[string]any{"result": map[string]any{"audioUrl": " https://example.invalid/a "}}); err != nil || value == "" {
t.Fatalf("media = %q, %v", value, err)
}
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{"sessionId": "s"}}} {
if _, _, err := UploadSession(data); err == nil {
t.Fatalf("invalid upload session accepted: %#v", data)
}
}
if session, url, err := UploadSession(map[string]any{"result": map[string]any{"uploadId": 7, "uploadUrl": "https://example.invalid"}}); err != nil || session != "7" || url == "" {
t.Fatalf("upload session = %q/%q, %v", session, url, err)
}
if _, err := CompletedTaskUUID(map[string]any{}); err == nil {
t.Fatal("invalid completed envelope accepted")
}
if _, err := CompletedTaskUUID(map[string]any{"result": map[string]any{}}); err == nil {
t.Fatal("missing completed UUID accepted")
}
if id, err := CompletedTaskUUID(map[string]any{"data": map[string]any{"result": map[string]any{"task_uuid": "done"}}}); err != nil || id != "done" {
t.Fatalf("completed UUID = %q, %v", id, err)
}
rows, err := ProjectList(Page{Items: []map[string]any{{
"uuid": "u", "name": "n", "creatorNick": "c", "createTime": 1,
"deadline": 2, "link": "l", "state": "ready",
}}})
if err != nil || rows[0]["title"] != "n" || rows[0]["creator"] != "c" || rows[0]["url"] != "l" {
t.Fatalf("projected fallback fields = %#v, %v", rows, err)
}
if got := TaskUUID(map[string]any{"taskUUID": json.Number("9")}); got != "9" {
t.Fatalf("numeric task UUID = %q", got)
}
if id, err := LatestTaskUUID(Page{}); err != nil || id != "" {
t.Fatalf("empty latest = %q, %v", id, err)
}
if _, err := LatestTaskUUID(Page{Items: []map[string]any{{"startTime": 1}}}); err == nil {
t.Fatal("latest missing UUID accepted")
}
latest, err := LatestTaskUUID(Page{Items: []map[string]any{
{"uuid": "skip", "createTime": "bad"},
{"uuid": "int", "createTime": 1},
{"uuid": "int64", "gmtCreate": int64(2)},
{"uuid": "number", "startTime": json.Number("3")},
{"uuid": "rfc", "createTimeStart": time.Now().UTC().Format(time.RFC3339Nano)},
}})
if err != nil || latest != "rfc" {
t.Fatalf("typed latest = %q, %v", latest, err)
}
if key, err := StableItemKey(map[string]any{"text": "same"}); err != nil || !strings.HasPrefix(key, "json:") {
t.Fatalf("JSON stable key = %q, %v", key, err)
}
if _, err := StableItemKey(map[string]any{"bad": func() {}}); err == nil {
t.Fatal("unencodable stable key accepted")
}
for _, value := range []any{"value", json.Number("4"), float64(5), 6, int64(7)} {
if got := stringField(map[string]any{"x": value}, "missing", "x"); got == "" {
t.Fatalf("stringField(%#v) empty", value)
}
}
if got := stringField(map[string]any{"x": true}, "x"); got != "" {
t.Fatalf("unsupported string field = %q", got)
}
for _, value := range []any{float64(1), 2, int64(3), json.Number("4"), "5", "2026-08-11T00:00:00Z"} {
if _, ok := timestamp(map[string]any{"x": value}, "x"); !ok {
t.Fatalf("timestamp(%#v) rejected", value)
}
}
if _, ok := timestamp(map[string]any{"x": json.Number("bad")}, "x"); ok {
t.Fatal("invalid timestamp number accepted")
}
}
func TestCrossPlatformCoverageMinutesPagerCompletion(t *testing.T) {
if _, err := CollectTranscript(nil, "", false, 1); err == nil {
t.Fatal("nil transcript caller accepted")
}
defaulted, err := CollectTranscript(func(string) (map[string]any, error) {
return map[string]any{"result": map[string]any{"paragraphList": []any{}}}, nil
}, "", false, 0)
if err != nil || !defaulted.Complete {
t.Fatalf("default max pages = %#v, %v", defaulted, err)
}
invalid, err := CollectTranscript(func(string) (map[string]any, error) { return map[string]any{}, nil }, "", false, 1)
if err == nil || invalid.FailurePage != 1 || invalid.FailureMessage == "" {
t.Fatalf("invalid page = %#v, %v", invalid, err)
}
badKey, err := CollectTranscript(func(string) (map[string]any, error) {
return map[string]any{"result": map[string]any{"paragraphList": []any{map[string]any{"bad": func() {}}}}}, nil
}, "", false, 1)
if err == nil || badKey.FailurePage != 1 {
t.Fatalf("bad key = %#v, %v", badKey, err)
}
one, err := CollectTranscript(func(string) (map[string]any, error) {
return map[string]any{"result": map[string]any{"paragraphList": []any{}, "hasNext": false}}, nil
}, "", true, 1)
if err != nil || !one.Complete {
t.Fatalf("complete single page = %#v, %v", one, err)
}
limited, err := CollectTranscript(func(token string) (map[string]any, error) {
return map[string]any{"result": map[string]any{"paragraphList": []any{}, "hasNext": true, "nextToken": token + "n"}}, nil
}, "", false, 1)
if err == nil || limited.FailurePage != 2 || limited.FailureMessage == "" {
t.Fatalf("page limit = %#v, %v", limited, err)
}
payload := TranscriptPayload("u", "1", TranscriptResult{
Paragraphs: []map[string]any{{"id": "p"}}, Pages: 1, Duplicates: 2,
NextToken: "n", FailurePage: 2, FailureMessage: "failed",
})
if payload["nextToken"] != "n" || payload["failurePage"] != 2 || payload["failure"] != "failed" {
t.Fatalf("transcript payload = %#v", payload)
}
}
func TestCrossPlatformCoverageMinutesWorkflowCompletion(t *testing.T) {
for _, data := range []map[string]any{{}, {"other": true}, {"success": "yes"}, {"success": false}} {
if err := RequireWriteAcknowledgement("write", data); err == nil {
t.Fatalf("invalid acknowledgement accepted: %#v", data)
}
}
if err := RequireWriteAcknowledgement("write", map[string]any{"result": map[string]any{"updated": true}}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
cmd, id string
data map[string]any
}{
{"pause", "u", map[string]any{}},
{"pause", "u", map[string]any{"success": true}},
{"pause", "u", map[string]any{"success": true, "result": map[string]any{"cmd": "resume", "uuid": "u"}}},
{"pause", "u", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "other"}}},
} {
if _, err := RecordResult(tc.cmd, tc.id, tc.data); err == nil {
t.Fatalf("invalid record accepted: %#v", tc)
}
}
if _, err := RecordResult("create", "", map[string]any{"success": true, "result": map[string]any{"cmd": "create"}}); err != nil {
t.Fatal(err)
}
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}, {"result": map[string]any{"taskStatus": 3}}} {
if _, _, err := MindGraphStatus(data); err == nil {
t.Fatalf("invalid mind graph accepted: %#v", data)
}
}
for _, value := range []any{0, "1", json.Number("2")} {
status, _, err := MindGraphStatus(map[string]any{"result": map[string]any{"taskStatus": value}})
if err != nil || status < 0 || status > 2 {
t.Fatalf("mind status %#v = %d, %v", value, status, err)
}
}
for _, data := range []map[string]any{{}, {"success": true}, {"success": true, "result": map[string]any{}}} {
if _, _, err := SpeakerSummaryTask(data); err == nil {
t.Fatalf("invalid speaker task accepted: %#v", data)
}
}
if id, status, err := SpeakerSummaryTask(map[string]any{"result": map[string]any{"taskID": int64(7), "status": "READY"}}); err != nil || id != "7" || status != "ready" {
t.Fatalf("speaker task = %q/%q, %v", id, status, err)
}
for _, data := range []map[string]any{{}, {"result": nil}, {"result": []any(nil)}, {"result": "bad"}} {
if _, err := SpeakerSummaryResult(data); err == nil {
t.Fatalf("invalid speaker result accepted: %#v", data)
}
}
if value, err := SpeakerSummaryResult(map[string]any{"result": []any{}}); err != nil || value == nil {
t.Fatalf("empty speaker list = %#v, %v", value, err)
}
if value, err := SpeakerSummaryResult(map[string]any{"result": map[string]any{"summary": "ok"}}); err != nil || value == nil {
t.Fatalf("speaker map = %#v, %v", value, err)
}
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}, {"result": map[string]any{"hotWordList": "bad"}}, {"result": map[string]any{"hotWordList": []any{true}}}} {
if _, err := HotWords(data); err == nil {
t.Fatalf("invalid hot words accepted: %#v", data)
}
}
words, err := HotWords(map[string]any{"result": map[string]any{"hotWordList": []any{" A ", "A", map[string]any{"content": "B"}}}})
if err != nil || len(words) != 2 {
t.Fatalf("deduplicated hot words = %#v, %v", words, err)
}
for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(-1), float64(3), json.Number("bad"), "bad", true} {
if _, err := intValue(value); err == nil {
t.Fatalf("invalid int value accepted: %#v", value)
}
}
if _, err := intValue(int64(1)); err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("unsupported int64 result = %v", err)
}
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import "fmt"
// TranscriptCaller fetches one transcript page for the supplied nextToken.
// The empty token means the first page.
type TranscriptCaller func(nextToken string) (map[string]any, error)
// TranscriptResult is the complete or partial result of transcript pagination.
type TranscriptResult struct {
Paragraphs []map[string]any
Pages int
Duplicates int
Complete bool
NextToken string
FailurePage int
FailureMessage string
}
// CollectTranscript follows hasNext/nextToken, de-duplicates paragraphs and
// fails closed on cursor drift. When a later call fails it returns the partial
// paragraphs plus the error so callers can publish an honest recovery ledger.
func CollectTranscript(call TranscriptCaller, initialToken string, singlePage bool, maxPages int) (TranscriptResult, error) {
if call == nil {
return TranscriptResult{}, fmt.Errorf("minutes transcript caller is nil")
}
if maxPages <= 0 {
maxPages = 1000
}
result := TranscriptResult{Complete: false, NextToken: initialToken}
seenTokens := map[string]bool{}
seenItems := map[string]bool{}
token := initialToken
for pageIndex := 1; pageIndex <= maxPages; pageIndex++ {
if seenTokens[token] {
result.NextToken = token
result.FailurePage = pageIndex
result.FailureMessage = "cursor stalled or cycled"
return result, fmt.Errorf("minutes transcript cursor stalled or cycled at page %d", pageIndex)
}
seenTokens[token] = true
data, err := call(token)
if err != nil {
result.NextToken = token
result.FailurePage = pageIndex
result.FailureMessage = err.Error()
return result, fmt.Errorf("fetch minutes transcript page %d: %w", pageIndex, err)
}
page, err := ParseTranscriptPage(data)
if err != nil {
result.NextToken = token
result.FailurePage = pageIndex
result.FailureMessage = err.Error()
return result, fmt.Errorf("validate minutes transcript page %d: %w", pageIndex, err)
}
result.Pages++
for _, item := range page.Items {
key, err := StableItemKey(item)
if err != nil {
result.FailurePage = pageIndex
result.FailureMessage = err.Error()
return result, err
}
if seenItems[key] {
result.Duplicates++
continue
}
seenItems[key] = true
result.Paragraphs = append(result.Paragraphs, item)
}
result.NextToken = page.NextToken
if singlePage {
result.Complete = !page.HasMore
return result, nil
}
if !page.HasMore {
result.Complete = true
result.NextToken = ""
return result, nil
}
token = page.NextToken
}
result.FailurePage = maxPages + 1
result.FailureMessage = "page safety limit exceeded"
return result, fmt.Errorf("minutes transcript exceeded page safety limit %d", maxPages)
}
// TranscriptPayload converts a pagination result into the stable public view.
func TranscriptPayload(taskUUID, direction string, result TranscriptResult) map[string]any {
paragraphs := make([]any, 0, len(result.Paragraphs))
for _, paragraph := range result.Paragraphs {
paragraphs = append(paragraphs, paragraph)
}
payload := map[string]any{
"taskUuid": taskUUID,
"direction": direction,
"complete": result.Complete,
"pages": result.Pages,
"paragraphCount": len(paragraphs),
"duplicateCount": result.Duplicates,
"paragraphList": paragraphs,
}
if result.NextToken != "" {
payload["nextToken"] = result.NextToken
}
if result.FailurePage > 0 {
payload["failurePage"] = result.FailurePage
}
if result.FailureMessage != "" {
payload["failure"] = result.FailureMessage
}
return payload
}
@@ -0,0 +1,51 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import (
"errors"
"fmt"
"testing"
)
func TestCrossPlatformCoverageTranscriptCollectsPagesAndDeduplicatesE2E(t *testing.T) {
calls := []string{}
result, err := CollectTranscript(func(token string) (map[string]any, error) {
calls = append(calls, token)
if token == "" {
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"},{"paragraphId":"p2"}],"hasNext":true,"nextToken":"n2"}}`), nil
}
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p2"},{"paragraphId":"p3"}],"hasNext":false,"nextToken":""}}`), nil
}, "", false, 10)
if err != nil || !result.Complete || result.Pages != 2 || len(result.Paragraphs) != 3 || result.Duplicates != 1 || fmt.Sprint(calls) != "[ n2]" {
t.Fatalf("collect = %#v calls=%v err=%v", result, calls, err)
}
}
func TestCrossPlatformCoverageTranscriptPartialReadNeverBecomesSuccessE2E(t *testing.T) {
result, err := CollectTranscript(func(token string) (map[string]any, error) {
if token == "" {
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`), nil
}
return nil, errors.New("backend failed")
}, "", false, 10)
if err == nil || result.Complete || result.Pages != 1 || len(result.Paragraphs) != 1 || result.FailurePage != 2 {
t.Fatalf("partial read = %#v err=%v", result, err)
}
}
func TestCrossPlatformCoverageTranscriptCursorCycleAndSinglePageE2E(t *testing.T) {
cycle, err := CollectTranscript(func(string) (map[string]any, error) {
return decode(t, `{"result":{"paragraphList":[],"hasNext":true,"nextToken":"same"}}`), nil
}, "same", false, 10)
if err == nil || cycle.Complete {
t.Fatalf("cursor cycle = %#v err=%v", cycle, err)
}
one, err := CollectTranscript(func(string) (map[string]any, error) {
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`), nil
}, "", true, 10)
if err != nil || one.Complete || one.Pages != 1 || one.NextToken != "n2" {
t.Fatalf("single page = %#v err=%v", one, err)
}
}
+441
View File
@@ -0,0 +1,441 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package minutesdata owns response validation and pagination facts shared by
// Minutes shortcuts. A missing or malformed collection is never treated as an
// empty successful result.
package minutesdata
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// Page is one validated Minutes collection page.
type Page struct {
Items []map[string]any
HasMore bool
HasMoreKnown bool
NextToken string
}
var listKeys = []string{"itemList", "minutesList", "items", "list", "records", "dataList"}
// ParseListPage validates list_by_keyword_and_time_range and returns its
// explicit collection. An explicit [] is success; an absent/null/wrongly typed
// collection is a contract error.
func ParseListPage(data map[string]any) (Page, error) {
if err := validateEnvelope(data); err != nil {
return Page{}, err
}
containers := collectionContainers(data)
for _, container := range containers {
for _, key := range listKeys {
value, exists := container[key]
if !exists {
continue
}
items, err := mapItems(value, key)
if err != nil {
return Page{}, err
}
hasMore, known := boolField(container, "hasMore", "hasNext")
if !known {
hasMore, known = boolField(data, "hasMore", "hasNext")
}
next := stringField(container, "nextToken", "nextCursor", "pageToken")
if next == "" {
next = stringField(data, "nextToken", "nextCursor", "pageToken")
}
if hasMore && next == "" {
return Page{}, fmt.Errorf("minutes list declares another page but omits nextToken")
}
return Page{Items: items, HasMore: hasMore, HasMoreKnown: known, NextToken: next}, nil
}
}
return Page{}, fmt.Errorf("minutes list response has no recognized collection (want result.itemList)")
}
// ParseTranscriptPage validates get_minutes_transcription. The real response
// collection is result.paragraphList and pagination uses hasNext/nextToken.
func ParseTranscriptPage(data map[string]any) (Page, error) {
if err := validateEnvelope(data); err != nil {
return Page{}, err
}
containers := collectionContainers(data)
for _, container := range containers {
value, exists := container["paragraphList"]
if !exists {
continue
}
items, err := mapItems(value, "paragraphList")
if err != nil {
return Page{}, err
}
hasMore, known := boolField(container, "hasNext", "hasMore")
if !known {
hasMore, known = boolField(data, "hasNext", "hasMore")
}
next := stringField(container, "nextToken", "nextCursor")
if next == "" {
next = stringField(data, "nextToken", "nextCursor")
}
if hasMore && next == "" {
return Page{}, fmt.Errorf("minutes transcript declares another page but omits nextToken")
}
return Page{Items: items, HasMore: hasMore, HasMoreKnown: known, NextToken: next}, nil
}
return Page{}, fmt.Errorf("minutes transcript response has no result.paragraphList collection")
}
// ValidateArtifact rejects successful-looking but structurally empty artifact
// responses. This is intentionally artifact-specific: a generic {} cannot
// prove that the requested Minutes business data was returned.
func ValidateArtifact(name, taskUUID string, data map[string]any) error {
if err := validateEnvelope(data); err != nil {
return err
}
result, ok := data["result"].(map[string]any)
if !ok {
return fmt.Errorf("minutes %s response has no result object", name)
}
switch name {
case "basic":
actual := TaskUUID(result)
if actual == "" {
return fmt.Errorf("minutes basic response has no task UUID")
}
if taskUUID != "" && actual != taskUUID {
return fmt.Errorf("minutes basic response task UUID mismatch")
}
case "summary":
if _, exists := result["fullSummary"]; !exists {
return fmt.Errorf("minutes summary response has no fullSummary field")
}
case "keywords":
if _, err := mapSliceField(result, "keywords"); err != nil {
return fmt.Errorf("minutes keywords response: %w", err)
}
case "todos":
if _, actionsErr := mapSliceField(result, "actions"); actionsErr != nil {
if _, todosErr := mapSliceField(result, "dingtalkTodoList"); todosErr != nil {
return fmt.Errorf("minutes todos response has neither actions nor dingtalkTodoList array")
}
}
default:
if len(result) == 0 {
return fmt.Errorf("minutes %s response result is empty", name)
}
}
return nil
}
// ValidateEnvelope exposes the common backend failure check for composite
// write/read steps that apply their own operation-specific shape validation.
func ValidateEnvelope(data map[string]any) error {
return validateEnvelope(data)
}
// Basic returns the validated basic-info result.
func Basic(taskUUID string, data map[string]any) (map[string]any, error) {
if err := ValidateArtifact("basic", taskUUID, data); err != nil {
return nil, err
}
return data["result"].(map[string]any), nil
}
// SummaryText returns the full summary, including an explicitly empty string.
func SummaryText(data map[string]any) (string, error) {
if err := ValidateArtifact("summary", "", data); err != nil {
return "", err
}
value, ok := data["result"].(map[string]any)["fullSummary"].(string)
if !ok {
return "", fmt.Errorf("minutes summary fullSummary has type %T, want string", data["result"].(map[string]any)["fullSummary"])
}
return value, nil
}
// MediaURL returns the actual audio/video download URL. An empty result is not
// accepted as "no media" because it cannot distinguish processing, expiry and
// permission failures.
func MediaURL(data map[string]any) (string, error) {
if err := validateEnvelope(data); err != nil {
return "", err
}
result, ok := data["result"].(map[string]any)
if !ok {
return "", fmt.Errorf("minutes media response has no result object")
}
value := stringField(result, "videoUrl", "audioUrl", "downloadUrl", "url")
if value == "" {
return "", fmt.Errorf("minutes media response has no audio/video URL")
}
return value, nil
}
// UploadSession extracts the two facts required to continue a local upload.
func UploadSession(data map[string]any) (sessionID, presignedURL string, err error) {
if err := validateEnvelope(data); err != nil {
return "", "", err
}
result, ok := data["result"].(map[string]any)
if !ok {
return "", "", fmt.Errorf("minutes upload create response has no result object")
}
sessionID = stringField(result, "sessionId", "uploadId")
presignedURL = stringField(result, "presignedUrl", "uploadUrl")
if sessionID == "" || presignedURL == "" {
return "", "", fmt.Errorf("minutes upload create response is missing sessionId or presignedUrl")
}
return sessionID, presignedURL, nil
}
// CompletedTaskUUID extracts the created Minutes task UUID from upload complete.
func CompletedTaskUUID(data map[string]any) (string, error) {
if err := validateEnvelope(data); err != nil {
return "", err
}
containers := collectionContainers(data)
for _, container := range containers {
if uuid := TaskUUID(container); uuid != "" {
return uuid, nil
}
}
return "", fmt.Errorf("minutes upload complete response has no task UUID")
}
// ProjectList converts validated list items into the stable Shortcut view.
// Every row must carry a real task UUID; malformed rows fail the entire page
// instead of disappearing and masquerading as an empty result.
func ProjectList(page Page) ([]map[string]any, error) {
out := make([]map[string]any, 0, len(page.Items))
for index, item := range page.Items {
uuid := TaskUUID(item)
if uuid == "" {
return nil, fmt.Errorf("minutes list item %d has no task UUID", index)
}
row := map[string]any{"taskUuid": uuid}
copyFirst(row, "title", item, "title", "name")
copyFirst(row, "creator", item, "creator", "creatorName", "createUserName", "creatorNick")
copyFirst(row, "startTime", item, "startTime", "gmtStart", "beginTime", "createTime")
copyFirst(row, "endTime", item, "endTime", "gmtEnd", "deadline")
copyFirst(row, "url", item, "url", "shareUrl", "link")
copyFirst(row, "status", item, "status", "taskStatus", "state")
out = append(out, row)
}
return out, nil
}
// TaskUUID returns only spellings known to identify a Minutes task. A generic
// id/minutesId is deliberately not accepted because record-control consumes
// this value directly as the task UUID.
func TaskUUID(item map[string]any) string {
return stringField(item, "taskUuid", "taskUUID", "task_uuid", "uuid")
}
// LatestTaskUUID selects the newest item by an explicit comparable timestamp.
// It never silently assumes that an undocumented response order is newest-first.
func LatestTaskUUID(page Page) (string, error) {
if len(page.Items) == 0 {
return "", nil
}
bestUUID := ""
var bestTime float64
haveTime := false
for index, item := range page.Items {
uuid := TaskUUID(item)
if uuid == "" {
return "", fmt.Errorf("minutes list item %d has no task UUID", index)
}
value, ok := timestamp(item, "createTime", "gmtCreate", "startTime", "createTimeStart")
if !ok {
continue
}
if !haveTime || value > bestTime {
haveTime = true
bestTime = value
bestUUID = uuid
}
}
if !haveTime {
return "", fmt.Errorf("minutes list is non-empty but no item has a comparable creation/start time")
}
return bestUUID, nil
}
// StableItemKey returns a deterministic de-duplication key for a transcript
// paragraph. Stable IDs win; otherwise the canonical JSON object is used.
func StableItemKey(item map[string]any) (string, error) {
if id := stringField(item, "paragraphId", "sentenceId", "segmentId", "id"); id != "" {
return "id:" + id, nil
}
raw, err := json.Marshal(item)
if err != nil {
return "", fmt.Errorf("encode transcript paragraph key: %w", err)
}
return "json:" + string(raw), nil
}
func validateEnvelope(data map[string]any) error {
if len(data) == 0 {
return fmt.Errorf("minutes response is empty")
}
if success, ok := data["success"].(bool); ok && !success {
return fmt.Errorf("minutes backend reported failure: %s", envelopeMessage(data))
}
for _, key := range []string{"errorCode", "dingOpenErrcode"} {
value, exists := data[key]
if !exists || isZeroCode(value) {
continue
}
return fmt.Errorf("minutes backend reported %s=%v: %s", key, value, envelopeMessage(data))
}
return nil
}
func collectionContainers(data map[string]any) []map[string]any {
containers := []map[string]any{data}
for _, key := range []string{"result", "data"} {
if inner, ok := data[key].(map[string]any); ok {
containers = append(containers, inner)
for _, nestedKey := range []string{"result", "data"} {
if nested, ok := inner[nestedKey].(map[string]any); ok {
containers = append(containers, nested)
}
}
}
}
return containers
}
func mapItems(value any, field string) ([]map[string]any, error) {
raw, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("minutes response field %s has type %T, want array", field, value)
}
items := make([]map[string]any, 0, len(raw))
for index, value := range raw {
item, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("minutes response field %s item %d has type %T, want object", field, index, value)
}
items = append(items, item)
}
return items, nil
}
func mapSliceField(data map[string]any, field string) ([]any, error) {
value, exists := data[field]
if !exists {
return nil, fmt.Errorf("missing %s", field)
}
items, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("field %s has type %T, want array", field, value)
}
return items, nil
}
func copyFirst(dst map[string]any, dstKey string, src map[string]any, keys ...string) {
for _, key := range keys {
if value, ok := src[key]; ok && value != nil {
dst[dstKey] = value
return
}
}
}
func boolField(data map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
if value, ok := data[key].(bool); ok {
return value, true
}
}
return false, false
}
func stringField(data map[string]any, keys ...string) string {
for _, key := range keys {
value, exists := data[key]
if !exists || value == nil {
continue
}
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) != "" {
return strings.TrimSpace(typed)
}
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
}
}
return ""
}
func timestamp(data map[string]any, keys ...string) (float64, bool) {
for _, key := range keys {
value, exists := data[key]
if !exists || value == nil {
continue
}
switch typed := value.(type) {
case float64:
return typed, true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case json.Number:
if parsed, err := typed.Float64(); err == nil {
return parsed, true
}
case string:
if parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil {
return parsed, true
}
if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(typed)); err == nil {
return float64(parsed.UnixNano()), true
}
}
}
return 0, false
}
func isZeroCode(value any) bool {
switch typed := value.(type) {
case nil:
return true
case string:
trimmed := strings.TrimSpace(typed)
return trimmed == "" || trimmed == "0"
case float64:
return typed == 0
case int:
return typed == 0
case int64:
return typed == 0
case json.Number:
return typed.String() == "0"
default:
return false
}
}
func envelopeMessage(data map[string]any) string {
for _, key := range []string{"errorMsg", "message", "msg"} {
if value := stringField(data, key); value != "" {
return value
}
}
return "no backend error message"
}
@@ -0,0 +1,113 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import (
"encoding/json"
"strings"
"testing"
)
func decode(t *testing.T, raw string) map[string]any {
t.Helper()
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("decode fixture: %v", err)
}
return data
}
func TestCrossPlatformCoverageListExplicitEmptyAndRealItemListE2E(t *testing.T) {
empty, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[],"hasMore":false,"nextToken":""}}`))
if err != nil || len(empty.Items) != 0 || empty.HasMore {
t.Fatalf("explicit empty = %#v, %v", empty, err)
}
page, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[{"uuid":"u1","title":"n","startTime":"2026-08-10T01:00:00Z"}],"hasMore":true,"nextToken":"next"}}`))
if err != nil || len(page.Items) != 1 || !page.HasMore || page.NextToken != "next" {
t.Fatalf("real itemList = %#v, %v", page, err)
}
rows, err := ProjectList(page)
if err != nil || len(rows) != 1 || rows[0]["taskUuid"] != "u1" {
t.Fatalf("projection = %#v, %v", rows, err)
}
}
func TestCrossPlatformCoverageListUnknownOrMalformedIsNotEmptySuccessE2E(t *testing.T) {
tests := []string{
`{}`,
`{"success":true}`,
`{"success":false,"errorMsg":"denied"}`,
`{"success":true,"result":{"itemList":null}}`,
`{"success":true,"result":{"itemList":{}}}`,
`{"success":true,"result":{"itemList":["bad"]}}`,
`{"success":true,"result":{"hasMore":true,"itemList":[]}}`,
}
for _, raw := range tests {
if page, err := ParseListPage(decode(t, raw)); err == nil {
t.Fatalf("unknown response accepted as %#v: %s", page, raw)
}
}
page, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[{"title":"missing uuid"}]}}`))
if err != nil {
t.Fatalf("parse list envelope: %v", err)
}
if rows, err := ProjectList(page); err == nil || rows != nil {
t.Fatalf("malformed item projected: %#v, %v", rows, err)
}
}
func TestCrossPlatformCoverageTranscriptShapeAndPaginationContractE2E(t *testing.T) {
page, err := ParseTranscriptPage(decode(t, `{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`))
if err != nil || len(page.Items) != 1 || !page.HasMore || page.NextToken != "n2" {
t.Fatalf("transcript page = %#v, %v", page, err)
}
if key, err := StableItemKey(page.Items[0]); err != nil || key != "id:p1" {
t.Fatalf("stable key = %q, %v", key, err)
}
for _, raw := range []string{
`{"success":true,"result":{"paragraphList":null}}`,
`{"success":true,"result":{"paragraphList":[],"hasNext":true}}`,
`{"success":true,"result":{}}`,
} {
if _, err := ParseTranscriptPage(decode(t, raw)); err == nil {
t.Fatalf("invalid transcript accepted: %s", raw)
}
}
}
func TestCrossPlatformCoverageLatestRequiresComparableTimestampE2E(t *testing.T) {
page, err := ParseListPage(decode(t, `{"result":{"itemList":[{"uuid":"old","startTime":1},{"uuid":"new","startTime":"2"}]}}`))
if err != nil {
t.Fatal(err)
}
if got, err := LatestTaskUUID(page); err != nil || got != "new" {
t.Fatalf("latest = %q, %v", got, err)
}
page, err = ParseListPage(decode(t, `{"result":{"itemList":[{"uuid":"u"}]}}`))
if err != nil {
t.Fatal(err)
}
if _, err := LatestTaskUUID(page); err == nil || !strings.Contains(err.Error(), "comparable") {
t.Fatalf("missing time accepted: %v", err)
}
}
func TestCrossPlatformCoverageArtifactEmptyResultIsNotSuccessE2E(t *testing.T) {
valid := map[string]string{
"basic": `{"success":true,"result":{"taskUuid":"u"}}`,
"summary": `{"success":true,"result":{"fullSummary":""}}`,
"keywords": `{"success":true,"result":{"keywords":[]}}`,
"todos": `{"success":true,"result":{"actions":[]}}`,
}
for name, raw := range valid {
if err := ValidateArtifact(name, "u", decode(t, raw)); err != nil {
t.Fatalf("valid %s rejected: %v", name, err)
}
}
for _, name := range []string{"basic", "summary", "keywords", "todos"} {
if err := ValidateArtifact(name, "u", decode(t, `{"success":true,"result":{}}`)); err == nil {
t.Fatalf("empty %s result accepted", name)
}
}
}
+175
View File
@@ -0,0 +1,175 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
)
// RequireWriteAcknowledgement accepts only an explicit successful envelope or
// a non-empty result object. A bare {} cannot prove a remote write succeeded.
func RequireWriteAcknowledgement(operation string, data map[string]any) error {
if err := validateEnvelope(data); err != nil {
return err
}
if success, exists := data["success"]; exists {
value, ok := success.(bool)
if !ok || !value {
return fmt.Errorf("minutes %s response has invalid success acknowledgement", operation)
}
return nil
}
if result, ok := data["result"].(map[string]any); ok && len(result) > 0 {
return nil
}
return fmt.Errorf("minutes %s response has no explicit successful acknowledgement", operation)
}
// RecordResult validates the gateway's observed listening-note command result.
func RecordResult(expectedCmd, taskUUID string, data map[string]any) (map[string]any, error) {
if err := RequireWriteAcknowledgement("record "+expectedCmd, data); err != nil {
return nil, err
}
result, ok := data["result"].(map[string]any)
if !ok {
return nil, fmt.Errorf("minutes record %s response has no result object", expectedCmd)
}
cmd := stringField(result, "cmd")
if cmd != expectedCmd {
return nil, fmt.Errorf("minutes record response cmd %q does not match %q", cmd, expectedCmd)
}
if expectedCmd != "create" {
actual := TaskUUID(result)
if actual == "" || actual != taskUUID {
return nil, fmt.Errorf("minutes record %s response task UUID mismatch", expectedCmd)
}
}
return result, nil
}
// MindGraphStatus extracts the documented 0=processing, 1=success, 2=failed
// state and rejects empty status responses.
func MindGraphStatus(data map[string]any) (int, map[string]any, error) {
if err := validateEnvelope(data); err != nil {
return 0, nil, err
}
result, ok := data["result"].(map[string]any)
if !ok {
return 0, nil, fmt.Errorf("minutes mind graph response has no result object")
}
value, exists := result["taskStatus"]
if !exists {
return 0, nil, fmt.Errorf("minutes mind graph response has no taskStatus")
}
status, err := intValue(value)
if err != nil || status < 0 || status > 2 {
return 0, nil, fmt.Errorf("minutes mind graph taskStatus is invalid: %v", value)
}
return status, result, nil
}
// SpeakerSummaryTask extracts the recovery handle returned by task creation.
func SpeakerSummaryTask(data map[string]any) (taskID, status string, err error) {
if err := RequireWriteAcknowledgement("speaker summary create", data); err != nil {
return "", "", err
}
result, ok := data["result"].(map[string]any)
if !ok {
return "", "", fmt.Errorf("minutes speaker summary create response has no result object")
}
taskID = stringField(result, "taskId", "taskID")
status = strings.ToLower(stringField(result, "status"))
if taskID == "" {
return "", "", fmt.Errorf("minutes speaker summary create response has no taskId")
}
return taskID, status, nil
}
// SpeakerSummaryResult requires a concrete result payload. The API returns a
// business error while processing, so an empty object is never ready.
func SpeakerSummaryResult(data map[string]any) (any, error) {
if err := validateEnvelope(data); err != nil {
return nil, err
}
result, exists := data["result"]
if !exists || result == nil {
return nil, fmt.Errorf("minutes speaker summary response has no result")
}
switch value := result.(type) {
case map[string]any:
if len(value) == 0 {
return nil, fmt.Errorf("minutes speaker summary result is empty")
}
case []any:
if value == nil {
return nil, fmt.Errorf("minutes speaker summary result is null")
}
default:
return nil, fmt.Errorf("minutes speaker summary result has type %T", result)
}
return result, nil
}
// HotWords extracts the explicit current personal hot-word set. [] is valid;
// a missing/null/wrongly typed list is not.
func HotWords(data map[string]any) ([]string, error) {
if err := validateEnvelope(data); err != nil {
return nil, err
}
result, ok := data["result"].(map[string]any)
if !ok {
return nil, fmt.Errorf("minutes hot-word response has no result object")
}
raw, exists := result["hotWordList"]
if !exists || raw == nil {
return nil, fmt.Errorf("minutes hot-word response has no hotWordList array")
}
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("minutes hotWordList has type %T, want array", raw)
}
out := make([]string, 0, len(items))
seen := map[string]bool{}
for index, item := range items {
word := ""
switch value := item.(type) {
case string:
word = value
case map[string]any:
word = stringField(value, "hotWord", "word", "name", "content")
}
word = strings.TrimSpace(word)
if word == "" {
return nil, fmt.Errorf("minutes hotWordList item %d has no word", index)
}
if !seen[word] {
seen[word] = true
out = append(out, word)
}
}
return out, nil
}
func intValue(value any) (int, error) {
switch typed := value.(type) {
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed < 0 || typed > 2 {
return 0, fmt.Errorf("invalid status number %v", typed)
}
return int(typed), nil
case int:
return typed, nil
case json.Number:
parsed, err := strconv.Atoi(string(typed))
return parsed, err
case string:
return strconv.Atoi(strings.TrimSpace(typed))
default:
return 0, fmt.Errorf("unsupported number type %T", value)
}
}
@@ -0,0 +1,50 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import (
"encoding/json"
"testing"
)
func TestCrossPlatformCoverageMinutesWorkflowShapesRejectUnknownE2E(t *testing.T) {
if err := RequireWriteAcknowledgement("write", map[string]any{}); err == nil {
t.Fatal("bare write response accepted")
}
if _, err := HotWords(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
t.Fatal("missing hot-word list accepted")
}
if _, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
t.Fatal("missing mind graph status accepted")
}
if _, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": 1.5}}); err == nil {
t.Fatal("fractional mind graph status accepted")
}
if _, err := SpeakerSummaryResult(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
t.Fatal("empty speaker summary accepted")
}
}
func TestCrossPlatformCoverageMinutesWorkflowObservedShapesE2E(t *testing.T) {
record, err := RecordResult("pause", "u1", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "u1"}})
if err != nil || record["cmd"] != "pause" {
t.Fatalf("record=%#v err=%v", record, err)
}
status, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": float64(1)}})
if err != nil || status != 1 {
t.Fatalf("mind status=%d err=%v", status, err)
}
status, _, err = MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": json.Number("1")}})
if err != nil || status != 1 {
t.Fatalf("number-preserving mind status=%d err=%v", status, err)
}
taskID, state, err := SpeakerSummaryTask(map[string]any{"success": true, "result": map[string]any{"taskId": "job-1", "status": "processing"}})
if err != nil || taskID != "job-1" || state != "processing" {
t.Fatalf("speaker task=%q state=%q err=%v", taskID, state, err)
}
words, err := HotWords(map[string]any{"success": true, "result": map[string]any{"hotWordList": []any{"DWS", map[string]any{"hotWord": "听记"}}}})
if err != nil || len(words) != 2 || words[1] != "听记" {
t.Fatalf("words=%#v err=%v", words, err)
}
}
@@ -318,12 +318,33 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"doc\u0000+version-revert": {},
"doc\u0000+version-save": {},
"drive\u0000+copy": {},
"drive\u0000+cover": {},
"drive\u0000+create-folder": {},
"drive\u0000+create-shortcut": {},
"drive\u0000+delete": {},
"drive\u0000+download": {},
"drive\u0000+find-file": {},
"drive\u0000+info": {},
"drive\u0000+inspect": {},
"drive\u0000+list": {},
"drive\u0000+move": {},
"drive\u0000+publish-get": {},
"drive\u0000+publish-unset": {},
"drive\u0000+recent": {},
"drive\u0000+recycle-list": {},
"drive\u0000+recycle-restore": {},
"drive\u0000+rename": {},
"drive\u0000+search": {},
"drive\u0000+search-docs": {},
"drive\u0000+star-add": {},
"drive\u0000+star-list": {},
"drive\u0000+star-remove": {},
"drive\u0000+stats": {},
"drive\u0000+upload": {},
"drive\u0000+version-download": {},
"drive\u0000+version-get": {},
"drive\u0000+version-history": {},
"drive\u0000+version-revert": {},
"mail\u0000+contact-list": {},
"mail\u0000+find-mail-user": {},
"mail\u0000+folder-list": {},
@@ -334,12 +355,33 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"mail\u0000+thread-list": {},
"mail\u0000+unread-mail": {},
"mail\u0000+user-search": {},
"minutes\u0000+action-items": {},
"minutes\u0000+apply-permission": {},
"minutes\u0000+detail": {},
"minutes\u0000+download": {},
"minutes\u0000+export-pack": {},
"minutes\u0000+latest": {},
"minutes\u0000+list-all": {},
"minutes\u0000+list-mine": {},
"minutes\u0000+list-shared": {},
"minutes\u0000+mindmap": {},
"minutes\u0000+prepare-asr": {},
"minutes\u0000+record-pause": {},
"minutes\u0000+record-resume": {},
"minutes\u0000+record-start": {},
"minutes\u0000+record-stop": {},
"minutes\u0000+record-wrap-up": {},
"minutes\u0000+replace-batch": {},
"minutes\u0000+search": {},
"minutes\u0000+share": {},
"minutes\u0000+speaker-insights": {},
"minutes\u0000+speaker-replace": {},
"minutes\u0000+summary": {},
"minutes\u0000+transcript": {},
"minutes\u0000+unshare": {},
"minutes\u0000+update": {},
"minutes\u0000+upload": {},
"minutes\u0000+upload-and-analyze": {},
"oa\u0000+list-cc": {},
"oa\u0000+list-executed": {},
"oa\u0000+list-forms": {},
+8
View File
@@ -19,6 +19,12 @@ var docSemanticCatalogJSON []byte
//go:embed semantic_catalog_aitable.json
var aitableSemanticCatalogJSON []byte
//go:embed semantic_catalog_minutes.json
var minutesSemanticCatalogJSON []byte
//go:embed semantic_catalog_drive.json
var driveSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -40,6 +46,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
semanticCatalogJSON,
docSemanticCatalogJSON,
aitableSemanticCatalogJSON,
minutesSemanticCatalogJSON,
driveSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -0,0 +1,39 @@
{
"version": 1,
"service": "drive",
"default_availability": "available",
"shortcuts": {
"+list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空目录与缺失/畸形响应,稳定投影节点并完整保留分页游标。","risk":"read","public":true,"reviewed":true},
"+inspect": {"disposition":"primary_smart","semantic_delta":"以文件元数据为必达结果,按需聚合统计、公开状态和封面;可选读取失败显式报告 partial_success。","risk":"read","public":true,"reviewed":true},
"+info": {"disposition":"alias_internal","semantic_delta":"保留历史元数据入口;新的 Agent 场景统一使用可扩展的 +inspect。","risk":"read","primary":"+inspect","public":true,"reviewed":true},
"+search": {"disposition":"semantic_adapter","semantic_delta":"统一关键词、文件类型、扩展名、创建者、时间和分页过滤,并拒绝缺失结果数组。","risk":"read","public":true,"reviewed":true},
"+find-file": {"disposition":"alias_internal","semantic_delta":"保留历史文件定位入口;新的 Agent 文件搜索统一使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true},
"+search-docs": {"disposition":"alias_internal","semantic_delta":"保留历史跨域文档搜索入口;新的在线文档搜索统一使用 doc +search。","risk":"read","primary":"doc +search","public":true,"reviewed":true},
"+recent": {"disposition":"semantic_adapter","semantic_delta":"严格读取最近访问/编辑列表并保留 nextCursor/hasMore,防止嵌套响应被投影为空。","risk":"read","public":true,"reviewed":true},
"+upload": {"disposition":"primary_smart","semantic_delta":"组合工作目录边界校验、上传凭证、OSS PUT、严格提交响应和远端元数据读回。","risk":"write","public":true,"reviewed":true},
"+download": {"disposition":"primary_smart","semantic_delta":"不再只返回临时链接;使用受控相对路径、no-clobber、原子发布并验证非零本地字节。","risk":"read","public":true,"reviewed":true},
"+create-folder": {"disposition":"primary_smart","semantic_delta":"创建普通钉盘文件夹后要求 fileId,并读回名称验证。","risk":"write","public":true,"reviewed":true},
"+create-shortcut": {"disposition":"primary_smart","semantic_delta":"明确 shortcut 与 copy 语义差异,创建后读取新节点验证。","risk":"write","public":true,"reviewed":true},
"+copy": {"disposition":"primary_smart","semantic_delta":"复制前预检在线对象类型;普通钉盘文件因下层只会生成 .dlink 而显式拒绝,避免把快捷方式伪装成独立副本。","risk":"write","public":true,"reviewed":true},
"+move": {"disposition":"semantic_adapter","semantic_delta":"移动后原位置不保留,统一 folder/workspace 目标语义并发布静态确认。","risk":"write","public":true,"reviewed":true},
"+rename": {"disposition":"primary_smart","semantic_delta":"重命名后读取真实节点元数据验证最终名称。","risk":"write","public":true,"reviewed":true},
"+delete": {"disposition":"semantic_adapter","semantic_delta":"将已确认节点移入回收站,要求高风险确认和 success=true 终态证据。","risk":"high-risk-write","public":true,"reviewed":true},
"+stats": {"disposition":"schema_leaf","semantic_delta":"读取节点访问、编辑、评论、点赞、预览和下载统计的一对一入口。","risk":"read","public":true,"reviewed":true},
"+cover": {"disposition":"schema_leaf","semantic_delta":"读取节点封面或缩略图地址;明确不声称服务端多格式预览转换。","risk":"read","public":true,"reviewed":true},
"+recycle-list": {"disposition":"semantic_adapter","semantic_delta":"严格分页列出回收项并稳定投影 recycleItemId,显式空数组才是空回收站。","risk":"read","public":true,"reviewed":true},
"+recycle-restore": {"disposition":"primary_smart","semantic_delta":"只要求列表可获得的 recycleItemId;恢复响应必须给出节点 ID,随后读回验证。","risk":"write","public":true,"reviewed":true},
"+star-list": {"disposition":"semantic_adapter","semantic_delta":"严格分页列出当前用户收藏并保留游标。","risk":"read","public":true,"reviewed":true},
"+star-add": {"disposition":"schema_leaf","semantic_delta":"以幂等用户偏好语义收藏指定节点。","risk":"write","public":true,"reviewed":true},
"+star-remove": {"disposition":"schema_leaf","semantic_delta":"以幂等用户偏好语义取消收藏指定节点。","risk":"write","public":true,"reviewed":true},
"+publish-get": {"disposition":"schema_leaf","semantic_delta":"只读查询互联网公开状态和权限,不沿用原子命令错误的写风险标签。","risk":"read","public":true,"reviewed":true},
"+publish-set": {"disposition":"primary_smart","semantic_delta":"实现了高风险确认和读回,但真实普通文件与在线文档夹具均返回 operation.notSupported;在获得可验证的 eligible 节点前不公开。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true},
"+publish-unset": {"disposition":"primary_smart","semantic_delta":"高风险确认后关闭互联网公开,并读回状态验证外链已失效。","risk":"high-risk-write","public":true,"reviewed":true},
"+version-history": {"disposition":"semantic_adapter","semantic_delta":"严格分页列出普通文件版本,区分合法空历史与响应契约错误。","risk":"read","public":true,"reviewed":true},
"+version-get": {"disposition":"primary_smart","semantic_delta":"按正整数版本号精确匹配元数据;零命中显式失败。","risk":"read","public":true,"reviewed":true},
"+version-download": {"disposition":"primary_smart","semantic_delta":"预检版本存在后安全下载历史字节,受控相对路径原子发布且要求非零产物。","risk":"read","public":true,"reviewed":true},
"+version-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再经高风险确认回滚并读取当前节点状态。","risk":"high-risk-write","public":true,"reviewed":true}
}
}
@@ -0,0 +1,35 @@
{
"version": 1,
"service": "minutes",
"default_availability": "available",
"shortcuts": {
"+search": {"disposition":"primary_smart","semantic_delta":"Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; replaces the deprecated +minutes-search discovery route.","risk":"read","public":true,"reviewed":true},
"+minutes-search": {"disposition":"alias_internal","semantic_delta":"Hidden compatibility leaf preserving the historical Schema identity and argv while routing new discovery to +search.","risk":"read","primary":"+search","public":false,"reviewed":true},
"+download": {"disposition":"primary_smart","semantic_delta":"Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.","risk":"read","public":true,"reviewed":true},
"+upload": {"disposition":"primary_smart","semantic_delta":"Owns local file validation, create-PUT-complete polling, pre-complete transfer cancellation compensation, unknown-completion recovery and final task read-back verification.","risk":"write","public":true,"reviewed":true},
"+update": {"disposition":"semantic_adapter","semantic_delta":"Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.","risk":"write","public":true,"reviewed":true},
"+apply-permission": {"disposition":"semantic_adapter","semantic_delta":"Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.","risk":"write","public":true,"reviewed":true},
"+summary": {"disposition":"primary_smart","semantic_delta":"Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.","risk":"write","public":true,"reviewed":true},
"+speaker-replace": {"disposition":"primary_smart","semantic_delta":"Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.","risk":"write","public":true,"reviewed":true},
"+record-wrap-up": {"disposition":"primary_smart","semantic_delta":"Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.","risk":"write","public":true,"reviewed":true},
"+upload-and-analyze": {"disposition":"primary_smart","semantic_delta":"Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.","risk":"write","public":true,"reviewed":true},
"+mindmap": {"disposition":"primary_smart","semantic_delta":"Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.","risk":"write","public":true,"reviewed":true},
"+speaker-insights": {"disposition":"primary_smart","semantic_delta":"Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.","risk":"write","public":true,"reviewed":true},
"+prepare-asr": {"disposition":"primary_smart","semantic_delta":"Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.","risk":"write","public":true,"reviewed":true},
"+export-pack": {"disposition":"primary_smart","semantic_delta":"Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.","risk":"read","public":true,"reviewed":true},
"+share": {"disposition":"primary_smart","semantic_delta":"Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.","risk":"write","public":true,"reviewed":true},
"+unshare": {"disposition":"primary_smart","semantic_delta":"Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.","risk":"write","public":true,"reviewed":true},
"+list-mine": {"disposition":"schema_leaf","semantic_delta":"Stable projection of current-user Minutes with strict itemList response validation.","risk":"read","public":true,"reviewed":true},
"+list-shared": {"disposition":"schema_leaf","semantic_delta":"Stable projection of shared Minutes with strict itemList response validation.","risk":"read","public":true,"reviewed":true},
"+list-all": {"disposition":"schema_leaf","semantic_delta":"Stable projection of all accessible Minutes with strict itemList response validation.","risk":"read","public":true,"reviewed":true},
"+record-start": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-create facade with explicit confirmation and stable session parameter.","risk":"write","public":true,"reviewed":true},
"+record-pause": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-pause facade with explicit task UUID validation and confirmation.","risk":"write","public":true,"reviewed":true},
"+record-resume": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-resume facade with explicit task UUID validation and confirmation.","risk":"write","public":true,"reviewed":true},
"+record-stop": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-stop facade with explicit task UUID validation and confirmation.","risk":"write","public":true,"reviewed":true},
"+latest": {"disposition":"primary_smart","semantic_delta":"Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.","risk":"read","public":true,"reviewed":true},
"+transcript": {"disposition":"primary_smart","semantic_delta":"Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.","risk":"read","public":true,"reviewed":true},
"+action-items": {"disposition":"primary_smart","semantic_delta":"Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.","risk":"read","public":true,"reviewed":true},
"+detail": {"disposition":"primary_smart","semantic_delta":"Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.","risk":"read","public":true,"reviewed":true},
"+replace-batch": {"disposition":"primary_smart","semantic_delta":"Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.","risk":"write","public":true,"reviewed":true}
}
}
+53 -25
View File
@@ -14,8 +14,10 @@
package smart
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// ActionItems: fetch the extracted to-do items (待办事项) from MY most recent
@@ -24,8 +26,7 @@ import (
// Steps:
// 1. list my minutes via list_by_keyword_and_time_range
// (belongingConditionId = "created", maxResults = 20);
// 2. pick the newest entry (largest create time, falling back to the first
// item since lists come back newest-first) and read its taskUuid;
// 2. pick the newest entry by an explicit comparable timestamp and read its taskUuid;
// 3. print that minute's extracted to-do items via list_minutes_todos.
//
// If the list is empty it reports "暂无妙记" instead of failing obscurely.
@@ -35,41 +36,68 @@ var ActionItems = shortcut.Shortcut{
Service: "minutes",
Command: "+action-items",
Product: "minutes",
Description: "取我最新一条妙记里的待办事项",
Intent: "当你刚开完会、只想立刻知道自己最近这条听记里系统帮你提取出了哪些待办事项," +
"却不想先翻听记列表、复制 taskUuid、再手动去查待办时使用;" +
"内部会先列出你创建的听记,自动挑出最新的一条,再拉取这条听记里提取的待办事项清单(含待办内容、参与人、待办时间等)。" +
"这是只读操作,不会新增或修改任何待办;若你名下没有任何听记则提示「暂无妙记」。",
Description: "读取指定或我最新一条听记中已抽取的行动项",
Intent: "当你要读取已知 taskUuid(--id)的听记行动项,或不传 --id 自动选择自己最新听记时使用;" +
"只接受服务端显式 actions/dingtalkTodoList 数组,合法空数组表示没有抽取到行动项,缺字段或错误响应不会被当成空成功。" +
"这是只读的 Minutes 产物读取,不会创建或修改钉钉 Todo。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: minutesSmartContract(
"+action-items",
"读取最新听记中已抽取的行动项",
"要读取指定听记(--id)或默认最新听记中由听记服务抽取的 actions/dingtalkTodoList 时使用;该命令只读,不会创建钉钉待办。",
[]string{"要创建或修改真正的钉钉待办时使用 Todo 产品命令"},
[]string{"dws minutes +action-items --id <taskUuid>", "dws minutes +action-items"},
nil,
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid;不传时选择我最新的一条"},
},
Tips: []string{
`dws minutes +action-items --id <taskUuid>`,
`dws minutes +action-items`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my minutes (newest first). belongingConditionId /
// maxResults mirror helpers.callListByKeywordRange.
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", map[string]any{
"belongingConditionId": "created",
"maxResults": float64(20),
taskUUID := rt.Str("id")
if taskUUID == "" {
// Step 1 — list my minutes (newest first). belongingConditionId /
// maxResults mirror helpers.callListByKeywordRange.
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", map[string]any{
"belongingConditionId": "created",
"maxResults": float64(20),
})
if err != nil {
return err
}
// Step 2 — locate the newest minute's taskUuid. Reuse the defensive
// list/UUID/createTime parsing from latest_minutes.go.
taskUUID, err = latestMinutesTaskUUID(data)
if err != nil {
return err
}
if taskUUID == "" {
return apperrors.NewValidation("暂无妙记")
}
}
// Step 3 — validate and print its extracted to-do items. Empty arrays are
// valid business data; a missing result/actions collection is not.
todosData, err := rt.CallMCPData("minutes", "list_minutes_todos", map[string]any{
"taskUuid": taskUUID,
})
if err != nil {
return err
}
// Step 2 — locate the newest minute's taskUuid. Reuse the defensive
// list/UUID/createTime parsing from latest_minutes.go.
taskUUID := latestMinutesTaskUUID(data)
if taskUUID == "" {
return apperrors.NewValidation("暂无妙记")
if err := minutesdata.ValidateArtifact("todos", taskUUID, todosData); err != nil {
return err
}
// Step 3 — print its extracted to-do items (taskUuid param mirrors
// helpers' list_minutes_todos call).
return rt.CallMCP("list_minutes_todos", map[string]any{
"taskUuid": taskUUID,
})
return rt.Output(todosData["result"])
},
}
func init() {
shortcut.Register(ActionItems)
shortcut.Register(finalizeMinutesSmartShortcut(ActionItems))
}
+44 -92
View File
@@ -14,8 +14,10 @@
package smart
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// LatestMinutes: fetch the details of MY most recent minutes (听记) in one step.
@@ -23,29 +25,40 @@ import (
// Steps:
// 1. list my minutes via list_by_keyword_and_time_range (belongingConditionId
// = "created"), optionally filtered by --keyword;
// 2. pick the newest entry (largest create time, falling back to the first
// item) and read its taskUuid;
// 2. pick the newest entry by an explicit comparable timestamp and read its taskUuid;
// 3. print that minute's basic info via get_minutes_basic_info.
//
// If the list is empty it reports "暂无妙记" instead of failing obscurely.
//
// dws minutes +latest-minutes
// dws minutes +latest-minutes --keyword 周会
// dws minutes +latest
// dws minutes +latest --keyword 周会
var LatestMinutes = shortcut.Shortcut{
Service: "minutes",
Command: "+latest-minutes",
Command: "+latest",
Aliases: []string{"+latest-minutes"},
Product: "minutes",
Description: "取我最新的一条妙记(听记)详情",
Intent: "当你只想快速看回自己最近的一条会议听记,却不想先翻列表、复制 taskUuid 再查详情时使用;" +
"内部先列出你创建的听记(可用 --keyword 缩小范围),自动挑出最新的一条,再拉取它的基础信息(标题、创建人、时间、访问链接等)。" +
"这是只读操作,不会修改任何听记;若你名下没有任何听记则提示「暂无妙记」。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: minutesSmartContract(
"+latest",
"取我最新的一条妙记(听记)详情",
"需要按真实创建/开始时间选出当前用户最新一条听记并直接读取基础详情时使用;可用 --keyword 缩小候选集。",
[]string{"已知 taskUuid 时直接使用基础信息原子命令;需要搜索多条结果时使用 +search"},
[]string{"dws minutes +latest", "dws minutes +latest --keyword 周会"},
[]string{"+latest-minutes"},
),
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false},
},
Tips: []string{
`dws minutes +latest-minutes`,
`dws minutes +latest-minutes --keyword 周会`,
`dws minutes +latest`,
`dws minutes +latest --keyword 周会`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my minutes (newest first). belongingConditionId /
@@ -63,103 +76,42 @@ var LatestMinutes = shortcut.Shortcut{
}
// Step 2 — locate the newest minute's taskUuid.
taskUUID := latestMinutesTaskUUID(data)
taskUUID, err := latestMinutesTaskUUID(data)
if err != nil {
return err
}
if taskUUID == "" {
return apperrors.NewValidation("暂无妙记")
}
// Step 3 — print its basic info (taskUuid param mirrors helper).
return rt.CallMCP("get_minutes_basic_info", map[string]any{
// Step 3 — validate and print its basic info. A successful-looking empty
// result must not become a Shortcut success.
basicData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{
"taskUuid": taskUUID,
})
if err != nil {
return err
}
basic, err := minutesdata.Basic(taskUUID, basicData)
if err != nil {
return err
}
return rt.Output(basic)
},
}
// latestMinutesItems walks a list_by_keyword_and_time_range response and returns
// its minutes entries. The gateway wraps the list under one of several common
// container keys, so we probe them defensively before scanning for a bare list.
func latestMinutesItems(data map[string]any) []map[string]any {
for _, key := range []string{"result", "list", "minutesList", "items", "data", "records"} {
if arr, ok := data[key].([]any); ok {
return latestMinutesToMaps(arr)
}
// Some responses nest the list one level deeper, e.g. {"data": {"list": [...]}}.
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range []string{"list", "minutesList", "items", "records", "result"} {
if arr, ok := inner[k2].([]any); ok {
return latestMinutesToMaps(arr)
}
}
}
}
return nil
}
func latestMinutesToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// latestMinutesTaskUUID picks the newest minute's taskUuid: the item with the
// largest numeric create time, falling back to the first item that carries a
// taskUuid (lists come back newest-first).
func latestMinutesTaskUUID(data map[string]any) string {
items := latestMinutesItems(data)
if len(items) == 0 {
return ""
// largest explicit create/start time. Unknown list shapes and rows without a
// comparable timestamp fail closed instead of becoming an empty or arbitrary
// "latest" success.
func latestMinutesTaskUUID(data map[string]any) (string, error) {
page, err := minutesdata.ParseListPage(data)
if err != nil {
return "", err
}
best := ""
var bestTime float64
haveTime := false
firstUUID := ""
for _, m := range items {
uuid := latestMinutesUUID(m)
if uuid == "" {
continue
}
if firstUUID == "" {
firstUUID = uuid
}
if t, ok := latestMinutesCreateTime(m); ok {
if !haveTime || t > bestTime {
haveTime = true
bestTime = t
best = uuid
}
}
}
if haveTime && best != "" {
return best
}
return firstUUID
}
func latestMinutesUUID(m map[string]any) string {
for _, key := range []string{"taskUuid", "taskUUID", "uuid", "id"} {
if s, ok := m[key].(string); ok && s != "" {
return s
}
}
return ""
}
func latestMinutesCreateTime(m map[string]any) (float64, bool) {
for _, key := range []string{"createTime", "gmtCreate", "startTime", "createTimeStart"} {
if v, ok := m[key].(float64); ok {
return v, true
}
}
return 0, false
return minutesdata.LatestTaskUUID(page)
}
func init() {
shortcut.Register(LatestMinutes)
shortcut.Register(finalizeMinutesSmartShortcut(LatestMinutes))
}
@@ -0,0 +1,408 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
type smartMinutesFailWriter struct{}
func (smartMinutesFailWriter) Write([]byte) (int, error) {
return 0, errors.New("fixture output failure")
}
func runMinutesCLIWithWriter(t *testing.T, caller *smartCoverageCaller, writer io.Writer, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := newPlatformCoverageRoot()
root.SetOut(writer)
root.SetErr(&bytes.Buffer{})
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMinutesSmartContractFinalizer(t *testing.T) {
value := shortcut.Shortcut{
Description: "desc", Intent: "intent",
Contract: corecmd.ContractDecl{Parameters: []contract.ParamDecl{
{Name: "a", Description: "parameter"},
{Name: "a", Description: "evidence present"},
{Name: "other", Description: "other"},
}},
Flags: []shortcut.Flag{{Name: "a", Desc: "flag"}},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"a"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}, Description: "evidence"},
},
}
got := finalizeMinutesSmartShortcut(value)
if got.Contract.Selection.AgentSummary != "desc" || got.Contract.Selection.UseWhen[0] != "intent" || !strings.Contains(got.Flags[0].Desc, "evidence") || !strings.Contains(got.Contract.Parameters[0].Description, "evidence") || got.Contract.Parameters[1].Description != "evidence present" || got.Contract.Parameters[2].Description != "other" {
t.Fatalf("finalized=%#v", got)
}
}
func TestCrossPlatformCoverageMinutesLatestAndActionItemsBranchesE2E(t *testing.T) {
listKey := "minutes/list_by_keyword_and_time_range"
basicKey := "minutes/get_minutes_basic_info"
todoKey := "minutes/list_minutes_todos"
latestCases := []struct {
name string
responses map[string][]string
failAt map[string]int
wantError bool
}{
{name: "list call", failAt: map[string]int{listKey: 1}, wantError: true},
{name: "list parse", responses: map[string][]string{listKey: {`{"success":true,"result":{}}`}}, wantError: true},
{name: "empty", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}, wantError: true},
{name: "basic call", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}}, failAt: map[string]int{basicKey: 1}, wantError: true},
{name: "basic parse", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, basicKey: {`{"success":true,"result":{}}`}}, wantError: true},
{name: "success", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, basicKey: {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`}}},
}
for _, test := range latestCases {
t.Run("latest "+test.name, func(t *testing.T) {
payload, _, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, "minutes", "+latest", "--keyword", "weekly")
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
latestOutput := &smartCoverageCaller{responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, basicKey: {`{"success":true,"result":{"taskUuid":"u1"}}`}}}
if err := runMinutesCLIWithWriter(t, latestOutput, smartMinutesFailWriter{}, "minutes", "+latest"); err == nil {
t.Fatal("latest output failure accepted")
}
if _, err := latestMinutesTaskUUID(map[string]any{}); err == nil {
t.Fatal("latest malformed data accepted")
}
actionCases := []struct {
name string
responses map[string][]string
failAt map[string]int
args []string
wantError bool
}{
{name: "list call", failAt: map[string]int{listKey: 1}, wantError: true},
{name: "list parse", responses: map[string][]string{listKey: {`{"success":true,"result":{}}`}}, wantError: true},
{name: "empty", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}, wantError: true},
{name: "todo call", failAt: map[string]int{todoKey: 1}, args: []string{"--id", "u1"}, wantError: true},
{name: "todo parse", responses: map[string][]string{todoKey: {`{"success":true,"result":{}}`}}, args: []string{"--id", "u1"}, wantError: true},
{name: "success id", responses: map[string][]string{todoKey: {`{"success":true,"result":{"actions":[]}}`}}, args: []string{"--id", "u1"}},
{name: "success latest", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, todoKey: {`{"success":true,"result":{"actions":[]}}`}}},
}
for _, test := range actionCases {
t.Run("actions "+test.name, func(t *testing.T) {
args := []string{"minutes", "+action-items"}
args = append(args, test.args...)
payload, _, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, args...)
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
actionOutput := &smartCoverageCaller{responses: map[string][]string{todoKey: {`{"success":true,"result":{"actions":[]}}`}}}
if err := runMinutesCLIWithWriter(t, actionOutput, smartMinutesFailWriter{}, "minutes", "+action-items", "--id", "u1"); err == nil {
t.Fatal("actions output failure accepted")
}
}
func TestCrossPlatformCoverageMinutesSearchAndTranscriptBranchesE2E(t *testing.T) {
listKey := "minutes/list_by_keyword_and_time_range"
transcriptKey := "minutes/get_minutes_transcription"
searchCases := []struct {
name string
responses map[string][]string
failAt map[string]int
}{
{name: "call", failAt: map[string]int{listKey: 1}},
{name: "project", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"title":"missing id"}]}}`}}},
}
for _, test := range searchCases {
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, "minutes", "+minutes-search", "--query", "x"); err == nil {
t.Fatalf("search %s failure accepted", test.name)
}
}
searchOutput := &smartCoverageCaller{responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}}
if err := runMinutesCLIWithWriter(t, searchOutput, smartMinutesFailWriter{}, "minutes", "+minutes-search", "--query", "x"); err == nil {
t.Fatal("search output failure accepted")
}
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+transcript", "--id", "u1", "--page-limit", "0"); err == nil {
t.Fatal("transcript page limit accepted")
}
transcriptCases := []struct {
name string
responses map[string][]string
failAt map[string]int
wantError bool
}{
{name: "list call", failAt: map[string]int{listKey: 1}, wantError: true},
{name: "list parse", responses: map[string][]string{listKey: {`{"success":true,"result":{}}`}}, wantError: true},
{name: "empty", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}, wantError: true},
{name: "success", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}},
{name: "partial", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`}}, failAt: map[string]int{transcriptKey: 2}, wantError: true},
}
for _, test := range transcriptCases {
t.Run("transcript "+test.name, func(t *testing.T) {
payload, output, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, "minutes", "+transcript", "--keyword", "weekly", "--direction", "1")
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v output=%q err=%v", payload, output, err)
}
})
}
transcriptOutput := &smartCoverageCaller{responses: map[string][]string{transcriptKey: {`{"success":true,"result":{"paragraphList":[],"hasNext":false}}`}}}
if err := runMinutesCLIWithWriter(t, transcriptOutput, smartMinutesFailWriter{}, "minutes", "+transcript", "--id", "u1"); err == nil {
t.Fatal("transcript output failure accepted")
}
}
func TestCrossPlatformCoverageMinutesDetailRemainingBranchesE2E(t *testing.T) {
invalid := [][]string{
{"minutes", "+detail", "--id", "u1", "--page-limit", "0"},
{"minutes", "+detail", "--id", "u1", "--ids", "u2"},
{"minutes", "+detail"},
{"minutes", "+detail", "--id", "u1", "--transcript-output", "file", "--output-dir", "../escape"},
}
for _, args := range invalid {
if payload, output, err := runMinutesCLI(t, &smartCoverageCaller{}, args...); err == nil || payload != nil || output != "" {
t.Fatalf("detail invalid accepted %v payload=%#v output=%q err=%v", args, payload, output, err)
}
}
all := &smartCoverageCaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`},
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`},
"minutes/get_minutes_keywords": {`{"success":true,"result":{"keywords":[]}}`},
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`},
"minutes/list_minutes_todos": {`{"success":true,"result":{"actions":[]}}`},
}}
payload, _, err := runMinutesCLI(t, all, "minutes", "+detail", "--id", "u1")
if err != nil || payload["complete"] != true {
t.Fatalf("detail all payload=%#v err=%v", payload, err)
}
if err := runMinutesCLIWithWriter(t, all, smartMinutesFailWriter{}, "minutes", "+detail", "--id", "u1", "--artifacts", "basic"); err == nil {
t.Fatal("detail single output failure accepted")
}
batchOutput := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`, `{"success":true,"result":{"taskUuid":"u2"}}`}}}
if err := runMinutesCLIWithWriter(t, batchOutput, smartMinutesFailWriter{}, "minutes", "+detail", "--ids", "u1,u2", "--artifacts", "basic"); err == nil {
t.Fatal("detail batch output failure accepted")
}
for _, artifact := range []string{"basic", "summary", "keywords", "todos"} {
tool := minutesArtifactTools[artifact]
caller := &smartCoverageCaller{failAt: map[string]int{"minutes/" + tool: 1}}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", artifact)
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("detail call %s payload=%#v err=%v", artifact, payload, err)
}
caller = &smartCoverageCaller{responses: map[string][]string{"minutes/" + tool: {`{"success":true,"result":{}}`}}}
payload, output, err = runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", artifact)
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("detail parse %s payload=%#v err=%v", artifact, payload, err)
}
}
transcript := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}}
testseam.Swap(t, &smartMinutesMarshalIndent, func(any, string, string) ([]byte, error) { return nil, errors.New("marshal") })
payload, output, err := runMinutesCLI(t, transcript, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript", "--transcript-output", "file", "--output-dir", "out")
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("detail marshal payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesDetailPublishFailure(t *testing.T) {
testseam.Swap(t, &smartMinutesPublishBytes, func([]byte, localio.PublishBytesOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("publish")
})
caller := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript", "--transcript-output", "both", "--output-dir", "out")
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("detail publish payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesDetailFileSuccess(t *testing.T) {
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
work := t.TempDir()
if err := os.Chdir(work); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
caller := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}}
payload, _, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript", "--transcript-output", "file", "--output-dir", "out")
if err != nil || payload["complete"] != true {
t.Fatalf("detail file payload=%#v err=%v", payload, err)
}
if _, statErr := os.Stat(filepath.Join(work, "out", "u1.json")); statErr != nil {
t.Fatal(statErr)
}
helpers.InitDepsForTest(t, caller)
cmd := &cobra.Command{Use: "detail"}
cmd.Flags().String("cursor", "", "")
cmd.Flags().Bool("single-page", false, "")
cmd.Flags().Int("page-limit", 100, "")
cmd.Flags().String("transcript-output", "inline", "")
rt := shortcut.RuntimeContextForTest(cmd, MinutesDetail)
if bundle, failures := readMinutesDetail(rt, "u1", []string{"unknown"}, "0"); len(failures) != 0 || bundle["complete"] != true {
t.Fatalf("unknown detail bundle=%#v failures=%#v", bundle, failures)
}
}
func TestCrossPlatformCoverageMinutesReplaceParsing(t *testing.T) {
for _, raw := range [][]string{{"missing"}, {" =>x"}, {"a=>x", "a=>y"}} {
if pairs, err := parseReplacePairs(raw); err == nil || pairs != nil {
t.Fatalf("invalid pairs accepted: %#v pairs=%#v err=%v", raw, pairs, err)
}
}
if pairs, err := parseReplacePairs([]string{" a => b "}); err != nil || len(pairs) != 1 || pairs[0].orig != "a" || pairs[0].repl != "b" {
t.Fatalf("pairs=%#v err=%v", pairs, err)
}
for _, raw := range []string{"not-json", `[]`, `[{"replacement":"x"}]`} {
if pairs, err := parseReplaceJSON(raw); err == nil || pairs != nil {
t.Fatalf("invalid JSON accepted: %q pairs=%#v err=%v", raw, pairs, err)
}
}
if pairs, err := parseReplaceJSON(`{"replacements":[{"originalText":" a ","replacedText":" b "}]}`); err != nil || len(pairs) != 1 || pairs[0] != (replacePair{orig: "a", repl: "b"}) {
t.Fatalf("envelope pairs=%#v err=%v", pairs, err)
}
if pairs, err := parseReplaceJSON(`[{"source":"a","replacement":"b"}]`); err != nil || len(pairs) != 1 {
t.Fatalf("direct pairs=%#v err=%v", pairs, err)
}
for _, pairs := range [][]replacePair{nil, {{orig: " "}}, {{orig: "a"}, {orig: "a"}}} {
if got, err := validateReplacePairs(pairs); err == nil || got != nil {
t.Fatalf("invalid validated: %#v got=%#v err=%v", pairs, got, err)
}
}
valid := []replacePair{{orig: "a", repl: "b"}}
if got, err := validateReplacePairs(valid); err != nil || len(got) != 1 {
t.Fatalf("valid=%#v err=%v", got, err)
}
for _, test := range []struct {
before, after string
pair replacePair
beforeSource int
beforeTarget int
want bool
}{
{before: "same", after: "same", pair: replacePair{orig: "a", repl: "b"}},
{before: "a", after: "", pair: replacePair{orig: "a"}, beforeSource: 1, want: true},
{before: "a", after: "a", pair: replacePair{orig: "a"}, beforeSource: 1},
{before: "a", after: "aa", pair: replacePair{orig: "a", repl: "aa"}, beforeSource: 1, beforeTarget: 0, want: true},
{before: "a", after: "b", pair: replacePair{orig: "a", repl: "b"}, beforeSource: 1, beforeTarget: 0, want: true},
{before: "a", after: "c", pair: replacePair{orig: "a", repl: "b"}, beforeSource: 1, beforeTarget: 0},
} {
if got := replaceReadbackVerified(test.before, test.after, test.pair, test.beforeSource, test.beforeTarget); got != test.want {
t.Fatalf("verified(%#v)=%v", test, got)
}
}
}
func TestCrossPlatformCoverageMinutesReplaceLoadAndTranscript(t *testing.T) {
cmd := &cobra.Command{Use: "replace"}
cmd.Flags().StringSlice("pair", []string{"bad"}, "")
cmd.Flags().String("json", "", "")
if pairs, err := loadReplacePairs(shortcut.RuntimeContextForTest(cmd, ReplaceBatch)); err == nil || pairs != nil {
t.Fatalf("direct malformed pair accepted pairs=%#v err=%v", pairs, err)
}
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>b", "--page-limit", "0"); err == nil {
t.Fatal("replace page limit accepted")
}
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--json", "@missing", "--dry-run"); err == nil {
t.Fatal("missing JSON file accepted")
}
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--json", "bad", "--dry-run"); err == nil {
t.Fatal("malformed JSON accepted")
}
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>b", "--json", `[{"source":"a","replacement":"c"}]`, "--dry-run"); err == nil {
t.Fatal("cross-input duplicate accepted")
}
for _, test := range []struct {
name string
responses []string
failAt int
}{
{name: "call", failAt: 1},
{name: "empty", responses: []string{`{"success":true,"result":{"paragraphList":[],"hasNext":false}}`}},
{name: "no text", responses: []string{`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}},
} {
t.Run(test.name, func(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": test.responses}, failAt: map[string]int{"minutes/get_minutes_transcription": test.failAt}}
helpers.InitDepsForTest(t, caller)
cmd := &cobra.Command{Use: "replace"}
rt := shortcut.RuntimeContextForTest(cmd, ReplaceBatch)
if text, err := minutesReplaceTranscriptText(rt, "u1", 1); err == nil || text != "" {
t.Fatalf("text=%q err=%v", text, err)
}
})
}
nested := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraph":"one","sentences":[{"text":"two"}]}],"hasNext":false}}`}}}
helpers.InitDepsForTest(t, nested)
if text, err := minutesReplaceTranscriptText(shortcut.RuntimeContextForTest(&cobra.Command{Use: "replace"}, ReplaceBatch), "u1", 1); err != nil || !strings.Contains(text, "one") || !strings.Contains(text, "two") {
t.Fatalf("nested text=%q err=%v", text, err)
}
}
func TestCrossPlatformCoverageMinutesReplaceExecutionBranchesE2E(t *testing.T) {
transcriptKey := "minutes/get_minutes_transcription"
writeKey := "minutes/replace_minutes_text"
preRead := &smartCoverageCaller{failAt: map[string]int{transcriptKey: 1}}
if _, _, err := runMinutesCLI(t, preRead, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes"); err == nil {
t.Fatal("replace pre-read failure accepted")
}
missing := &smartCoverageCaller{responses: map[string][]string{transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraph":"other"}],"hasNext":false}}`}}}
if _, _, err := runMinutesCLI(t, missing, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes"); err == nil || missing.counts[writeKey] != 0 {
t.Fatal("missing source write accepted")
}
readback := &smartCoverageCaller{
responses: map[string][]string{
transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraph":"a"}],"hasNext":false}}`},
writeKey: {`{"success":true,"result":{}}`},
},
failAt: map[string]int{transcriptKey: 2},
}
payload, output, err := runMinutesCLI(t, readback, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes")
if err == nil || output == "" || payload["failed"] != float64(1) {
t.Fatalf("readback payload=%#v err=%v", payload, err)
}
success := func() *smartCoverageCaller {
return &smartCoverageCaller{responses: map[string][]string{
transcriptKey: {
`{"success":true,"result":{"paragraphList":[{"paragraph":"a"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraph":"A"}],"hasNext":false}}`,
},
writeKey: {`{"success":true,"result":{}}`},
}}
}
payload, output, err = runMinutesCLI(t, success(), "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes")
if err != nil || output == "" || payload["ok"] != true || payload["applied"] != float64(1) {
t.Fatalf("success payload=%#v err=%v", payload, err)
}
if err := runMinutesCLIWithWriter(t, success(), smartMinutesFailWriter{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes"); err == nil {
t.Fatal("replace output failure accepted")
}
if err := runMinutesCLIWithWriter(t, &smartCoverageCaller{}, smartMinutesFailWriter{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--dry-run"); err == nil {
t.Fatal("replace dry-run output failure accepted")
}
}
@@ -0,0 +1,82 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func minutesSmartContract(command, description, useWhen string, avoidWhen, examples, aliases []string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
identityAliases := make([]string, 0, len(aliases))
for _, alias := range aliases {
identityAliases = append(identityAliases, "minutes "+alias)
}
return corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: name,
CanonicalPath: "minutes." + name,
CLIPath: "minutes " + command,
PrimaryCLIPath: "minutes " + command,
Aliases: identityAliases,
},
Description: description,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: "The executable Shortcut owns strict Minutes response validation, orchestration, completeness and final output; no single RPC represents the final command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{useWhen},
AvoidWhen: avoidWhen,
Examples: examples,
},
}
}
// finalizeMinutesSmartShortcut keeps Minutes shortcuts that live in the smart
// package under the same declare=delivery contract as the Minutes package.
func finalizeMinutesSmartShortcut(value shortcut.Shortcut) shortcut.Shortcut {
value.Contract.Selection.AgentSummary = value.Description
value.Contract.Selection.UseWhen = []string{value.Intent}
for _, constraint := range value.Constraints {
if constraint.Kind != shortcut.ConstraintCustom {
continue
}
evidence := strings.TrimSpace(constraint.Description)
if evidence == "" {
continue
}
for flagIndex := range value.Flags {
flag := &value.Flags[flagIndex]
if !smartMinutesContains(constraint.Flags, flag.Name) || strings.Contains(flag.Desc, evidence) {
continue
}
flag.Desc = strings.TrimRight(flag.Desc, ";。 ") + ";约束:" + evidence
}
for parameterIndex := range value.Contract.Parameters {
parameter := &value.Contract.Parameters[parameterIndex]
if !smartMinutesContains(constraint.Flags, parameter.Name) || strings.Contains(parameter.Description, evidence) {
continue
}
parameter.Description = strings.TrimRight(parameter.Description, ";。 ") + ";约束:" + evidence
}
}
return value
}
func smartMinutesContains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
+168 -29
View File
@@ -14,12 +14,22 @@
package smart
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
var (
smartMinutesMarshalIndent = json.MarshalIndent
smartMinutesPublishBytes = localio.PublishBytes
)
// MinutesDetail: fetch several artifacts of ONE minute (听记) in a single command
@@ -44,10 +54,10 @@ var MinutesDetail = shortcut.Shortcut{
Service: "minutes",
Command: "+detail",
Product: "minutes",
Description: "一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办)",
Intent: "当你已经有某条听记的 taskUuid,想在一次操作里同时拿到它的基础信息、AI 摘要、关键词、逐字稿和待办,而不想分别敲 4~5 个子命令再自己拼时使用;" +
"内部按 --artifacts 选择要拉的产物(默认全部:basic/summary/keywords/transcript/todos),逐个调用对应的原子工具并聚合成一个结果," +
"某一项失败不会中断整体(会以错误字符串记录在该项下)。这是纯只读操作,不会修改听记;--direction 仅影响逐字稿排序(0=正序默认,1=倒序)。",
Description: "批量聚合听记基础信息、摘要、关键词、完整逐字稿和行动项,支持安全文件输出",
Intent: "当你已有一个或最多 50 个 taskUuid,要一次读取所选 basic/summary/keywords/transcript/todos 产物时使用;" +
"逐字稿默认追完所有分页并可用 file/both 安全落盘,批量结果带 complete 与逐项 failure ledger。" +
"任一所选产物失败都会保留已取得结果并返回非零,绝不会把 partial bundle 当成完整成功;这是纯只读操作。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -61,7 +71,7 @@ var MinutesDetail = shortcut.Shortcut{
CLIPath: "minutes +detail",
PrimaryCLIPath: "minutes +detail",
},
Description: "一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办)",
Description: "批量聚合听记基础信息、摘要、关键词、完整逐字稿和行动项,支持安全文件输出",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -73,22 +83,49 @@ var MinutesDetail = shortcut.Shortcut{
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws minutes +detail --id <taskUuid>",
"dws minutes +detail --id <taskUuid> --artifacts summary,todos",
"dws minutes +detail --ids <uuid1,uuid2> --artifacts summary,transcript --transcript-output file",
},
},
},
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true},
{Name: "id", Type: shortcut.FlagString, Desc: "单个听记 taskUuid"},
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个听记 taskUuid,最多 50 个"},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要拉取的产物子集(默认全部)", Required: false, Enum: []string{"basic", "summary", "keywords", "transcript", "todos"}},
{Name: "direction", Type: shortcut.FlagString, Desc: "逐字稿排序: 0=正序(默认), 1=倒序(可选)", Required: false, Enum: []string{"0", "1"}},
{Name: "cursor", Type: shortcut.FlagString, Desc: "逐字稿单页/续拉的起始 nextToken"},
{Name: "single-page", Type: shortcut.FlagBool, Desc: "逐字稿只读取一页并返回 nextToken"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿自动翻页安全上限"},
{Name: "transcript-output", Type: shortcut.FlagString, Default: "inline", Desc: "逐字稿输出方式", Enum: []string{"inline", "file", "both"}},
{Name: "output-dir", Type: shortcut.FlagString, Default: "minutes-transcripts", Desc: "file/both 模式下的安全相对输出目录"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "--id 与 --ids 只能选择一种,去重后 taskUuid 必须为 1..50 个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"transcript-output", "output-dir"}, Description: "file/both 输出目录必须是安全相对路径"},
},
Tips: []string{
`dws minutes +detail --id <taskUuid>`,
`dws minutes +detail --id <taskUuid> --artifacts summary,todos`,
`dws minutes +detail --id <taskUuid> --direction 1`,
`dws minutes +detail --ids <uuid1,uuid2> --artifacts summary,transcript --transcript-output file`,
`dws minutes +detail --id <taskUuid> --direction 1 --transcript-output both`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
if strings.TrimSpace(rt.Str("id")) != "" && len(rt.StrSlice("ids")) > 0 {
return apperrors.NewValidation("--id 与 --ids 只能选择一种")
}
ids := minutesDetailIDs(rt)
if len(ids) == 0 || len(ids) > 50 {
return apperrors.NewValidation("taskUuid 数量必须为 1..50")
}
if rt.Str("transcript-output") != "inline" {
return localio.ValidateOutput(rt.Str("output-dir"))
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
taskUUID := rt.Str("id")
ids := minutesDetailIDs(rt)
direction := rt.Str("direction")
if direction == "" {
direction = "0"
@@ -100,30 +137,132 @@ var MinutesDetail = shortcut.Shortcut{
want = minutesArtifactOrder
}
bundle := map[string]any{"taskUuid": taskUUID}
for _, raw := range want {
name := strings.ToLower(strings.TrimSpace(raw))
tool, ok := minutesArtifactTools[name]
if !ok {
continue // guarded by Validate, but stay defensive
results := make([]map[string]any, 0, len(ids))
allFailures := make([]map[string]any, 0)
succeeded := 0
for _, taskUUID := range ids {
bundle, failures := readMinutesDetail(rt, taskUUID, want, direction)
results = append(results, bundle)
if len(failures) == 0 {
succeeded++
}
params := map[string]any{"taskUuid": taskUUID}
if name == "transcript" {
params["direction"] = direction
for _, failure := range failures {
failure["taskUuid"] = taskUUID
allFailures = append(allFailures, failure)
}
data, err := rt.CallMCPData("minutes", tool, params)
if err != nil {
// Partial-failure tolerance: record and continue.
bundle[name] = map[string]any{"error": err.Error()}
continue
}
bundle[name] = data
}
return rt.Output(bundle)
if !rt.Changed("ids") && len(results) == 1 {
if err := rt.Output(results[0]); err != nil {
return err
}
if len(allFailures) > 0 {
return minutesDetailReadError(ids, allFailures)
}
return nil
}
payload := map[string]any{
"operation": "minutes.detail", "complete": len(allFailures) == 0,
"requested": len(ids), "succeeded": succeeded, "failed": len(ids) - succeeded,
"results": results, "failures": allFailures,
}
if err := rt.Output(payload); err != nil {
return err
}
if len(allFailures) > 0 {
return minutesDetailReadError(ids, allFailures)
}
return nil
},
}
func readMinutesDetail(rt *shortcut.RuntimeContext, taskUUID string, want []string, direction string) (map[string]any, []map[string]any) {
bundle := map[string]any{"taskUuid": taskUUID}
failures := make([]map[string]any, 0)
for _, raw := range want {
name := strings.ToLower(strings.TrimSpace(raw))
tool, ok := minutesArtifactTools[name]
if !ok {
continue
}
if name == "transcript" {
result, err := collectMinutesTranscript(rt, taskUUID, direction, rt.Str("cursor"), rt.Bool("single-page"), rt.Int("page-limit"))
transcript := minutesdata.TranscriptPayload(taskUUID, direction, result)
if err == nil && rt.Str("transcript-output") != "inline" {
raw, marshalErr := smartMinutesMarshalIndent(transcript, "", " ")
if marshalErr == nil {
raw = append(raw, '\n')
published, publishErr := smartMinutesPublishBytes(raw, localio.PublishBytesOptions{Output: strings.TrimSuffix(rt.Str("output-dir"), "/") + "/", PreferredName: taskUUID + ".json"})
if publishErr == nil {
transcript["path"] = published.RelativePath
transcript["sizeBytes"] = published.SizeBytes
transcript["fileComplete"] = true
if rt.Str("transcript-output") == "file" {
delete(transcript, "paragraphList")
transcript["inline"] = false
}
} else {
err = publishErr
}
} else {
err = marshalErr
}
}
bundle[name] = transcript
if err != nil {
failures = append(failures, map[string]any{"artifact": name, "error": err.Error()})
}
continue
}
data, err := rt.CallMCPData("minutes", tool, map[string]any{"taskUuid": taskUUID})
if err != nil {
bundle[name] = map[string]any{"error": err.Error()}
failures = append(failures, map[string]any{"artifact": name, "error": err.Error()})
continue
}
if err := minutesdata.ValidateArtifact(name, taskUUID, data); err != nil {
bundle[name] = map[string]any{"error": err.Error()}
failures = append(failures, map[string]any{"artifact": name, "error": err.Error()})
continue
}
bundle[name] = data
}
bundle["complete"] = len(failures) == 0
bundle["failureCount"] = len(failures)
if len(failures) > 0 {
bundle["failures"] = failures
}
return bundle, failures
}
func minutesDetailIDs(rt *shortcut.RuntimeContext) []string {
values := []string{rt.Str("id")}
values = append(values, rt.StrSlice("ids")...)
seen := map[string]bool{}
out := []string{}
for _, value := range values {
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item != "" && !seen[item] {
seen[item] = true
out = append(out, item)
}
}
}
return out
}
func minutesDetailReadError(ids []string, failures []map[string]any) error {
return apperrors.NewAPI(
fmt.Sprintf("听记详情读取不完整:%d 个产物失败", len(failures)),
apperrors.WithOperation("minutes/+detail"),
apperrors.WithOrigin("shortcut"),
apperrors.WithFailureStage("artifact_read"),
apperrors.WithReason("minutes_detail_incomplete"),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{"taskUuids": ids, "failures": failures}),
)
}
// minutesArtifactTools maps the user-facing artifact name to the real MCP tool
// (ground truth: internal/helpers/minutes.go). Each tool takes a single
// "taskUuid"; transcript additionally takes "direction".
@@ -140,5 +279,5 @@ var minutesArtifactTools = map[string]string{
var minutesArtifactOrder = []string{"basic", "summary", "keywords", "transcript", "todos"}
func init() {
shortcut.Register(MinutesDetail)
shortcut.Register(finalizeMinutesSmartShortcut(MinutesDetail))
}
+230
View File
@@ -0,0 +1,230 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func runMinutesCLI(t *testing.T, caller *smartCoverageCaller, args ...string) (map[string]any, string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := newPlatformCoverageRoot()
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetArgs(args)
err := root.Execute()
if stdout.Len() == 0 {
return nil, "", err
}
var payload map[string]any
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
}
return payload, stdout.String(), err
}
func TestCrossPlatformCoverageMinutesDetailPaginatesRealTranscriptShapeE2E(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"},{"paragraphId":"p2"}],"hasNext":true,"nextToken":"n2"}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2"},{"paragraphId":"p3"}],"hasNext":false,"nextToken":""}}`,
},
}}
payload, _, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript")
if err != nil {
t.Fatalf("detail: %v", err)
}
transcript := payload["transcript"].(map[string]any)
if payload["complete"] != true || transcript["complete"] != true || transcript["pages"] != float64(2) || transcript["paragraphCount"] != float64(3) || transcript["duplicateCount"] != float64(1) {
t.Fatalf("detail payload = %#v", payload)
}
args := caller.arguments["minutes/get_minutes_transcription"]
if len(args) != 2 || args[1]["nextToken"] != "n2" {
t.Fatalf("transcript calls = %#v", args)
}
}
func TestCrossPlatformCoverageMinutesDetailUnknownOrPartialTranscriptIsNotSuccessE2E(t *testing.T) {
tests := []struct {
name string
responses []string
failAt int
wantCount float64
}{
{name: "missing collection", responses: []string{`{"success":true,"result":{}}`}, wantCount: 0},
{name: "later page failure", responses: []string{`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`}, failAt: 2, wantCount: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &smartCoverageCaller{
responses: map[string][]string{"minutes/get_minutes_transcription": test.responses},
failAt: map[string]int{"minutes/get_minutes_transcription": test.failAt},
}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript")
if err == nil || output == "" || payload["complete"] != false || payload["failureCount"] != float64(1) {
t.Fatalf("incomplete detail accepted = payload:%#v output:%q err:%v", payload, output, err)
}
transcript := payload["transcript"].(map[string]any)
if transcript["complete"] != false || transcript["paragraphCount"] != test.wantCount {
t.Fatalf("incomplete transcript = %#v", transcript)
}
})
}
}
func TestCrossPlatformCoverageMinutesDetailBatchWritesVerifiedTranscriptFilesE2E(t *testing.T) {
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
work := t.TempDir()
if err := os.Chdir(work); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
caller := &smartCoverageCaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"one"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","paragraph":"two"}],"hasNext":false}}`,
},
}}
payload, _, err := runMinutesCLI(t, caller, "minutes", "+detail", "--ids", "u1,u2", "--artifacts", "transcript", "--transcript-output", "file", "--output-dir", "transcripts")
if err != nil || payload["complete"] != true || payload["requested"] != float64(2) || payload["succeeded"] != float64(2) {
t.Fatalf("batch detail=%#v err=%v", payload, err)
}
for _, result := range payload["results"].([]any) {
transcript := result.(map[string]any)["transcript"].(map[string]any)
if transcript["inline"] != false || transcript["fileComplete"] != true || transcript["paragraphList"] != nil {
t.Fatalf("file transcript=%#v", transcript)
}
path := transcript["path"].(string)
info, statErr := os.Stat(filepath.Join(work, filepath.FromSlash(path)))
if statErr != nil || info.Size() <= 0 || float64(info.Size()) != transcript["sizeBytes"] {
t.Fatalf("transcript file path=%q info=%#v err=%v", path, info, statErr)
}
}
}
func TestCrossPlatformCoverageMinutesDetailBatchPartialIsNonZeroE2E(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"one"}],"hasNext":false}}`,
`{"success":true,"result":{}}`,
},
}}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--ids", "u1,u2", "--artifacts", "transcript")
if err == nil || output == "" || payload["complete"] != false || payload["requested"] != float64(2) || payload["succeeded"] != float64(1) || payload["failed"] != float64(1) {
t.Fatalf("batch partial accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesSearchAndLatestRejectUnknownButAcceptItemListE2E(t *testing.T) {
unknown := &smartCoverageCaller{responses: map[string][]string{
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`},
}}
if payload, output, err := runMinutesCLI(t, unknown, "minutes", "+minutes-search", "--query", "weekly"); err == nil || payload != nil || output != "" {
t.Fatalf("unknown search accepted = payload:%#v output:%q err:%v", payload, output, err)
}
valid := &smartCoverageCaller{responses: map[string][]string{
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"weekly","startTime":1}]}}`},
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"weekly"}}`},
}}
_, _, err := runMinutesCLI(t, valid, "minutes", "+latest-minutes")
if err != nil {
t.Fatalf("latest itemList: %v", err)
}
if calls := valid.arguments["minutes/get_minutes_basic_info"]; len(calls) != 1 || calls[0]["taskUuid"] != "u1" {
t.Fatalf("latest readback calls = %#v", calls)
}
}
func TestCrossPlatformCoverageMinutesTranscriptSinglePagePublishesIncompleteCursorE2E(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`},
}}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+transcript", "--id", "u1", "--single-page")
if err != nil || output == "" || payload["complete"] != false || payload["nextToken"] != "n2" || payload["paragraphCount"] != float64(1) {
t.Fatalf("single page = payload:%#v output:%q err:%v", payload, output, err)
}
if strings.Contains(output, `"complete": true`) {
t.Fatalf("single page falsely complete: %s", output)
}
}
func TestCrossPlatformCoverageMinutesReplaceBatchPartialWriteIsNonZeroE2E(t *testing.T) {
for _, policy := range []struct {
name string
args []string
wantCalls int
wantApplied float64
wantUnattempted float64
}{
{name: "stop", wantCalls: 2, wantApplied: 1, wantUnattempted: 1},
{name: "continue", args: []string{"--failure-policy", "continue"}, wantCalls: 3, wantApplied: 2, wantUnattempted: 0},
} {
t.Run(policy.name, func(t *testing.T) {
caller := &smartCoverageCaller{
responses: map[string][]string{
"minutes/replace_minutes_text": {`{"success":true}`, `{"success":true}`, `{"success":true}`},
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"a b c"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"A b c"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"A b C"}],"hasNext":false}}`,
},
},
failAt: map[string]int{"minutes/replace_minutes_text": 2},
}
args := []string{"minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--pair", "b=>B", "--pair", "c=>C", "--yes"}
args = append(args, policy.args...)
payload, output, err := runMinutesCLI(t, caller, args...)
if err == nil || output == "" || payload["ok"] != false || payload["failed"] != float64(1) || payload["applied"] != policy.wantApplied || payload["unattempted"] != policy.wantUnattempted {
t.Fatalf("partial replace = payload:%#v output:%q err:%v", payload, output, err)
}
if got := caller.counts["minutes/replace_minutes_text"]; got != policy.wantCalls {
t.Fatalf("replace calls = %d, want %d", got, policy.wantCalls)
}
})
}
}
func TestCrossPlatformCoverageMinutesReplaceAcknowledgedButUnchangedReadbackIsNonZeroE2E(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
"minutes/replace_minutes_text": {`{"success":true}`},
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"a"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"a"}],"hasNext":false}}`,
},
}}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes")
if err == nil || output == "" || payload["ok"] != false || payload["failed"] != float64(1) || payload["applied"] != float64(0) {
t.Fatalf("unchanged readback accepted: payload=%#v output=%q err=%v", payload, output, err)
}
result := payload["results"].([]any)[0].(map[string]any)
if result["acknowledged"] != true || result["remoteEffectUnknown"] != true || result["verified"] == true {
t.Fatalf("unchanged result=%#v", result)
}
}
func TestCrossPlatformCoverageMinutesReplaceJSONDryRunDoesNotWriteE2E(t *testing.T) {
caller := &smartCoverageCaller{}
payload, output, err := runMinutesCLI(t, caller,
"minutes", "+replace-batch", "--id", "u1",
"--json", `[{"source":"a","replacement":"A"},{"originalText":"b","replacedText":"B"}]`,
"--dry-run")
if err != nil || output == "" || payload["dryRun"] != true || payload["executed"] != false || payload["total"] != float64(2) {
t.Fatalf("replace dry-run = payload:%#v output:%q err:%v", payload, output, err)
}
if caller.counts["minutes/replace_minutes_text"] != 0 {
t.Fatalf("dry-run performed writes: %#v", caller.counts)
}
}
+15 -40
View File
@@ -16,8 +16,8 @@ package smart
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// MinutesSearch: search MY minutes (听记) by keyword and print a projected list.
@@ -86,50 +86,25 @@ var MinutesSearch = shortcut.Shortcut{
return err
}
// Step 2 — project matched entries. latestMinutesItems (from
// latest_minutes.go) defensively unwraps the list container.
items := latestMinutesItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"title": minutesSearchTitle(m),
"createTime": minutesSearchCreateTime(m),
// latestMinutesUUID probes taskUuid/taskUUID/uuid/id in order.
"taskUuid": latestMinutesUUID(m),
})
// Step 2 — validate and project matched entries. An explicit [] is a
// successful empty search; an unknown/malformed shape is a contract error.
page, err := minutesdata.ParseListPage(data)
if err != nil {
return err
}
results, err := minutesdata.ProjectList(page)
if err != nil {
return err
}
// Step 3 — empty result guard.
if len(results) == 0 {
return apperrors.NewValidation("没搜到妙记")
}
return rt.Output(map[string]any{"minutes": results})
return rt.Output(map[string]any{"count": len(results), "minutes": results})
},
}
// minutesSearchTitle reads a minute's display title, tolerating the common
// title keys the gateway may use.
func minutesSearchTitle(m map[string]any) string {
for _, key := range []string{"title", "minutesTitle", "name", "subject"} {
if s, ok := m[key].(string); ok && s != "" {
return s
}
}
return ""
}
// minutesSearchCreateTime reads a minute's create time, returning the raw value
// (usually epoch millis) under whichever candidate key is present.
func minutesSearchCreateTime(m map[string]any) any {
for _, key := range []string{"createTime", "gmtCreate", "startTime", "createTimeStart"} {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
}
func init() {
shortcut.Register(MinutesSearch)
// Keep the historical Schema identity as a hidden compatibility
// leaf. +search is the only public discovery route, but removing this
// independently shipped ToolSpec would break existing Schema consumers.
shortcut.Register(finalizeMinutesSmartShortcut(MinutesSearch))
}
+245 -29
View File
@@ -14,6 +14,7 @@
package smart
import (
"encoding/json"
"fmt"
"strings"
@@ -21,7 +22,9 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// ReplaceBatch: apply MANY word replacements to one minute (听记) in a single
@@ -33,18 +36,19 @@ import (
// repeatedly and eyeballing each result. This shortcut takes multiple --pair
// "原文=>替换" entries, validates them (rejecting duplicate source words so two
// rules don't fight over the same term), applies each via replace_minutes_text
// and aggregates a per-pair {applied|error} report through rt.Output — one
// failing pair does not abort the rest.
// and aggregates a per-pair {applied|error} report through rt.Output. The
// default policy stops at the first error; explicit continue still returns
// non-zero when any write fails.
//
// dws minutes +replace-batch --id <taskUuid> --pair "张三=>张三丰" --pair "Q2=>第二季度"
var ReplaceBatch = shortcut.Shortcut{
Service: "minutes",
Command: "+replace-batch",
Product: "minutes",
Description: "对一条妙记(听记)批量执行多组文字替换(原文=>替换)",
Intent: "当你要在同一条听记里一次性纠正多个词(如把多个错识别的人名/术语统一替换),而底层工具一次只能替换一组时使用;" +
"内部按多个 --pair \"原文=>替换\" 逐组调用替换工具,先在本地校验去重(同一个「原文」不能出现两次,避免两条规则互相打架)," +
"再逐组应用并聚合每组的成功/失败结果,某一组失败不会中断其余组。这是写操作,会实际修改听记文字内容,请确认 taskUuid 与替换规则无误。",
Description: "预检并批量执行多组听记文字替换,逐项验证且失败必定非零",
Intent: "当你要在同一条听记里一次纠正多个错识别人名或术语时使用;支持重复 --pair,或通过 --json 接受字面量、@相对文件和 stdin。" +
"命令会先读取完整逐字稿并校验所有原文、空值和重复规则,再逐项写入并重新读取完整逐字稿验证效果。" +
"默认首错停止;只有显式 failure-policy=continue 才继续,但任一失败仍返回非零及 applied/failed/unattempted ledger。底层不是事务且没有自动回滚,并会同时影响逐字稿和摘要。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -58,7 +62,7 @@ var ReplaceBatch = shortcut.Shortcut{
CLIPath: "minutes +replace-batch",
PrimaryCLIPath: "minutes +replace-batch",
},
Description: "对一条妙记(听记)批量执行多组文字替换(原文=>替换)",
Description: "预检并批量执行多组听记文字替换,逐项验证且失败必定非零",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -73,34 +77,50 @@ var ReplaceBatch = shortcut.Shortcut{
"dws minutes +replace-batch --id <taskUuid> --pair \"Q2=>第二季度\" --pair \"PM=>产品经理\"",
},
},
DryRun: &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewPlan,
RemoteReads: false,
},
Parameters: []contract.ParamDecl{
{Name: "pair", Description: `替换规则,格式 "原文=>替换",可重复传多组(必填);每组原文不能为空且不能重复`},
{Name: "json", Description: "JSON 字面量、@工作目录相对文件或 - 表示 stdin"},
{Name: "failure-policy", Description: "stop=首错停止;continue=显式继续,但只要有失败仍返回非零"},
},
},
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true},
{Name: "pair", Type: shortcut.FlagStringSlice, Desc: `替换规则,格式 "原文=>替换",可重复传多组(必填)`, Required: true},
{Name: "pair", Type: shortcut.FlagStringSlice, Desc: `替换规则,格式 "原文=>替换",可重复传多组`},
{Name: "json", Type: shortcut.FlagString, Desc: "替换规则 JSON 字面量、@相对文件或 - 表示 stdin"},
{Name: "failure-policy", Type: shortcut.FlagString, Default: "stop", Desc: "失败策略", Enum: []string{"stop", "continue"}},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿写前/写后验证的翻页上限"},
},
Constraints: []shortcut.Constraint{
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"pair"},
Description: `每个 --pair 必须使用 "原文=>替换" 格式,原文不能为空且不能重复`,
Flags: []string{"pair", "json"},
Description: `--pair 与 --json 至少提供一种;规则必须使用 "原文=>替换" 或 JSON 数组;原文不能为空且不能重复`,
},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
},
Tips: []string{
`dws minutes +replace-batch --id <taskUuid> --pair "张三=>张三丰"`,
`dws minutes +replace-batch --id <taskUuid> --pair "Q2=>第二季度" --pair "PM=>产品经理"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
_, err := parseReplacePairs(rt.StrSlice("pair"))
return err
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
if len(rt.StrSlice("pair")) > 0 {
_, err := parseReplacePairs(rt.StrSlice("pair"))
return err
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
taskUUID := rt.Str("id")
pairs, err := parseReplacePairs(rt.StrSlice("pair"))
pairs, err := loadReplacePairs(rt)
if err != nil {
return err // already validated, but stay defensive
return err
}
if rt.DryRun() {
replacements := make([]map[string]any, 0, len(pairs))
@@ -111,40 +131,109 @@ var ReplaceBatch = shortcut.Shortcut{
})
}
return rt.Output(map[string]any{
"dryRun": true,
"taskUuid": taskUUID,
"total": len(pairs),
"replacements": replacements,
"operation": "minutes.replace_batch",
"dry_run": true,
"dryRun": true,
"preview_kind": contract.DryRunPreviewPlan,
"executed": false,
"failurePolicy": rt.Str("failure-policy"),
"taskUuid": taskUUID,
"total": len(pairs),
"replacements": replacements,
})
}
currentText, err := minutesReplaceTranscriptText(rt, taskUUID, rt.Int("page-limit"))
if err != nil {
return err
}
for _, pair := range pairs {
if strings.Count(currentText, pair.orig) == 0 {
return apperrors.NewValidation(fmt.Sprintf("完整逐字稿中不存在待替换原文 %q;未执行任何写入", pair.orig))
}
}
results := make([]map[string]any, 0, len(pairs))
applied := 0
for _, p := range pairs {
failed := 0
unattempted := 0
for index, p := range pairs {
// Params mirror helpers replace_minutes_text call site: taskUuid /
// originalText / replacedText.
_, callErr := rt.CallMCPWriteData("minutes", "replace_minutes_text", map[string]any{
writeData, callErr := rt.CallMCPWriteDataStrict("minutes", "replace_minutes_text", map[string]any{
"taskUuid": taskUUID,
"originalText": p.orig,
"replacedText": p.repl,
})
if callErr == nil {
callErr = minutesdata.RequireWriteAcknowledgement("replace text", writeData)
}
entry := map[string]any{"originalText": p.orig, "replacedText": p.repl}
acknowledged := callErr == nil
if acknowledged {
entry["acknowledged"] = true
beforeSource := strings.Count(currentText, p.orig)
beforeTarget := strings.Count(currentText, p.repl)
afterText, readErr := minutesReplaceTranscriptText(rt, taskUUID, rt.Int("page-limit"))
if readErr != nil {
callErr = fmt.Errorf("写入已确认但逐字稿读回失败: %w", readErr)
} else if !replaceReadbackVerified(currentText, afterText, p, beforeSource, beforeTarget) {
callErr = fmt.Errorf("写入已确认但逐字稿读回未证明替换生效")
} else {
currentText = afterText
entry["verified"] = true
}
}
if callErr != nil {
entry["error"] = callErr.Error()
entry["remoteEffectUnknown"] = acknowledged
failed++
results = append(results, entry)
if rt.Str("failure-policy") == "stop" {
for _, pending := range pairs[index+1:] {
results = append(results, map[string]any{
"originalText": pending.orig,
"replacedText": pending.repl,
"unattempted": true,
})
unattempted++
}
break
}
} else {
entry["applied"] = true
applied++
results = append(results, entry)
}
results = append(results, entry)
}
return rt.Output(map[string]any{
"taskUuid": taskUUID,
"total": len(pairs),
"applied": applied,
"failed": len(pairs) - applied,
"results": results,
})
payload := map[string]any{
"ok": failed == 0,
"partial": applied > 0 && (failed > 0 || unattempted > 0),
"taskUuid": taskUUID,
"failurePolicy": rt.Str("failure-policy"),
"total": len(pairs),
"applied": applied,
"failed": failed,
"unattempted": unattempted,
"results": results,
}
if err := rt.Output(payload); err != nil {
return err
}
if failed > 0 {
return apperrors.NewAPI(
fmt.Sprintf("批量替换未完成:成功 %d、失败 %d、未执行 %d", applied, failed, unattempted),
apperrors.WithOperation("minutes/replace_minutes_text"),
apperrors.WithOrigin("shortcut"),
apperrors.WithFailureStage("write"),
apperrors.WithReason("minutes_replace_partial_failure"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithDetails(payload),
)
}
return nil
},
}
@@ -183,6 +272,133 @@ func parseReplacePairs(raw []string) ([]replacePair, error) {
return pairs, nil
}
func loadReplacePairs(rt *shortcut.RuntimeContext) ([]replacePair, error) {
pairs, err := parseReplacePairs(rt.StrSlice("pair"))
if err != nil {
return nil, err
}
if rt.Changed("json") {
text, err := localio.ReadTextInput(rt.Str("json"), rt.Command().InOrStdin(), 8<<20)
if err != nil {
return nil, apperrors.NewValidation(err.Error())
}
jsonPairs, err := parseReplaceJSON(text)
if err != nil {
return nil, err
}
pairs = append(pairs, jsonPairs...)
}
return validateReplacePairs(pairs)
}
func parseReplaceJSON(raw string) ([]replacePair, error) {
type wirePair struct {
Source string `json:"source"`
Replacement string `json:"replacement"`
OriginalText string `json:"originalText"`
ReplacedText string `json:"replacedText"`
}
var direct []wirePair
if err := json.Unmarshal([]byte(raw), &direct); err != nil {
var envelope struct {
Replacements []wirePair `json:"replacements"`
}
if envelopeErr := json.Unmarshal([]byte(raw), &envelope); envelopeErr != nil || envelope.Replacements == nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--json 必须是替换规则数组或 {\"replacements\":[...]}: %v", err))
}
direct = envelope.Replacements
}
if len(direct) == 0 {
return nil, apperrors.NewValidation("--json 至少需要一条替换规则")
}
pairs := make([]replacePair, 0, len(direct))
for index, item := range direct {
source := item.Source
if source == "" {
source = item.OriginalText
}
replacement := item.Replacement
if replacement == "" {
replacement = item.ReplacedText
}
if strings.TrimSpace(source) == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--json 第 %d 条规则缺少 source/originalText", index))
}
pairs = append(pairs, replacePair{orig: strings.TrimSpace(source), repl: strings.TrimSpace(replacement)})
}
return pairs, nil
}
func validateReplacePairs(pairs []replacePair) ([]replacePair, error) {
if len(pairs) == 0 {
return nil, apperrors.NewValidation("至少需要一条替换规则")
}
seen := map[string]bool{}
for _, pair := range pairs {
if strings.TrimSpace(pair.orig) == "" {
return nil, apperrors.NewValidation("替换规则的原文不能为空")
}
if seen[pair.orig] {
return nil, apperrors.NewValidation(fmt.Sprintf("重复的「原文」%q:同一个原文只能有一条替换规则", pair.orig))
}
seen[pair.orig] = true
}
return pairs, nil
}
func minutesReplaceTranscriptText(rt *shortcut.RuntimeContext, taskUUID string, pageLimit int) (string, error) {
result, err := collectMinutesTranscript(rt, taskUUID, "0", "", false, pageLimit)
if err != nil {
return "", err
}
if len(result.Paragraphs) == 0 {
return "", fmt.Errorf("逐字稿为空,无法证明文字替换目标或写入结果")
}
parts := make([]string, 0, len(result.Paragraphs))
var collect func(any)
collect = func(value any) {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
switch key {
case "paragraph", "sentence", "word", "text", "content":
if text, ok := child.(string); ok && text != "" {
parts = append(parts, text)
}
default:
collect(child)
}
}
case []any:
for _, child := range typed {
collect(child)
}
}
}
for _, paragraph := range result.Paragraphs {
collect(paragraph)
}
if len(parts) == 0 {
return "", fmt.Errorf("逐字稿没有可验证的文本字段")
}
return strings.Join(parts, "\n"), nil
}
func replaceReadbackVerified(before, after string, pair replacePair, beforeSource, beforeTarget int) bool {
if before == after {
return false
}
afterSource := strings.Count(after, pair.orig)
if pair.repl == "" {
return afterSource < beforeSource
}
afterTarget := strings.Count(after, pair.repl)
if strings.Contains(pair.repl, pair.orig) {
return afterTarget > beforeTarget
}
return afterSource < beforeSource && afterTarget > beforeTarget
}
func init() {
shortcut.Register(ReplaceBatch)
shortcut.Register(finalizeMinutesSmartShortcut(ReplaceBatch))
}
+95 -33
View File
@@ -14,8 +14,12 @@
package smart
import (
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// Transcript: fetch the verbatim transcript (逐字稿 / 语音转写原文) of MY most
@@ -38,41 +42,66 @@ var Transcript = shortcut.Shortcut{
Service: "minutes",
Command: "+transcript",
Product: "minutes",
Description: "取我最新一条妙记(听记)的逐字稿(语音转写原文)",
Intent: "当你想直接读回自己最近一次会议听记的逐字稿(完整语音转写原文),又不想先翻列表、复制 taskUuid 再单独查转写时使用;" +
"内部先列出你创建的听记(可用 --keyword 缩小范围),自动挑出最新的一条,再拉取它的逐字记录(每条含发言人、文本、时间戳)。" +
"可用 --direction 控制排序:0=正序(时间递增,默认),1=倒序。这是只读操作,不会修改任何听记;" +
"若你名下没有任何听记则提示「暂无妙记」。",
Description: "读取指定或我最新一条听记的完整逐字稿,并交付分页完整性证据",
Intent: "当你要读取已知 taskUuid(--id)的完整逐字稿,或不传 --id 自动选择自己最新听记时使用;" +
"默认追完 nextToken、跨页去重并输出 complete/pages/nextToken,只有显式 --single-page 才停在一页。" +
"可用 --direction 控制排序,--keyword 仅在自动选择最新听记时缩小候选;任何分页漂移或中途失败都返回非零而不是把部分原文当成全集。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false},
{Name: "direction", Type: shortcut.FlagString, Desc: "排序方向: 0=正序(默认), 1=倒序(可选)", Required: false},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: minutesSmartContract(
"+transcript",
"读取指定或最新听记的完整逐字稿",
"需要读取逐字稿并自动追完 nextToken、去重段落,同时看到 complete/pages/nextToken 完整性证据时使用;不传 --id 时严格选择最新听记。",
[]string{"只需单个原始分页响应时使用底层转写命令;需要汇总多个制品时使用 +detail"},
[]string{"dws minutes +transcript --id <taskUuid>", "dws minutes +transcript --keyword 周会"},
nil,
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid;不传时选择我最新的一条"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false},
{Name: "direction", Type: shortcut.FlagString, Desc: "排序方向: 0=正序(默认), 1=倒序(可选)", Required: false, Enum: []string{"0", "1"}},
{Name: "cursor", Type: shortcut.FlagString, Desc: "单页/续拉的起始 nextToken"},
{Name: "single-page", Type: shortcut.FlagBool, Desc: "只读取一页;输出 complete/nextToken"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "自动翻页安全上限"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"}},
Tips: []string{
`dws minutes +transcript`,
`dws minutes +transcript --id <taskUuid>`,
`dws minutes +transcript --keyword 周会`,
`dws minutes +transcript --direction 1`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my minutes (newest first). belongingConditionId /
// maxResults / keyword mirror helpers.callListByKeywordRange.
listArgs := map[string]any{
"belongingConditionId": "created",
"maxResults": float64(20),
}
if kw := rt.Str("keyword"); kw != "" {
listArgs["keyword"] = kw
}
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", listArgs)
if err != nil {
return err
}
// Step 2 — locate the newest minute's taskUuid (reuses helper from
// latest_minutes.go).
taskUUID := latestMinutesTaskUUID(data)
taskUUID := rt.Str("id")
if taskUUID == "" {
return apperrors.NewValidation("暂无妙记")
// Step 1 — list my minutes (newest first). belongingConditionId /
// maxResults / keyword mirror helpers.callListByKeywordRange.
listArgs := map[string]any{
"belongingConditionId": "created",
"maxResults": float64(20),
}
if kw := rt.Str("keyword"); kw != "" {
listArgs["keyword"] = kw
}
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", listArgs)
if err != nil {
return err
}
// Step 2 — locate the newest minute's taskUuid.
taskUUID, err = latestMinutesTaskUUID(data)
if err != nil {
return err
}
if taskUUID == "" {
return apperrors.NewValidation("暂无妙记")
}
}
// Step 3 — print its verbatim transcript. taskUuid + direction mirror
@@ -82,13 +111,46 @@ var Transcript = shortcut.Shortcut{
if direction == "" {
direction = "0"
}
return rt.CallMCP("get_minutes_transcription", map[string]any{
"taskUuid": taskUUID,
"direction": direction,
})
result, readErr := collectMinutesTranscript(rt, taskUUID, direction, rt.Str("cursor"), rt.Bool("single-page"), rt.Int("page-limit"))
payload := minutesdata.TranscriptPayload(taskUUID, direction, result)
if err := rt.Output(payload); err != nil {
return err
}
if readErr != nil {
return minutesTranscriptReadError(taskUUID, result, readErr)
}
return nil
},
}
func collectMinutesTranscript(rt *shortcut.RuntimeContext, taskUUID, direction, cursor string, singlePage bool, pageLimit int) (minutesdata.TranscriptResult, error) {
return minutesdata.CollectTranscript(func(nextToken string) (map[string]any, error) {
params := map[string]any{"taskUuid": taskUUID, "direction": direction}
if nextToken != "" {
params["nextToken"] = nextToken
}
return rt.CallMCPData("minutes", "get_minutes_transcription", params)
}, cursor, singlePage, pageLimit)
}
func minutesTranscriptReadError(taskUUID string, result minutesdata.TranscriptResult, cause error) error {
return apperrors.NewAPI(
fmt.Sprintf("逐字稿读取不完整:已读取 %d 页、%d 个段落", result.Pages, len(result.Paragraphs)),
apperrors.WithOperation("minutes/get_minutes_transcription"),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("pagination"),
apperrors.WithReason("minutes_transcript_incomplete"),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{
"taskUuid": taskUUID,
"pages": result.Pages,
"paragraphCount": len(result.Paragraphs),
"nextToken": result.NextToken,
"cause": cause.Error(),
}),
)
}
func init() {
shortcut.Register(Transcript)
shortcut.Register(finalizeMinutesSmartShortcut(Transcript))
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package skillprovenance builds the per-Skill provenance records stored in
// the single ~/.dws/skills-state.json metadata file.
package skillprovenance
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
const (
DigestScope = "skill-directory-v1"
SourceSkillSetup = "dws-skill-setup"
SourceUpgrade = "dws-upgrade"
)
// Record identifies one DWS-managed Skill. Ownership is derived from these
// records, never from files placed inside an Agent's Skill directory.
type Record struct {
Name string `json:"name"`
Version string `json:"version"`
Source string `json:"source"`
Digest string `json:"digest"`
DigestScope string `json:"digest_scope"`
}
var (
provenanceWalkDir = filepath.WalkDir
provenanceRelative = filepath.Rel
provenanceReadFile = os.ReadFile
)
// Build returns a deterministic provenance record for a Skill directory.
func Build(name, dir, version, source string) (Record, error) {
name = strings.TrimSpace(name)
if name == "" {
return Record{}, fmt.Errorf("provenance Skill name is empty")
}
digest, err := DigestDir(dir)
if err != nil {
return Record{}, err
}
version = strings.TrimSpace(version)
if version == "" {
version = "unknown"
}
source = strings.TrimSpace(source)
if source == "" {
return Record{}, fmt.Errorf("provenance source is empty")
}
return Record{
Name: name,
Version: version,
Source: source,
Digest: digest,
DigestScope: DigestScope,
}, nil
}
// DigestDir hashes every regular file ordered by slash-normalized relative
// path. Paths and contents are NUL-delimited.
func DigestDir(root string) (string, error) {
var paths []string
err := provenanceWalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
rel, err := provenanceRelative(root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if !entry.Type().IsRegular() {
return fmt.Errorf("unsupported non-regular Skill file: %s", path)
}
paths = append(paths, rel)
return nil
})
if err != nil {
return "", err
}
sort.Strings(paths)
hash := sha256.New()
for _, rel := range paths {
_, _ = hash.Write([]byte(rel))
_, _ = hash.Write([]byte{0})
content, err := provenanceReadFile(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
return "", err
}
_, _ = hash.Write(content)
_, _ = hash.Write([]byte{0})
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
}
// Merge replaces records with matching names and returns a name-sorted set.
func Merge(existing, updates []Record) []Record {
byName := make(map[string]Record, len(existing)+len(updates))
for _, record := range existing {
if strings.TrimSpace(record.Name) != "" {
byName[record.Name] = record
}
}
for _, record := range updates {
if strings.TrimSpace(record.Name) != "" {
byName[record.Name] = record
}
}
names := make([]string, 0, len(byName))
for name := range byName {
names = append(names, name)
}
sort.Strings(names)
out := make([]Record, 0, len(names))
for _, name := range names {
out = append(out, byName[name])
}
return out
}
func Names(records []Record) map[string]bool {
names := make(map[string]bool, len(records))
for _, record := range records {
if record.Name != "" {
names[record.Name] = true
}
}
return names
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package skillprovenance
import (
"errors"
"io/fs"
"os"
"path/filepath"
"reflect"
"testing"
)
type fakeDirEntry struct {
name string
mode fs.FileMode
}
func (entry fakeDirEntry) Name() string { return entry.name }
func (entry fakeDirEntry) IsDir() bool { return entry.mode.IsDir() }
func (entry fakeDirEntry) Type() fs.FileMode { return entry.mode.Type() }
func (entry fakeDirEntry) Info() (fs.FileInfo, error) { return nil, errors.New("unused") }
func swapProvenanceSeam[T any](t *testing.T, target *T, replacement T) {
t.Helper()
original := *target
*target = replacement
t.Cleanup(func() { *target = original })
}
func TestCrossPlatformCoverageSkillProvenanceBuildDigestAndMerge(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("skill"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "references", "a.md"), []byte("ref"), 0o644); err != nil {
t.Fatal(err)
}
record, err := Build("dingtalk-a", dir, "", SourceUpgrade)
if err != nil {
t.Fatal(err)
}
if record.Version != "unknown" || record.Source != SourceUpgrade || record.DigestScope != DigestScope {
t.Fatalf("record = %#v", record)
}
first := record.Digest
if err := os.WriteFile(filepath.Join(dir, "references", "a.md"), []byte("changed"), 0o644); err != nil {
t.Fatal(err)
}
second, err := Build("dingtalk-a", dir, "v2", SourceSkillSetup)
if err != nil || second.Digest == first {
t.Fatalf("second = %#v, %v", second, err)
}
merged := Merge([]Record{record, {Name: "dingtalk-b"}}, []Record{second, {}})
if !reflect.DeepEqual(merged, []Record{second, {Name: "dingtalk-b"}}) {
t.Fatalf("merged = %#v", merged)
}
if got := Names(merged); !got["dingtalk-a"] || !got["dingtalk-b"] || len(got) != 2 {
t.Fatalf("names = %#v", got)
}
if _, err := Build("", dir, "v1", "test"); err == nil {
t.Fatal("empty name accepted")
}
if _, err := Build("dingtalk-a", dir, "v1", " "); err == nil {
t.Fatal("empty source accepted")
}
if _, err := Build("dingtalk-a", filepath.Join(dir, "missing"), "v1", "test"); err == nil {
t.Fatal("missing Skill directory accepted")
}
}
func TestCrossPlatformCoverageSkillProvenanceDigestErrors(t *testing.T) {
want := errors.New("injected")
t.Run("walk", func(t *testing.T) {
swapProvenanceSeam(t, &provenanceWalkDir, func(string, fs.WalkDirFunc) error { return want })
if _, err := DigestDir(t.TempDir()); !errors.Is(err, want) {
t.Fatal(err)
}
})
t.Run("callback", func(t *testing.T) {
swapProvenanceSeam(t, &provenanceWalkDir, func(root string, walk fs.WalkDirFunc) error { return walk(root, nil, want) })
if _, err := DigestDir(t.TempDir()); !errors.Is(err, want) {
t.Fatal(err)
}
})
t.Run("relative", func(t *testing.T) {
swapProvenanceSeam(t, &provenanceWalkDir, func(root string, walk fs.WalkDirFunc) error {
return walk(filepath.Join(root, "file"), fakeDirEntry{name: "file"}, nil)
})
swapProvenanceSeam(t, &provenanceRelative, func(string, string) (string, error) { return "", want })
if _, err := DigestDir(t.TempDir()); !errors.Is(err, want) {
t.Fatal(err)
}
})
t.Run("nonregular", func(t *testing.T) {
swapProvenanceSeam(t, &provenanceWalkDir, func(root string, walk fs.WalkDirFunc) error {
return walk(filepath.Join(root, "link"), fakeDirEntry{name: "link", mode: fs.ModeSymlink}, nil)
})
if _, err := DigestDir(t.TempDir()); err == nil {
t.Fatal("nonregular accepted")
}
})
t.Run("read", func(t *testing.T) {
dir := t.TempDir()
_ = os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("x"), 0o644)
swapProvenanceSeam(t, &provenanceReadFile, func(string) ([]byte, error) { return nil, want })
if _, err := DigestDir(dir); !errors.Is(err, want) {
t.Fatal(err)
}
})
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package skillstate persists the official Skill snapshot and centralized
// ownership metadata written after setup and upgrade. Bundled skills are
// always fully refreshed from the current release and local absence is not an
// exclusion.
package skillstate
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
)
const stateFile = "skills-state.json"
// legacyOfficialSkillNames is the frozen set of official multi-skill names
// shipped before centralized ownership metadata. Exact names are safe
// migration evidence; a dingtalk-* prefix is not.
//
// Keep retired names here permanently so an old installation can still be
// migrated after that Skill has been folded into another bundle.
var legacyOfficialSkillNames = map[string]struct{}{
"dingtalk-agoal": {},
"dingtalk-aiapp": {},
"dingtalk-aisearch": {},
"dingtalk-aitable": {},
"dingtalk-attendance": {},
"dingtalk-calendar": {},
"dingtalk-chat": {},
"dingtalk-contact": {},
"dingtalk-dev": {},
"dingtalk-devapp": {},
"dingtalk-devdoc": {},
"dingtalk-ding": {},
"dingtalk-doc": {},
"dingtalk-drive": {},
"dingtalk-event": {},
"dingtalk-hrbrain": {},
"dingtalk-live": {},
"dingtalk-mail": {},
"dingtalk-markdown": {},
"dingtalk-minutes": {},
"dingtalk-misc": {},
"dingtalk-oa": {},
"dingtalk-pat": {},
"dingtalk-profile": {},
"dingtalk-report": {},
"dingtalk-shared": {},
"dingtalk-sheet": {},
"dingtalk-skill": {},
"dingtalk-todo": {},
"dingtalk-wiki": {},
"dws-shared": {},
}
var (
skillStateReadFile = os.ReadFile
skillStateRemove = os.Remove
)
type State struct {
Version string `json:"version"`
OfficialSkills []string `json:"official_skills"`
UpdatedSkills []string `json:"updated_skills"`
ManagedSkills []skillprovenance.Record `json:"managed_skills"`
UpdatedAt string `json:"updated_at"`
}
// IsLegacyOfficialSkillName reports whether name was an exact official
// multi-skill directory name before centralized ownership metadata shipped.
func IsLegacyOfficialSkillName(name string) bool {
_, ok := legacyOfficialSkillNames[name]
return ok
}
func Path(home string) string {
if configured := strings.TrimSpace(os.Getenv("DWS_CONFIG_DIR")); configured != "" {
return filepath.Join(configured, stateFile)
}
return filepath.Join(home, ".dws", stateFile)
}
func Read(home string) (*State, bool, error) {
data, err := skillStateReadFile(Path(home))
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return nil, false, fmt.Errorf("skill 状态不可读: %w", err)
}
return &state, true, nil
}
func Write(home string, state State) error {
state.OfficialSkills = uniqueSorted(state.OfficialSkills)
state.UpdatedSkills = uniqueSorted(state.UpdatedSkills)
state.ManagedSkills = skillprovenance.Merge(nil, state.ManagedSkills)
data, _ := json.MarshalIndent(state, "", " ")
if err := helpers.AtomicWriteJSON(Path(home), append(data, '\n')); err != nil {
return fmt.Errorf("保存 skill 状态失败: %w", err)
}
return nil
}
func ManagedSkillNames(state *State) map[string]bool {
if state == nil {
return map[string]bool{}
}
return skillprovenance.Names(state.ManagedSkills)
}
func Remove(home string) error {
if err := skillStateRemove(Path(home)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("清理 skill 状态失败: %w", err)
}
return nil
}
func uniqueSorted(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Strings(out)
return out
}
+97
View File
@@ -0,0 +1,97 @@
package skillstate
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillStateReadWriteRemoveAndErrors(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", "")
home := t.TempDir()
if state, readable, err := Read(home); err != nil || readable || state != nil {
t.Fatalf("missing = %#v, %v, %v", state, readable, err)
}
want := State{
Version: "1.2.3",
OfficialSkills: []string{"dingtalk-b", "dingtalk-a", "dingtalk-a"},
UpdatedSkills: []string{"dingtalk-a"},
ManagedSkills: []skillprovenance.Record{
{Name: "dingtalk-b", Version: "old"},
{Name: "dingtalk-a", Version: "1"},
{Name: "dingtalk-b", Version: "2"},
},
}
if err := Write(home, want); err != nil {
t.Fatal(err)
}
got, readable, err := Read(home)
if err != nil || !readable || !reflect.DeepEqual(got.OfficialSkills, []string{"dingtalk-a", "dingtalk-b"}) {
t.Fatalf("round trip = %#v, %v, %v", got, readable, err)
}
if !reflect.DeepEqual(got.ManagedSkills, []skillprovenance.Record{{Name: "dingtalk-a", Version: "1"}, {Name: "dingtalk-b", Version: "2"}}) {
t.Fatalf("managed skills = %#v", got.ManagedSkills)
}
if names := ManagedSkillNames(got); !reflect.DeepEqual(names, map[string]bool{"dingtalk-a": true, "dingtalk-b": true}) {
t.Fatalf("managed names = %#v", names)
}
if names := ManagedSkillNames(nil); len(names) != 0 {
t.Fatalf("nil managed names = %#v", names)
}
if err := Remove(home); err != nil {
t.Fatal(err)
}
if err := Remove(home); err != nil {
t.Fatal(err)
}
badHome := t.TempDir()
if err := os.MkdirAll(filepath.Dir(Path(badHome)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(Path(badHome), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := Read(badHome); err == nil || !strings.Contains(err.Error(), "不可读") {
t.Fatalf("malformed = %v", err)
}
blocked := filepath.Join(t.TempDir(), "home-file")
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := Write(blocked, State{}); err == nil {
t.Fatal("blocked write succeeded")
}
failure := errors.New("denied")
testseam.Swap(t, &skillStateReadFile, func(string) ([]byte, error) { return nil, failure })
if _, _, err := Read(blocked); !errors.Is(err, failure) {
t.Fatal("blocked read succeeded")
}
testseam.Swap(t, &skillStateRemove, func(string) error { return failure })
if err := Remove(blocked); !errors.Is(err, failure) {
t.Fatal("blocked remove succeeded")
}
configured := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", " "+configured+" ")
if Path("ignored") != filepath.Join(configured, stateFile) {
t.Fatal("configured path ignored")
}
}
func TestCrossPlatformCoverageIsLegacyOfficialSkillName(t *testing.T) {
for _, name := range []string{"dingtalk-aitable", "dingtalk-devdoc", "dws-shared"} {
if !IsLegacyOfficialSkillName(name) {
t.Fatalf("historical official Skill %q not recognized", name)
}
}
for _, name := range []string{"dingtalk-custom", "other-skill", ""} {
if IsLegacyOfficialSkillName(name) {
t.Fatalf("user Skill %q must not be treated as historical official", name)
}
}
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// SwapUserHomeDirForTest swaps the home-dir seam used by UpgradeSkillLocations
// and related upgrade path helpers. Restored via t.Cleanup; not safe with
// t.Parallel.
func SwapUserHomeDirForTest(t *testing.T, fn func() (string, error)) {
t.Helper()
testseam.Swap(t, &upgradeUserHomeDir, fn)
}
+671 -15
View File
@@ -4,10 +4,17 @@
package upgrade
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
)
// Permission constants following Unix best practices.
@@ -50,13 +57,83 @@ var knownSkillDirs = []string{
}
var (
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeRemoveAll = os.RemoveAll
upgradeMkdirAll = os.MkdirAll
upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir
upgradeStat = os.Stat
upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
upgradeWriteSkillState = skillstate.Write
upgradeNow = time.Now
)
// skillBackupSubdir is the user-level directory where skill directories are
// preserved before a layout-changing install/upgrade removes them. Non-
// interactive flows (install scripts, npm postinstall, `dws upgrade`) cannot
// ask for confirmation, so deletions must stay reversible instead.
const skillBackupSubdir = ".dws/skill-backups"
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel> instead of destroying it, and returns the backup path. It is
// fail-safe: a directory that cannot be backed up is NOT removed and the
// error is returned so the caller can surface it (and never install the
// opposite layout next to it silently). Missing paths and regular files are
// no-ops ("", nil).
func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
info, err := upgradeStat(dir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err)
}
if !info.IsDir() {
return "", nil
}
rel, relErr := filepath.Rel(homeDir, dir)
if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") {
rel = filepath.Base(dir)
}
name := strings.NewReplacer(string(filepath.Separator), "-", "/", "-").Replace(rel)
stamp := upgradeBackupStamp()
backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp)
target := filepath.Join(backupRoot, name)
for i := 1; ; i++ {
if _, err := os.Stat(target); os.IsNotExist(err) {
break
}
backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i))
target = filepath.Join(backupRoot, name)
if i > 1000 {
return "", fmt.Errorf("备份目录冲突无法解决: %s", target)
}
}
if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil {
return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
}
if err := upgradeRename(dir, target); err != nil {
return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err)
}
// Keep the backup directory bounded; a prune failure must not fail the
// install (the backup itself succeeded).
_ = pruneSkillBackups(homeDir)
return target, nil
}
// BackupAndRemoveSkillDir is the exported wrapper over
// backupAndRemoveSkillDir for callers outside the upgrade package (the
// skill-setup channel in internal/app).
func BackupAndRemoveSkillDir(homeDir, dir string) (string, error) {
return backupAndRemoveSkillDir(homeDir, dir)
}
// skillDirBlacklist contains parent directories whose skills are managed by
// external mechanisms (e.g. IDE extensions) and must NOT be touched by upgrade.
var skillDirBlacklist = []string{
@@ -107,22 +184,341 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
return out
}
// UpgradeSkillLocations installs skills from extractedDir into all locations
// where they are currently installed or expected.
// UpgradeSkillLocations refreshes skills from extractedDir into agent homes.
// extractedDir may be a multi-skill bundle root (subdirectories each containing
// SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that
// resolve a release zip usually pass LocateSkillsRoot's result (multi/
// preferred when present).
//
// Package-driven layout:
// - release zip has multi/ → install and overwrite the complete official
// bundle. Locally deleted bundled skills are restored on the next upgrade;
// local absence is never treated as a persistent exclusion.
// - dingtalk-shared is mandatory whenever it exists in the bundle.
// - legacy zip with no multi tree → mono refresh path (unchanged fallback)
//
// Fresh install defaults to multi with opt-in mono via the interactive
// `dws skill setup --mode mono` flow.
//
// Strategy (matches npm install.js installSkillsToHomes):
// - ~/.agents/skills/dws/ is ALWAYS updated (primary install location)
// - ~/.agents/skills/ is ALWAYS updated (primary install location)
// - Other agent dirs (claude, cursor, ...) are updated only when the parent
// directory exists (e.g. ~/.claude/ exists => user has Claude)
// - ~/.real/ and other blacklisted paths are NEVER touched
// - If no location was updated at all, fall back to ~/.agents/skills/dws/
// - If no location was updated at all, fall back to ~/.agents/skills/
//
// Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then
// removed so mono and multi never co-exist after an upgrade; a leftover that
// cannot be backed up is never removed and fails that home. Same-name bundle
// skills are refreshed in place (verified DWS-managed overwrite). Caches
// under ~/.dws/skills/{multi,mono} are refreshed best-effort.
func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
return UpgradeSkillLocationsWithOptions(extractedDir, SkillUpgradeOptions{})
}
type SkillUpgradeOptions struct {
Version string
}
func UpgradeSkillLocationsWithOptions(extractedDir string, opts SkillUpgradeOptions) (*SkillUpgradeResult, error) {
homeDir, err := upgradeUserHomeDir()
if err != nil {
return nil, err
}
multiRoot, skills := resolveMultiBundle(extractedDir)
if len(skills) > 0 {
official := append([]string(nil), skills...)
managedSkills, provenanceErr := buildUpgradeProvenanceRecords(multiRoot, official, opts.Version)
if provenanceErr != nil {
return nil, fmt.Errorf("生成统一 Skill provenance 失败: %w", provenanceErr)
}
result, installErr := upgradeMultiSkillLocations(homeDir, multiRoot, official)
if installErr != nil {
return result, installErr
}
if len(result.Failed()) == 0 && len(result.Succeeded()) > 0 {
state := skillstate.State{
Version: opts.Version,
OfficialSkills: official,
UpdatedSkills: official,
ManagedSkills: managedSkills,
UpdatedAt: upgradeNow().UTC().Format(time.RFC3339),
}
if writeErr := upgradeWriteSkillState(homeDir, state); writeErr != nil {
return result, fmt.Errorf("skill 已同步但状态未写入: %w", writeErr)
}
}
return result, nil
}
monoSrc := resolveMonoSkillSrc(extractedDir)
if monoSrc != "" {
return upgradeMonoSkillLocations(homeDir, monoSrc)
}
return nil, fmt.Errorf("升级包中找不到可安装的 skill 源")
}
func buildUpgradeProvenanceRecords(root string, names []string, version string) ([]skillprovenance.Record, error) {
records := make([]skillprovenance.Record, 0, len(names))
for _, name := range names {
record, err := upgradeBuildProvenance(name, filepath.Join(root, name), version, skillprovenance.SourceUpgrade)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
records = append(records, record)
}
return records, nil
}
// skillBackupKeep limits ~/.dws/skill-backups/ growth: only the newest
// backups are kept.
const skillBackupKeep = 5
// pruneSkillBackups removes the oldest backup directories when more than
// skillBackupKeep remain. Best-effort: a removal failure never aborts, but
// pruning failures are reported so callers can warn the user.
func pruneSkillBackups(homeDir string) error {
root := filepath.Join(homeDir, skillBackupSubdir)
entries, err := upgradeReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names)
var firstErr error
for len(names) > skillBackupKeep {
old := filepath.Join(root, names[0])
names = names[1:]
if err := upgradeRemoveAll(old); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// resolveMultiBundle returns the multi skill root and its skill names when
// extractedDir is itself a bundle, or when a multi/ child of the parent
// extract root carries one (LocateSkillsRoot already prefers that child).
func resolveMultiBundle(extractedDir string) (string, []string) {
if skills := bundleSkillNames(extractedDir); len(skills) > 0 {
return extractedDir, skills
}
child := filepath.Join(extractedDir, "multi")
if skills := bundleSkillNames(child); len(skills) > 0 {
return child, skills
}
return "", nil
}
// resolveMonoSkillSrc finds a mono skill tree for the legacy mono-only
// package fallback: the path itself, a sibling mono/ next to a multi root,
// or the extract-root SKILL.md copy that release zips still ship.
func resolveMonoSkillSrc(extractedDir string) string {
if skillTreeHasRoot(extractedDir) {
return extractedDir
}
sibling := filepath.Join(filepath.Dir(extractedDir), "mono")
if skillTreeHasRoot(sibling) {
return sibling
}
parent := filepath.Dir(extractedDir)
if skillTreeHasRoot(parent) {
return parent
}
child := filepath.Join(extractedDir, "mono")
if skillTreeHasRoot(child) {
return child
}
return ""
}
type skillStageSpec struct {
src string
dest string
}
type stagedSkillDir struct {
staged string
dest string
}
type backedUpSkillDir struct {
original string
backup string
}
// stageSkillSet builds a complete replacement next to its final destinations.
// Nothing Agent-visible is changed until every copy succeeds.
func stageSkillSet(destBase, prefix string, specs []skillStageSpec) (stageRoot string, staged []stagedSkillDir, err error) {
if err := upgradeMkdirAll(destBase, dirPermShared); err != nil {
return "", nil, fmt.Errorf("创建 Skill 目标目录失败 %s: %w", destBase, err)
}
stageRoot, err = upgradeMkdirTemp(destBase, prefix)
if err != nil {
return "", nil, fmt.Errorf("创建 Skill staging 失败 %s: %w", destBase, err)
}
defer func() {
if err == nil {
return
}
if cleanupErr := upgradeRemoveAll(stageRoot); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
}()
staged = make([]stagedSkillDir, 0, len(specs))
for _, spec := range specs {
stageDir := filepath.Join(stageRoot, filepath.Base(spec.dest))
if err := upgradeCopyDir(spec.src, stageDir); err != nil {
return stageRoot, nil, fmt.Errorf("拷贝 Skill staging 失败 %s: %w", stageDir, err)
}
staged = append(staged, stagedSkillDir{staged: stageDir, dest: spec.dest})
}
return stageRoot, staged, nil
}
func uniqueSkillDirs(paths []string) []string {
seen := make(map[string]bool, len(paths))
unique := make([]string, 0, len(paths))
for _, path := range paths {
path = filepath.Clean(path)
if seen[path] {
continue
}
seen[path] = true
unique = append(unique, path)
}
return unique
}
// restoreSkillSet removes any newly published directories, then restores all
// original directories in reverse backup order.
func restoreSkillSet(published []string, backups []backedUpSkillDir) error {
var restoreErr error
for i := len(published) - 1; i >= 0; i-- {
if err := upgradeRemoveAll(published[i]); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
}
}
for i := len(backups) - 1; i >= 0; i-- {
backup := backups[i]
if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err))
continue
}
if err := upgradeRename(backup.backup, backup.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err))
}
}
return restoreErr
}
// backupSkillSet moves every victim aside as one logical operation. If a
// later backup fails, earlier moves are restored before the error is returned.
func backupSkillSet(homeDir string, victims []string) ([]backedUpSkillDir, error) {
backups := make([]backedUpSkillDir, 0, len(victims))
for _, victim := range uniqueSkillDirs(victims) {
backup, err := backupAndRemoveSkillDir(homeDir, victim)
if err != nil {
if restoreErr := restoreSkillSet(nil, backups); restoreErr != nil {
return nil, errors.Join(err, fmt.Errorf("恢复已备份 Skill 失败: %w", restoreErr))
}
return nil, err
}
if backup != "" {
backups = append(backups, backedUpSkillDir{original: victim, backup: backup})
}
}
return backups, nil
}
// publishStagedSkillSet switches a fully staged set into place. Any publish
// failure removes the partial new set and restores every original directory.
func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []string) error {
backups, err := backupSkillSet(homeDir, victims)
if err != nil {
return err
}
published := make([]string, 0, len(staged))
for _, skill := range staged {
if err := upgradeRename(skill.staged, skill.dest); err != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err)
if restoreErr := restoreSkillSet(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr))
}
return publishErr
}
published = append(published, skill.dest)
}
return nil
}
func monoUpgradeVictims(baseDir, destDir string, managed ...map[string]bool) ([]string, error) {
victims, err := managedMultiSkillVictims(baseDir, managed...)
if err != nil {
return nil, err
}
return append(victims, destDir), nil
}
func multiUpgradeVictims(destBase string, skillSet map[string]bool, skills []string, managed ...map[string]bool) ([]string, error) {
victims, err := oppositeModeSkillVictims(destBase, skillSet, managed...)
if err != nil {
return nil, err
}
for _, name := range skills {
victims = append(victims, filepath.Join(destBase, name))
}
return victims, nil
}
func publishMonoUpgradeTarget(homeDir, destBase, skillSrc string, managed ...map[string]bool) error {
destDir := filepath.Join(destBase, "dws")
victims, err := monoUpgradeVictims(destBase, destDir, managed...)
if err != nil {
return err
}
stageRoot, staged, err := stageSkillSet(destBase, ".dws-upgrade-mono-", []skillStageSpec{{src: skillSrc, dest: destDir}})
if err != nil {
return err
}
defer func() { _ = upgradeRemoveAll(stageRoot) }()
return publishStagedSkillSet(homeDir, staged, victims)
}
func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []string, skillSet map[string]bool, managed ...map[string]bool) error {
victims, err := multiUpgradeVictims(destBase, skillSet, skills, managed...)
if err != nil {
return err
}
specs := make([]skillStageSpec, 0, len(skills))
for _, name := range skills {
specs = append(specs, skillStageSpec{
src: filepath.Join(multiRoot, name),
dest: filepath.Join(destBase, name),
})
}
stageRoot, staged, err := stageSkillSet(destBase, ".dws-upgrade-multi-", specs)
if err != nil {
return err
}
defer func() { _ = upgradeRemoveAll(stageRoot) }()
return publishStagedSkillSet(homeDir, staged, victims)
}
// upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory
// per agent home.
func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) {
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
for i, agentDir := range knownSkillDirs {
destDir := filepath.Join(homeDir, agentDir, "dws")
@@ -140,19 +536,21 @@ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
}
}
os.RemoveAll(destDir)
if err := upgradeCopyDir(extractedDir, destDir); err != nil {
if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
}
// Fallback: if nothing succeeded, force the primary location
// Fallback: if nothing succeeded, force the primary location. The multi
// leftovers under the primary base are the usual reason the primary
// install failed, so clean them first — failing loud like the multi
// fallback — instead of letting mono and multi co-exist marked OK.
if len(result.Succeeded()) == 0 {
dest := filepath.Join(homeDir, ".agents", "skills", "dws")
os.MkdirAll(filepath.Dir(dest), dirPermShared)
if err := upgradeCopyDir(extractedDir, dest); err != nil {
destBase := filepath.Join(homeDir, ".agents", "skills")
dest := filepath.Join(destBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
@@ -169,9 +567,267 @@ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
}
}
// Best-effort: refresh the user-level mono cache so that
// `dws skill setup --mode mono` fallbacks stay on the upgraded version
// (symmetric with the multi cache refresh in upgradeMultiSkillLocations).
_ = refreshSkillCache(homeDir, "mono", skillSrc)
return result, nil
}
// upgradeMultiSkillLocations installs every skill of the multi bundle into
// each agent home as sibling directories and backs up + removes the
// opposite-mode leftovers. A home is marked failed (and multi is NOT
// installed into it) when leftover backup/removal fails, so mono and multi
// never co-exist.
func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*SkillUpgradeResult, error) {
skillSet := make(map[string]bool, len(skills))
for _, s := range skills {
skillSet[s] = true
}
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
for i, agentDir := range knownSkillDirs {
destBase := filepath.Join(homeDir, agentDir)
if isBlacklisted(agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
continue
}
if i > 0 {
parentGate := filepath.Dir(destBase)
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
}
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
// Fallback: if nothing succeeded, force the primary location
if len(result.Succeeded()) == 0 {
destBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
replaced := false
for idx, r := range result.Results {
if r.Dir == destBase {
result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
}
// Best-effort: refresh the user-level caches so that `dws skill setup`
// fallbacks stay on the upgraded version. The release zip ships both
// trees, so when the sibling mono/ tree is present the mono cache is
// refreshed as well.
_ = refreshSkillCache(homeDir, "multi", multiRoot)
if monoSrc := filepath.Join(filepath.Dir(multiRoot), "mono"); skillTreeHasRoot(monoSrc) {
_ = refreshSkillCache(homeDir, "mono", monoSrc)
}
return result, nil
}
// refreshSkillCache mirrors src into ~/.dws/skills/<name>/ through a staged
// sibling directory. The existing cache is moved aside only after the staged
// copy is complete, and is restored if publishing the new cache fails.
func refreshSkillCache(homeDir, name, src string) error {
cacheDir := filepath.Join(homeDir, ".dws", "skills", name)
cacheParent := filepath.Dir(cacheDir)
if err := upgradeMkdirAll(cacheParent, dirPermShared); err != nil {
return fmt.Errorf("创建 Skill 缓存目录失败 %s: %w", cacheParent, err)
}
stagedDir, err := upgradeMkdirTemp(cacheParent, "."+name+".tmp-")
if err != nil {
return fmt.Errorf("创建 Skill 缓存临时目录失败 %s: %w", cacheParent, err)
}
stagedPublished := false
defer func() {
if !stagedPublished {
_ = upgradeRemoveAll(stagedDir)
}
}()
if err := upgradeCopyDir(src, stagedDir); err != nil {
return fmt.Errorf("暂存 Skill 缓存失败 %s: %w", stagedDir, err)
}
if _, err := upgradeStat(cacheDir); os.IsNotExist(err) {
if err := upgradeRename(stagedDir, cacheDir); err != nil {
return fmt.Errorf("发布 Skill 缓存失败 %s: %w", cacheDir, err)
}
stagedPublished = true
return nil
} else if err != nil {
return fmt.Errorf("检查 Skill 缓存失败 %s: %w", cacheDir, err)
}
rollbackDir, err := upgradeMkdirTemp(cacheParent, "."+name+".old-")
if err != nil {
return fmt.Errorf("创建 Skill 缓存回滚目录失败 %s: %w", cacheParent, err)
}
if err := upgradeRemoveAll(rollbackDir); err != nil {
return fmt.Errorf("准备 Skill 缓存回滚目录失败 %s: %w", rollbackDir, err)
}
if err := upgradeRename(cacheDir, rollbackDir); err != nil {
return fmt.Errorf("暂存原 Skill 缓存失败 %s: %w", cacheDir, err)
}
if err := upgradeRename(stagedDir, cacheDir); err != nil {
if restoreErr := upgradeRename(rollbackDir, cacheDir); restoreErr != nil {
return fmt.Errorf("发布 Skill 缓存失败 %s: %w(恢复原缓存也失败: %v)", cacheDir, err, restoreErr)
}
return fmt.Errorf("发布 Skill 缓存失败 %s: %w", cacheDir, err)
}
stagedPublished = true
_ = upgradeRemoveAll(rollbackDir)
return nil
}
// skillTreeHasRoot reports whether dir carries a top-level SKILL.md.
func skillTreeHasRoot(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "SKILL.md"))
return err == nil && !info.IsDir()
}
// cleanupMultiLeftovers backs up + removes every proven DWS-managed
// multi-mode skill directory inside one agent home before mono is installed.
// A missing base directory simply means no leftovers; any other read failure
// is reported so mono never silently co-exists with multi. Removal is
// reversible: each leftover is preserved under ~/.dws/skill-backups/ and a
// backup failure aborts the removal for that home.
func cleanupMultiLeftovers(homeDir, baseDir string) error {
victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
}
return nil
}
func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) {
entries, err := upgradeReadDir(baseDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("读取技能目录失败 %s: %w", baseDir, err)
}
victims := make([]string, 0)
for _, e := range entries {
if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(baseDir, e.Name()))
}
return victims, nil
}
// cleanupOppositeModeLeftovers backs up + removes, inside one agent home, the
// legacy mono directory (dws/) and every proven DWS-managed multi skill
// directory that is not part of the new bundle. A dingtalk-* prefix alone is
// not proof of ownership because market/user skills may use the same prefix.
// Removal is reversible: each
// directory is preserved under ~/.dws/skill-backups/ and a backup failure
// aborts the removal for that home.
func cleanupOppositeModeLeftovers(homeDir, destBase string, skillSet map[string]bool) error {
victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
}
return nil
}
func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) {
victims := []string{filepath.Join(destBase, "dws")}
entries, err := upgradeReadDir(destBase)
if err != nil {
if os.IsNotExist(err) {
return victims, nil
}
return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err)
}
for _, e := range entries {
if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(destBase, e.Name()))
}
return victims, nil
}
// isManagedMultiSkillDir accepts only centralized metadata or the frozen exact
// official-name migration list. Files inside Skill directories are ignored.
func isManagedMultiSkillDir(dir string, managed ...map[string]bool) bool {
if skillstate.IsLegacyOfficialSkillName(filepath.Base(dir)) {
return true
}
return len(managed) > 0 && managed[0][filepath.Base(dir)]
}
func readManagedSkillNames(homeDir string) map[string]bool {
state, readable, err := upgradeReadSkillState(homeDir)
if err != nil || !readable {
return map[string]bool{}
}
return skillstate.ManagedSkillNames(state)
}
// bundleSkillNames returns the sorted names of subdirectories of dir that
// contain a SKILL.md. It returns nil when dir itself carries a top-level
// SKILL.md (mono layout) so callers can distinguish the two layouts.
func bundleSkillNames(dir string) []string {
if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err == nil {
return nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var names []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(dir, e.Name(), "SKILL.md")); err == nil {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names
}
// LocateSkillsRoot resolves the skill root inside an extracted dws-skills.zip,
// preferring the multi bundle ({extractDir}/multi) over the legacy mono
// layouts handled by LocateSkillMD.
func LocateSkillsRoot(extractDir string) string {
multiRoot := filepath.Join(extractDir, "multi")
if skills := bundleSkillNames(multiRoot); len(skills) > 0 {
return multiRoot
}
return LocateSkillMD(extractDir)
}
// LocateSkillMD finds the directory containing SKILL.md in an extracted zip.
// It handles both flat layouts (SKILL.md at root) and nested layouts (dws/SKILL.md).
func LocateSkillMD(extractDir string) string {
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -59,6 +59,10 @@ func TestLocateSkillMD(t *testing.T) {
}
func TestUpgradeSkillLocations(t *testing.T) {
// Fake HOME: the mono path refreshes ~/.dws/skills/mono best-effort, and
// the test must never touch the real user cache.
homeDir := withFakeHome(t)
skillSrc := t.TempDir()
os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# test skill"), 0644)
os.MkdirAll(filepath.Join(skillSrc, "references"), 0755)
@@ -74,7 +78,6 @@ func TestUpgradeSkillLocations(t *testing.T) {
t.Fatal("UpgradeSkillLocations() returned 0 succeeded locations")
}
homeDir, _ := os.UserHomeDir()
primaryDest := filepath.Join(homeDir, ".agents", "skills", "dws")
found := false
+251
View File
@@ -0,0 +1,251 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func writeSkillCacheFixture(t *testing.T, dir, content string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func readSkillCacheFixture(t *testing.T, dir string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
if err != nil {
t.Fatalf("read cache fixture: %v", err)
}
return string(data)
}
// TestCrossPlatformCoverageRefreshSkillCacheTransaction pins the cache refresh
// transaction on every platform: copying is staged before the old cache moves,
// and a publish failure restores the prior usable cache.
func TestCrossPlatformCoverageRefreshSkillCacheTransaction(t *testing.T) {
newSource := func(t *testing.T) string {
t.Helper()
src := t.TempDir()
writeSkillCacheFixture(t, src, "new")
return src
}
cachePath := func(home string) string {
return filepath.Join(home, ".dws", "skills", "multi")
}
assertOldCache := func(t *testing.T, home string) {
t.Helper()
if got := readSkillCacheFixture(t, cachePath(home)); got != "old" {
t.Fatalf("cache content = %q, want old", got)
}
}
t.Run("replace existing cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
if err := refreshSkillCache(home, "multi", newSource(t)); err != nil {
t.Fatalf("refreshSkillCache() error = %v", err)
}
if got := readSkillCacheFixture(t, cachePath(home)); got != "new" {
t.Fatalf("cache content = %q, want new", got)
}
entries, err := os.ReadDir(filepath.Dir(cachePath(home)))
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != "multi" {
t.Fatalf("cache parent entries = %v, want only multi", entries)
}
})
t.Run("publish new cache", func(t *testing.T) {
home := t.TempDir()
if err := refreshSkillCache(home, "multi", newSource(t)); err != nil {
t.Fatalf("refreshSkillCache() error = %v", err)
}
if got := readSkillCacheFixture(t, cachePath(home)); got != "new" {
t.Fatalf("cache content = %q, want new", got)
}
})
t.Run("parent creation failure", func(t *testing.T) {
home := t.TempDir()
failure := errors.New("mkdir denied")
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
})
t.Run("staging creation failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("temp denied")
testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("copy failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("copy denied")
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("cache stat failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("stat denied")
testseam.Swap(t, &upgradeStat, func(string) (os.FileInfo, error) { return nil, failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("new cache publish failure cleans staging", func(t *testing.T) {
home := t.TempDir()
failure := errors.New("rename denied")
testseam.Swap(t, &upgradeRename, func(string, string) error { return failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
entries, err := os.ReadDir(filepath.Dir(cachePath(home)))
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("cache parent entries = %v, want empty", entries)
}
})
t.Run("rollback staging creation failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("rollback temp denied")
original := upgradeMkdirTemp
calls := 0
testseam.Swap(t, &upgradeMkdirTemp, func(dir, pattern string) (string, error) {
calls++
if calls == 2 {
return "", failure
}
return original(dir, pattern)
})
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("rollback preparation failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("remove denied")
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("old cache move failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("old move denied")
testseam.Swap(t, &upgradeRename, func(string, string) error { return failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("publish failure restores old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("publish denied")
original := upgradeRename
calls := 0
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
calls++
if calls == 2 {
return failure
}
return original(src, dst)
})
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("restore failure reports both errors and retains backup", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
publishFailure := errors.New("publish denied")
restoreFailure := errors.New("restore denied")
original := upgradeRename
calls := 0
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
calls++
switch calls {
case 2:
return publishFailure
case 3:
return restoreFailure
default:
return original(src, dst)
}
})
err := refreshSkillCache(home, "multi", newSource(t))
if !errors.Is(err, publishFailure) || !strings.Contains(err.Error(), restoreFailure.Error()) {
t.Fatalf("refreshSkillCache() error = %v, want publish and restore errors", err)
}
matches, globErr := filepath.Glob(filepath.Join(filepath.Dir(cachePath(home)), ".multi.old-*", "SKILL.md"))
if globErr != nil || len(matches) != 1 {
t.Fatalf("rollback cache matches = %v, err = %v", matches, globErr)
}
if got := readSkillCacheFixture(t, filepath.Dir(matches[0])); got != "old" {
t.Fatalf("rollback cache content = %q, want old", got)
}
})
t.Run("post publish cleanup is best effort", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
original := upgradeRemoveAll
calls := 0
testseam.Swap(t, &upgradeRemoveAll, func(path string) error {
calls++
if calls == 2 {
return errors.New("cleanup denied")
}
return original(path)
})
if err := refreshSkillCache(home, "multi", newSource(t)); err != nil {
t.Fatalf("refreshSkillCache() error = %v", err)
}
if got := readSkillCacheFixture(t, cachePath(home)); got != "new" {
t.Fatalf("cache content = %q, want new", got)
}
})
}

Some files were not shown because too many files have changed in this diff Show More