Compare commits

...
Author SHA1 Message Date
RuiGong01 f875b1bc87 Merge branch 'main' into dws_optimization 2026-08-12 20:54:37 +08:00
github-actions[bot] 3388df1c63 Merge pull request #978 from xlb1130/feat/85387314-chat-image-guide
docs(chat): clarify image markdown guide
2026-08-12 19:54:03 +08:00
xlb1130 d3584077d7 Merge branch 'main' into feat/85387314-chat-image-guide 2026-08-12 18:30:40 +08:00
github-actions[bot] e49ba1ae71 Merge pull request #972 from typefield/feat/zcode-skill-root
feat(skill): support ZCode skill root
2026-08-12 10:20:18 +00:00
长真 2c46213257 docs(chat): to #85387314 clarify image markdown guide 2026-08-12 18:04:17 +08:00
john 77dc7d30a0 Merge branch 'main' into feat/zcode-skill-root 2026-08-12 17:56:45 +08:00
ruigong aa3c279313 chore(policy): align doc skill context budget with event/chat (10000) 2026-08-12 17:55:31 +08:00
ruigong 51dc3df91b fix(skill): clarify document-space routing in doc/drive/wiki descriptions 2026-08-12 17:14:20 +08:00
github-actions[bot] 70e03887d4 Merge pull request #962 from xlb1130/chore/85200556-im-id-flag-migrations-pending
chore(interface): add IM ID flag migration pending approvals
2026-08-12 08:40:45 +00:00
xlb1130 6ac2bbb7cf Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 16:23:15 +08:00
github-actions[bot] 5812276f46 Merge pull request #958 from typefield/codex/upgrade-stream-client-v0.9.2-beta.1
chore(deps): upgrade DingTalk Stream SDK to v0.9.2-beta.1
2026-08-12 08:15:00 +00:00
john 74baac23a1 Merge branch 'main' into codex/upgrade-stream-client-v0.9.2-beta.1 2026-08-12 15:51:30 +08:00
玉澜 b31eaec78d docs: remove ZCode release fragment 2026-08-12 15:47:39 +08:00
xlb1130 34c5118e85 Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 15:36:05 +08:00
玉澜 6e4ea0980f feat(skill): support ZCode skill root 2026-08-12 15:34:38 +08:00
github-actions[bot] 3ce0e001c1 Merge pull request #961 from yutongShe/feat/drive-file-comments
feat(drive): add file comment commands
2026-08-12 15:20:41 +08:00
xlb1130 077a5c3b30 Merge branch 'main' into chore/85200556-im-id-flag-migrations-pending 2026-08-12 14:57:37 +08:00
之桐 f3567fba71 Merge remote-tracking branch 'upstream/main' into feat/drive-file-comments 2026-08-12 14:54:18 +08:00
github-actions[bot] e7837cdc6b Merge pull request #964 from typefield/fix/upgrade-default-multi
fix(skill): avoid duplicate Agent skill roots
2026-08-12 14:50:57 +08:00
长真 88e2f8e9e2 chore(interface): address migration approval review feedback to #85200556 2026-08-12 14:40:52 +08:00
之桐 b131726497 docs: add drive file comment release fragment 2026-08-12 14:36:26 +08:00
之桐 86ec9733c0 Merge remote-tracking branch 'upstream/main' into feat/drive-file-comments 2026-08-12 14:35:22 +08:00
玉澜 8a60334978 Merge remote-tracking branch 'upstream/main' into fix/upgrade-default-multi 2026-08-12 14:24:52 +08:00
之桐 76a6980244 fix(drive): validate numeric file comment IDs 2026-08-12 14:24:50 +08:00
玉澜 fcbbc0bd9a fix(skill): require explicit nested layout migration 2026-08-12 14:21:47 +08:00
github-actions[bot] 31edcc3c5a Merge pull request #888 from DingTalk-Real-AI/codex/release-fragments
release: use isolated changelog fragments
2026-08-12 14:21:18 +08:00
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
玉澜 24ea2505a5 test(skill): cover upgrade migration branches 2026-08-12 13:44:14 +08:00
玉澜 566e94a31e fix(skill): make generic cleanup deterministic 2026-08-12 13:19:52 +08:00
玉澜 5c68e4d9cc fix(skill): avoid duplicate Agent skill roots 2026-08-12 12:32:53 +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
长真 276ab52aed chore(interface): add im id flag migration pending approvals to #85200556 2026-08-12 12:14:10 +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
之桐 bdf3048773 feat(drive): add file comment commands 2026-08-12 11:29:21 +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
玉澜 57e23d661d chore(deps): upgrade DingTalk Stream SDK to v0.9.2-beta.1 2026-08-12 10:57:37 +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 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
chichuan afb25ae0e9 Merge remote-tracking branch 'origin/main' into codex/release-fragments 2026-08-11 19:49:49 +08:00
chichuan 3af7adaad6 Merge branch 'main' into codex/release-fragments 2026-08-10 17:06:47 +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
74 changed files with 8134 additions and 527 deletions
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Chat message send help** - Clarifies Markdown image syntax for inline mixed text and images.
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **Drive file comments** (#961) — adds `dws drive comment list` and `dws drive comment create` for comments on ordinary preview files.
+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。评审者根据改动是否可见来判断该
例外是否成立。
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **Doc/drive/wiki routing descriptions** — clarifies the document-space container-vs-content boundary across the doc, drive, and wiki skill descriptions for more predictable first-round Agent selection, without changing CLI behavior.
+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:
+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"
+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
+3 -3
View File
@@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
The upgrade process follows a two-phase atomic flow to ensure consistency:
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
2. **Apply** — only after all preparations succeed, the binary is replaced and skill packages are installed to all detected agent directories (`~/.agents/skills/dws`, `~/.claude/skills/dws`, `~/.cursor/skills/dws`, etc.).
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
@@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `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).
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
@@ -433,7 +433,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| Flag | Values | Description |
|------|--------|-------------|
| `--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 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home, including ZCode at `~/.zcode/skills` |
| `--source` | path | Local source directory (overrides bundled skills) |
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
+3 -3
View File
@@ -207,7 +207,7 @@ bash verify-all-channels.sh
升级过程采用两阶段原子流程,确保一致性:
1. **准备阶段** — 将平台对应的二进制文件和技能包下载到临时目录,校验 SHA256 校验和,解压并验证所有文件。任何步骤失败则立即中止,不会修改现有安装。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包安装到所有已检测到的 Agent 目录(`~/.agents/skills/dws`、`~/.claude/skills/dws`、`~/.cursor/skills/dws` 等)。
2. **执行阶段** — 仅在所有准备工作成功后,替换二进制文件并将技能包平铺到已检测到的具体 Agent 目录(例如 `~/.codex/skills/dingtalk-chat`、`~/.claude/skills/dingtalk-chat`)。只有未检测到具体 Agent 时才使用 `~/.agents/skills`;检测到具体 Agent 后会备份迁走旧的 DWS 通用副本,避免同一 Skill 被重复发现。
每次升级前自动备份当前版本,可通过 `dws upgrade --rollback` 随时回滚。
@@ -399,7 +399,7 @@ Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / Con
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` 安装到 `$HOME/.agents/skills/`(全局,multi 为按产品平铺,mono 为 `dws/` 子目录);`install-skills.sh` 安装到 `./.agents/skills/`(当前项目)。
> 安装器优先使用检测到的具体 Agent 根目录(如 `$HOME/.codex/skills/`);仅在未检测到具体 Agent 时回退到 `.agents/skills/`。multi 为按产品平铺,mono 为 `dws/` 子目录。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
@@ -427,7 +427,7 @@ 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 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `zcode` \| `opencode` \| `qoder` | 安装目标;`all` 表示铺到检测到的具体 Agent home(ZCode 为 `~/.zcode/skills`),仅在未检测到具体 Agent 时回退到 `~/.agents/skills` |
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
+67
View File
@@ -17,6 +17,7 @@ const AGENT_DIRS = [
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
@@ -233,6 +234,10 @@ function installSkillsToHomes(skillRoot) {
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
@@ -252,6 +257,9 @@ function installSkillsToHomes(skillRoot) {
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
@@ -265,6 +273,15 @@ function installSkillsToHomes(skillRoot) {
}
});
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
@@ -329,6 +346,40 @@ function isManagedMultiSkillDir(dir, managedNames) {
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
}
function retireGenericSkillRoot(homeDir, managedNames) {
const baseDir = path.join(homeDir, ".agents", "skills");
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));
}
}
}
const backups = [];
try {
for (const victim of victims) {
if (!backupAndRemoveSkillDir(homeDir, victim, backups)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
} catch (err) {
const restoreErrors = [];
for (let i = backups.length - 1; i >= 0; i -= 1) {
try {
fs.mkdirSync(path.dirname(backups[i].original), { recursive: true });
fs.renameSync(backups[i].backup, backups[i].original);
} catch (restoreErr) {
restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`);
}
throw err;
}
}
function skillDirectoryDigest(dir) {
const files = [];
const visit = (current, prefix) => {
@@ -577,6 +628,10 @@ function installMultiSkillsToHomes(multiRoot) {
let attempted = 0;
let failed = 0;
const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) =>
fs.existsSync(path.dirname(path.join(homeDir, agentDir))),
);
const installToBase = (baseDir) => {
fs.mkdirSync(baseDir, { recursive: true });
const victims = [path.join(baseDir, "dws")];
@@ -604,6 +659,9 @@ function installMultiSkillsToHomes(multiRoot) {
};
AGENT_DIRS.forEach((agentDir, index) => {
if (index === 0 && specificAgentDirs.length > 0) {
return;
}
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
@@ -617,6 +675,15 @@ function installMultiSkillsToHomes(multiRoot) {
}
});
if (specificAgentDirs.length > 0 && installed > 0) {
try {
retireGenericSkillRoot(homeDir, managedNames);
} catch (err) {
console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`);
failed += 1;
}
}
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
+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
+1 -1
View File
@@ -41,7 +41,7 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
## 两阶段迁移与回执清理
+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 证据。
@@ -142,6 +142,9 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
升级器对每个 Agent 目标执行:
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合;
3. staging 全部成功后,才将旧集合移入备份目录;
+262 -31
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-10T17:25:46.245552",
"count": 378,
"generated_at": "2026-08-12T00:10:44.511794",
"count": 399,
"results": [
{
"suite": "semantic",
@@ -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",
@@ -3180,7 +3411,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; +minutes-search is a compatibility alias.",
"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"
},
{
@@ -3260,7 +3491,7 @@
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Owns local file validation, create-PUT-complete polling, cancellation compensation and final task read-back verification.",
"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"
},
{
+1 -1
View File
@@ -14,7 +14,7 @@ require (
github.com/itchyny/gojq v0.12.18
github.com/mattn/go-isatty v0.0.20
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
+2 -2
View File
@@ -88,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1 h1:5WwR5TV6A12taXMH7SggT8yCMMJMF9jWE7Wj+4AuHck=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+5
View File
@@ -681,6 +681,11 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
if _, err := parseAgentProduct(os.Getenv(agentproduct.EnvName)); err != nil {
return err
}
if shouldDetectNestedSkillLayout(cmd) {
if found, err := detectNestedMultiSkillLayout(); err == nil && found {
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ 检测到旧升级器留下的嵌套 Skill;请运行 dws skill setup --mode multi 查看迁移计划并确认")
}
}
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 378
publicShortcutCount = 399
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 379
schemaPublishedShortcutCount = 401
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 378
publiclyDeliveredShortcutCount = 399
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
+1
View File
@@ -126,6 +126,7 @@ var agentSkillPaths = map[string]string{
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"zcode": ".zcode/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
// IDE / agent registries also probed by `dws skill setup --target all`.
"gemini": ".gemini/skills",
+1 -1
View File
@@ -452,7 +452,7 @@ func TestSupportedTargets(t *testing.T) {
// Should contain all predefined targets — including the agents/* sentinel
// and the IDE/agent registries we share with skillSetupAgentHomes.
expectedTargets := []string{
"agents", "claude", "cursor", "codex", "opencode", "qoder",
"agents", "claude", "cursor", "codex", "zcode", "opencode", "qoder",
"gemini", "github", "windsurf", "augment", "cline",
"amp", "kiro", "trae", "openclaw", "hermes",
".",
+97 -7
View File
@@ -29,6 +29,7 @@ var skillSetupAgentHomes = []string{
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
@@ -92,6 +93,7 @@ type skillSetupBackup struct {
type skillSetupTargetPlan struct {
Destination string
Backups []skillSetupBackup
CleanupOnly bool
}
type skillSetupPlan struct {
@@ -147,7 +149,8 @@ multi 模式支持按产品挑选:
备份失败时保留原目录并跳过该目标,绝不静默删除。
· 所有将被移除的目录都会在确认前逐条列出。
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录;
未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
Example: ` dws skill setup --mode multi --target claude --dry-run
dws skill setup --mode multi --target claude`,
@@ -940,18 +943,79 @@ func agentHomeForMode(base, mode string) string {
}
func detectExistingAgentHomes(home, mode string) []string {
var out []string
var specific []string
for i, rel := range skillSetupAgentHomes {
if i == 0 {
continue
}
base := filepath.Join(home, rel)
parent := filepath.Dir(base)
if i > 0 {
if _, err := skillSetupStat(parent); errors.Is(err, os.ErrNotExist) {
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() {
continue
}
specific = append(specific, agentHomeForMode(base, mode))
}
if len(specific) > 0 {
return specific
}
return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)}
}
func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) {
// Derive HOME from a concrete Agent destination instead of resolving it a
// second time. The destinations were already resolved from HOME by the
// caller, and a later/transient UserHomeDir failure must not turn an
// otherwise valid setup plan into an error. Direct/custom destinations that
// do not match a known concrete Agent root have no generic-root migration.
home := ""
for _, dest := range dests {
base := dest
if filepath.Base(dest) == "dws" {
base = filepath.Dir(dest)
}
base = filepath.Clean(base)
for i, rel := range skillSetupAgentHomes {
if i == 0 {
continue
}
suffix := filepath.Clean(filepath.FromSlash(rel))
needle := string(filepath.Separator) + suffix
if strings.HasSuffix(base, needle) {
home = strings.TrimSuffix(base, needle)
break
}
}
if home != "" {
break
}
out = append(out, agentHomeForMode(base, mode))
}
return out
if home == "" {
return nil, nil
}
genericBase := filepath.Join(home, ".agents", "skills")
target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true}
add := func(path, reason string) {
if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() {
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason})
}
}
add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual)
entries, readErr := skillSetupReadDir(genericBase)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr)
}
for _, entry := range entries {
path := filepath.Join(genericBase, entry.Name())
if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) {
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale})
}
}
if len(target.Backups) == 0 {
return nil, nil
}
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
return target, nil
}
func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) {
@@ -1027,6 +1091,13 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
plan.Targets = append(plan.Targets, target)
}
cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames)
if cleanupErr != nil {
return nil, cleanupErr
}
if cleanupTarget != nil {
plan.Targets = append(plan.Targets, *cleanupTarget)
}
return plan, nil
}
@@ -1079,7 +1150,11 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
}
fmt.Fprintln(out, " destinations:")
for _, target := range plan.Targets {
fmt.Fprintf(out, " - %s\n", target.Destination)
if target.CleanupOnly {
fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination)
} else {
fmt.Fprintf(out, " - %s\n", target.Destination)
}
}
fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):")
count := 0
@@ -1676,6 +1751,21 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
perTarget = len(plan.MultiSkillNames)
}
for _, target := range plan.Targets {
if target.CleanupOnly {
if skipped > 0 {
continue
}
if homeErr != nil {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr)
skipped++
continue
}
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
skipped++
}
continue
}
if len(target.Backups) > 0 && homeErr != nil {
if plan.Mode == skillSetupModeMono {
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr)
+80
View File
@@ -146,6 +146,86 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin
}
}
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".codex", "skills")
genericMono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(genericMono, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
return "", errors.New("transient HOME failure")
})
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
t.Fatalf("generic cleanup target = %#v", plan.Targets)
}
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
}
var preview bytes.Buffer
renderSkillSetupPlan(&preview, plan)
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
t.Fatalf("generic cleanup preview missing: %s", preview.String())
}
t.Run("managed multi and scan failure", func(t *testing.T) {
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
if targetErr != nil || target == nil || len(target.Backups) != 2 {
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
}
failure := errors.New("generic scan failure")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
t.Fatalf("generic scan error = %v", targetErr)
}
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
t.Fatalf("generic cleanup plan error = %v", planErr)
}
})
}
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
failure := errors.New("cleanup failure")
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("cleanup after skip = (%d, %d, %v)", installed, skipped, err)
}
})
t.Run("home failure keeps generic copy", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
t.Run("backup failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
}
func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
+44
View File
@@ -330,6 +330,50 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
}
}
func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".codex", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
}
}
func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
if err := os.MkdirAll(filepath.Join(home, ".zcode"), 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
if err != nil {
t.Fatal(err)
}
want := filepath.Join(home, ".zcode", "skills")
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
t.Fatalf("targets = %v, want [%s]", got, want)
}
explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono)
if err != nil {
t.Fatal(err)
}
wantMono := filepath.Join(want, "dws")
if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
}
}
func TestCrossPlatformCoverageInstallSkillToHomesEndToEnd(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"path/filepath"
"github.com/spf13/cobra"
)
// detectNestedMultiSkillLayout detects the compatibility gap where an older
// running dws process replaces itself with a newer binary, but then installs
// the new release bundle with its legacy mono copier. That produces the
// impossible layout <agent>/dws/multi/<skill>/SKILL.md.
//
// Detection is deliberately read-only. Ordinary commands must not turn a
// compatibility warning into an unconfirmed, cross-Agent Skill refresh.
// A legitimate mono install has no dws/multi product tree and is ignored.
func detectNestedMultiSkillLayout() (bool, error) {
home, err := skillSetupUserHomeDir()
if err != nil {
return false, err
}
for _, rel := range skillSetupAgentHomes {
nested := filepath.Join(home, rel, "dws", "multi")
if isSkillSourceRoot(nested, skillSetupModeMulti) {
return true, nil
}
}
return false, nil
}
func shouldDetectNestedSkillLayout(cmd *cobra.Command) bool {
if cmd == nil {
return true
}
if cmd.Name() == "upgrade" {
return false
}
return !(cmd.Name() == "setup" && cmd.Parent() != nil && cmd.Parent().Name() == "skill")
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageStartupDetectsNestedUpgradeLayoutWithoutMutation(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills", ".codex/skills"})
nested := filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
found, err := detectNestedMultiSkillLayout()
if err != nil || !found {
t.Fatalf("detect nested layout = (%v, %v), want (true, nil)", found, err)
}
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
if err != nil || string(data) != "old nested" {
t.Fatalf("detection changed nested Skill: data=%q err=%v", data, err)
}
if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) {
t.Fatalf("detection unexpectedly installed a canonical Skill: %v", err)
}
}
func TestCrossPlatformCoverageStartupDetectionIgnoresMonoAndReportsReadFailure(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".agents/skills"})
mono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(mono, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("valid mono"), 0o644); err != nil {
t.Fatal(err)
}
found, err := detectNestedMultiSkillLayout()
if err != nil || found {
t.Fatalf("valid mono detection = (%v, %v), want (false, nil)", found, err)
}
failure := errors.New("HOME failure")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
found, err = detectNestedMultiSkillLayout()
if found || !errors.Is(err, failure) {
t.Fatalf("HOME failure detection = (%v, %v)", found, err)
}
}
func TestCrossPlatformCoverageStartupDetectionSkipsExplicitSkillManagers(t *testing.T) {
upgradeCmd := &cobra.Command{Use: "upgrade"}
if shouldDetectNestedSkillLayout(upgradeCmd) {
t.Fatal("upgrade must manage its own Skill lifecycle")
}
skillCmd := &cobra.Command{Use: "skill"}
setupCmd := &cobra.Command{Use: "setup"}
skillCmd.AddCommand(setupCmd)
if shouldDetectNestedSkillLayout(setupCmd) {
t.Fatal("skill setup must manage its own Skill lifecycle")
}
if !shouldDetectNestedSkillLayout(&cobra.Command{Use: "version"}) || !shouldDetectNestedSkillLayout(nil) {
t.Fatal("ordinary commands must trigger read-only detection")
}
}
func TestCrossPlatformCoverageStartupWarningIsReadOnlyAndDoesNotBypassConfirmation(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupAgentHomes, []string{".codex/skills"})
nested := filepath.Join(home, ".codex", "skills", "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
// Any accidental return to startup mutation must fail this test before it
// can touch the fixture.
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
t.Fatal("ordinary command attempted to copy Skills")
return nil
})
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
t.Fatal("ordinary command attempted to back up or remove Skills")
return "", nil
})
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
t.Fatal("ordinary command attempted to write Skill state")
return nil
})
root := newRootCommandWithEngine(context.Background(), nil, false, true)
cmd := &cobra.Command{Use: "version"}
cmd.SetContext(context.Background())
var stderr bytes.Buffer
cmd.SetErr(&stderr)
if err := root.PersistentPreRunE(cmd, nil); err != nil {
t.Fatal(err)
}
warning := stderr.String()
if !strings.Contains(warning, "dws skill setup --mode multi") {
t.Fatalf("safe migration hint missing: %q", warning)
}
if strings.Contains(warning, "--yes") {
t.Fatalf("migration hint bypasses confirmation: %q", warning)
}
data, err := os.ReadFile(filepath.Join(nested, "SKILL.md"))
if err != nil || string(data) != "old nested" {
t.Fatalf("ordinary command changed nested Skill: data=%q err=%v", data, err)
}
}
+4
View File
@@ -2799,6 +2799,8 @@ func newChatCommand() *cobra.Command {
纯文本 / Markdown 消息(默认):
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
图文混排时,公网图片 URL 需要写成 Markdown 图片语法:![图片标题](https://example.com/image.png),才会以内联图片展示。
如果省略开头的 !,例如 [图片标题](https://example.com/image.png),将按链接/URL 展示,不会渲染为图片。
返回值与后续操作:
发送后会返回 openTaskId。如需编辑或撤回刚发送的消息,使用
@@ -2816,6 +2818,8 @@ func newChatCommand() *cobra.Command {
dws chat message send --user <userId> "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> "请查收"
dws chat message send --group <openconversation_id> --title "周报提醒" "请大家本周五前提交周报"
# 图文混排 Markdown:公网图片 URL 需要写成 ![图片标题](URL) 才会以内联图片展示
dws chat message send --group <openconversation_id> --text $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)'
# 发送本地图片或文件(图片会作为可下载的 file 附件发送)
dws chat message send --group <openconversation_id> --msg-type file --file-path ./screenshot.png
dws chat message send --group <openconversation_id> --msg-type file --file-path ./report.pdf
+80
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.
@@ -3204,6 +3283,7 @@ func newDriveCommand() *cobra.Command {
driveListCmd,
driveListSpacesCmd,
driveInfoCmd,
newDriveFileCommentCmd(),
driveDownloadCmd,
driveDownloadVersionCmd,
driveMkdirCmd,
@@ -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)
+576
View File
@@ -0,0 +1,576 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
)
const (
fileCommentServer = "doc-comment"
listFileCommentsTool = "list_file_comments"
createFileCommentTool = "create_file_comment"
fileCommentMaxPageSize = 200
fileCommentMaxAutoPages = 10
fileCommentMaxContentLength = 2099
)
type fileCommentPage struct {
nodeID string
total any
hasMore bool
nextCursor string
comments []map[string]any
}
func fileCommentNodeFlag() LeafFlag {
return LeafFlag{Name: "node", Usage: "文件 ID (dentryUuid)、数字 dentry ID 或钉盘文件 URL", Required: true, Aliases: []string{"url", "id", "node-id", "file-id"}, Bind: "fileId", Trim: true}
}
func fileCommentSpaceIDFlag() LeafFlag {
return LeafFlag{Name: "space-id", Usage: "钉盘空间 ID;仅数字 dentry ID 必填", Bind: "spaceId", OmitEmpty: true, Trim: true, RequiredWhen: "--node is a numeric dentry ID"}
}
// newDriveFileCommentCmd follows the existing resource-first comment surface:
// doc comment, sheet comment, and drive comment. The public command belongs to
// Drive, while the implementation routes to the shared doc-comment MCP server.
func newDriveFileCommentCmd() *cobra.Command {
commentCmd := &cobra.Command{
Use: "comment",
Short: "普通文件评论管理",
Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。",
RunE: groupRunE,
}
listCmd := NewLeafCommand(LeafSpec{
Use: "list",
Short: "查询普通文件评论列表",
Long: `查询钉盘普通预览文件的评论。支持 dentryUuid、钉盘文件 URL,以及配合
--space-id 使用的数字 dentry ID。支持的文件类型由服务端判定。
默认返回一页;--all 固定按每页 200 条自动翻页,最多 10 页。--scope 在 CLI
侧按服务端返回的统一 anchor 过滤;total 始终表示文件的全部有效评论数,count
表示本次输出且符合 scope 的评论数。`,
Example: ` dws drive comment list --node <dentryUuid> --format json
dws drive comment list --node <dentryUuid> --all --format json`,
Tool: listFileCommentsTool,
Flags: []LeafFlag{
fileCommentNodeFlag(),
fileCommentSpaceIDFlag(),
{Name: "limit", Usage: "每页评论数,范围 1-200", Kind: LeafInt, Default: "200", Aliases: []string{"page-size"}, Bind: "maxResults"},
{Name: "cursor", Usage: "分页游标,取自上页 nextCursor", Bind: "nextToken", OmitEmpty: true, Trim: true},
{Name: "all", Usage: "自动拉取全部评论,最多 10 页 / 2000 条", Kind: LeafBool, Bind: "all"},
{Name: "scope", Usage: "评论范围: all(全部) / whole(全文) / partial(历史局部)", Default: "all", Bind: "scope", Trim: true, Enum: []string{"all", "whole", "partial"}},
},
Constraints: []LeafConstraint{
{Kind: LeafMutuallyExclusive, Flags: []string{"all", "cursor"}, Description: "--all 与 --cursor 互斥"},
{Kind: LeafMutuallyExclusive, Flags: []string{"all", "limit"}, Description: "--all 与显式 --limit/--page-size 互斥"},
{Kind: "custom", Flags: []string{"limit"}, Description: "--limit/--page-size 必须在 1-200 之间"},
{Kind: "custom", Flags: []string{"cursor"}, Description: "--cursor 必须是服务端返回的非负数字游标"},
},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "list_file_comments",
CanonicalPath: "drive.list_file_comments",
CLIPath: "drive comment list",
PrimaryCLIPath: "drive comment list",
},
Description: "查询钉盘普通预览文件评论,支持安全分页聚合和全文/局部范围过滤",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "The CLI wraps doc-comment/list_file_comments with bounded auto-pagination, cursor anomaly guards, local anchor-scope filtering, and a stable output projection, so no single direct MCP interface represents the complete command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: "查询 PDF、DOCX、XLSX 等钉盘普通预览文件上的评论",
UseWhen: []string{
"用户要查看普通文件上的全文评论或历史高亮/矩形评论时",
"需要从评论列表取得 commentId、作者、时间和统一 anchor,或用 --all 拉取完整列表时",
},
AvoidWhen: []string{
"在线文字文档评论使用 dws doc comment list",
"在线表格单元格评论使用 dws sheet comment list",
},
Examples: []string{
"dws drive comment list --node <dentryUuid> --format json",
"dws drive comment list --node <dentryUuid> --all --format json",
},
},
},
Validate: validateFileCommentList,
Call: runFileCommentList,
})
createCmd := NewLeafCommand(LeafSpec{
Use: "create",
Short: "创建普通文件全文评论",
Long: `在钉盘普通预览文件上创建一条全文纯文本评论。当前不支持 @人、通知选项或
局部锚点;content 按服务端规则使用 UTF-16 长度计数,最多 2099。`,
Example: ` dws drive comment create --node <dentryUuid> --content "请补充最终结论" --format json`,
Tool: createFileCommentTool,
Flags: []LeafFlag{
fileCommentNodeFlag(),
fileCommentSpaceIDFlag(),
{Name: "content", Usage: "全文评论内容,纯文本且 UTF-16 长度不超过 2099", Required: true, Bind: "content"},
},
Constraints: []LeafConstraint{
{Kind: "custom", Flags: []string{"content"}, Description: "--content 去除首尾空白后必须非空,且 UTF-16 长度不超过 2099"},
},
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive",
Name: "create_file_comment",
CanonicalPath: "drive.create_file_comment",
CLIPath: "drive comment create",
PrimaryCLIPath: "drive comment create",
},
Description: "在钉盘普通预览文件上创建一条全文纯文本评论",
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "The CLI wraps doc-comment/create_file_comment with exact local content validation, runtime confirmation, and a stable flattened output projection, so no single direct MCP interface represents the complete command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: "在 PDF、DOCX、XLSX 等钉盘普通预览文件上创建全文纯文本评论",
UseWhen: []string{
"用户明确要求在普通文件上留下不绑定具体位置的评论时",
},
AvoidWhen: []string{
"在线文字文档评论使用 dws doc comment create",
"在线表格单元格评论使用 dws sheet comment create;当前普通文件评论不支持 @人或局部锚点",
},
Examples: []string{
"dws drive comment create --node <dentryUuid> --content \"请补充最终结论\" --format json",
},
},
},
Validate: validateFileCommentCreate,
Call: runFileCommentCreate,
})
commentCmd.AddCommand(listCmd, createCmd)
return commentCmd
}
func validateFileCommentList(cmd *cobra.Command, _ []string) error {
if err := validateFileCommentNodeSpace(cmd); err != nil {
return err
}
limit, _ := cmd.Flags().GetInt("limit")
if cmd.Flags().Changed("page-size") {
limit, _ = cmd.Flags().GetInt("page-size")
}
if limit < 1 || limit > fileCommentMaxPageSize {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("--limit/--page-size 必须在 1-%d 之间", fileCommentMaxPageSize),
}
}
if cursor, _ := cmd.Flags().GetString("cursor"); strings.TrimSpace(cursor) != "" {
cursor = strings.TrimSpace(cursor)
if !allASCIIDigits(cursor) {
return &CLIError{Code: CodeInvalidParam, Message: "--cursor 必须是服务端返回的非负数字游标"}
}
if _, err := strconv.ParseInt(cursor, 10, 64); err != nil {
return &CLIError{Code: CodeInvalidParam, Message: "--cursor 超出 64 位整数范围"}
}
}
return nil
}
func validateFileCommentCreate(cmd *cobra.Command, _ []string) error {
if err := validateFileCommentNodeSpace(cmd); err != nil {
return err
}
content, _ := cmd.Flags().GetString("content")
if strings.TrimSpace(content) == "" {
return &CLIError{Code: CodeInvalidParam, Message: "--content 去除首尾空白后不能为空"}
}
length := fileCommentUTF16Length(content)
if length > fileCommentMaxContentLength {
return &CLIError{
Code: CodeInputTooLarge,
Message: fmt.Sprintf("--content 最多 %d 个 UTF-16 代码单元,当前为 %d", fileCommentMaxContentLength, length),
}
}
return nil
}
func validateFileCommentNodeSpace(cmd *cobra.Command) error {
node := corecmd.EffectiveValue(cmd, fileCommentNodeFlag())
if !allASCIIDigits(node) {
return nil
}
if spaceID := corecmd.EffectiveValue(cmd, fileCommentSpaceIDFlag()); spaceID == "" {
return &CLIError{
Code: CodeInvalidParam,
Message: "--node 为数字 dentry ID 时必须同时提供 --space-id",
}
}
return nil
}
func fileCommentUTF16Length(value string) int {
length := 0
for _, r := range value {
length++
if r > 0xffff {
length++
}
}
return length
}
func allASCIIDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func runFileCommentList(cmd *cobra.Command, tool string, args map[string]any) error {
fetchAll, _ := args["all"].(bool)
scope, _ := args["scope"].(string)
if scope == "" {
scope = "all"
}
request := fileCommentMCPArgs(args)
if fetchAll {
request["maxResults"] = fileCommentMaxPageSize
delete(request, "nextToken")
}
if deps != nil && deps.Caller != nil && deps.Caller.DryRun() {
return callMCPToolOnServer(fileCommentServer, tool, request)
}
if !fetchAll {
page, err := fetchFileCommentPage(tool, request)
if err != nil {
return err
}
if err := validateFileCommentNextCursor(page, stringArg(request, "nextToken")); err != nil {
return err
}
page.comments = filterFileCommentsByScope(page.comments, scope)
return output.WriteCommandPayload(cmd, fileCommentListPayload(page, scope), output.FormatJSON)
}
var aggregate fileCommentPage
aggregate.comments = make([]map[string]any, 0)
seenCursors := map[string]bool{}
currentCursor := ""
for pageNumber := 0; pageNumber < fileCommentMaxAutoPages; pageNumber++ {
if currentCursor == "" {
delete(request, "nextToken")
} else {
request["nextToken"] = currentCursor
}
page, err := fetchFileCommentPage(tool, request)
if err != nil {
return err
}
if pageNumber == 0 {
aggregate.nodeID = page.nodeID
aggregate.total = page.total
}
aggregate.comments = append(aggregate.comments, filterFileCommentsByScope(page.comments, scope)...)
if !page.hasMore {
aggregate.hasMore = false
aggregate.nextCursor = ""
return output.WriteCommandPayload(cmd, fileCommentListPayload(aggregate, scope), output.FormatJSON)
}
if err := validateFileCommentNextCursor(page, currentCursor); err != nil {
return err
}
if seenCursors[page.nextCursor] {
return fileCommentPaginationError("分页游标发生循环,结果可能不完整")
}
seenCursors[page.nextCursor] = true
currentCursor = page.nextCursor
}
return fileCommentPaginationError(fmt.Sprintf("自动翻页达到 %d 页上限但服务端仍返回 hasMore=true,结果可能不完整", fileCommentMaxAutoPages))
}
func runFileCommentCreate(cmd *cobra.Command, tool string, args map[string]any) error {
request := fileCommentMCPArgs(args)
if deps != nil && deps.Caller != nil && deps.Caller.DryRun() {
return callMCPToolOnServer(fileCommentServer, tool, request)
}
raw, err := callMCPToolReturnTextOnServer(context.Background(), fileCommentServer, tool, request)
if err != nil {
return err
}
payload, err := decodeFileCommentPayload(tool, raw)
if err != nil {
return err
}
nodeID, ok := nonEmptyStringField(payload, "fileId")
if !ok {
return invalidFileCommentResponse(tool, "缺少 fileId", nil)
}
comment, ok := payload["comment"].(map[string]any)
if !ok {
return invalidFileCommentResponse(tool, "缺少 comment 对象", nil)
}
if err := validateFileCommentItem(tool, "comment", comment); err != nil {
return err
}
out := map[string]any{"nodeId": nodeID}
for key, value := range projectFileComment(comment) {
out[key] = value
}
return output.WriteCommandPayload(cmd, out, output.FormatJSON)
}
func fileCommentMCPArgs(args map[string]any) map[string]any {
out := make(map[string]any, len(args))
for key, value := range args {
switch key {
case "all", "scope":
continue
default:
out[key] = value
}
}
return out
}
func fetchFileCommentPage(tool string, request map[string]any) (fileCommentPage, error) {
raw, err := callMCPToolReturnTextOnServer(context.Background(), fileCommentServer, tool, request)
if err != nil {
return fileCommentPage{}, err
}
payload, err := decodeFileCommentPayload(tool, raw)
if err != nil {
return fileCommentPage{}, err
}
nodeID, ok := nonEmptyStringField(payload, "fileId")
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 fileId", nil)
}
total, ok := payload["total"]
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 total", nil)
}
hasMore, ok := payload["hasMore"].(bool)
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少布尔字段 hasMore", nil)
}
nextCursor := ""
if value, exists := payload["nextToken"]; exists && value != nil {
var stringValue bool
nextCursor, stringValue = value.(string)
if !stringValue {
return fileCommentPage{}, invalidFileCommentResponse(tool, "nextToken 不是字符串", nil)
}
}
rawItems, exists := payload["items"]
if !exists {
return fileCommentPage{}, invalidFileCommentResponse(tool, "缺少 items", nil)
}
items, ok := rawItems.([]any)
if rawItems == nil {
items = []any{}
ok = true
}
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, "items 不是数组", nil)
}
comments := make([]map[string]any, 0, len(items))
for index, item := range items {
comment, ok := item.(map[string]any)
if !ok {
return fileCommentPage{}, invalidFileCommentResponse(tool, fmt.Sprintf("items[%d] 不是对象", index), nil)
}
if err := validateFileCommentItem(tool, fmt.Sprintf("items[%d]", index), comment); err != nil {
return fileCommentPage{}, err
}
comments = append(comments, projectFileComment(comment))
}
return fileCommentPage{
nodeID: nodeID, total: total, hasMore: hasMore,
nextCursor: strings.TrimSpace(nextCursor), comments: comments,
}, nil
}
func decodeFileCommentPayload(tool, raw string) (map[string]any, error) {
if strings.TrimSpace(raw) == "" {
return nil, invalidFileCommentResponse(tool, "返回为空", nil)
}
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, invalidFileCommentResponse(tool, "返回不是有效 JSON", err)
}
for depth := 0; depth < 2; depth++ {
if _, ok := payload["fileId"]; ok {
break
}
var nested map[string]any
for _, key := range []string{"result", "data"} {
if value, ok := payload[key].(map[string]any); ok {
nested = value
break
}
}
if nested == nil {
break
}
payload = nested
}
return payload, nil
}
func projectFileComment(comment map[string]any) map[string]any {
out := map[string]any{}
for _, key := range []string{"commentId", "parentCommentId", "content", "createdAt", "updatedAt", "options", "anchor"} {
if value, ok := comment[key]; ok {
out[key] = value
}
}
if value, ok := comment["commentCustomType"]; ok {
out["customType"] = value
}
creator := map[string]any{}
for source, target := range map[string]string{
"creatorId": "userId", "creatorName": "name", "creatorAvatar": "avatar",
} {
if value, ok := comment[source]; ok {
creator[target] = value
}
}
if len(creator) > 0 {
out["creator"] = creator
}
return out
}
func validateFileCommentItem(tool, path string, comment map[string]any) error {
if _, ok := nonEmptyStringField(comment, "commentId"); !ok {
return invalidFileCommentResponse(tool, path+" 缺少 commentId", nil)
}
if _, ok := comment["anchor"].(map[string]any); !ok {
return invalidFileCommentResponse(tool, path+" 缺少 anchor 对象", nil)
}
return nil
}
func filterFileCommentsByScope(comments []map[string]any, scope string) []map[string]any {
if scope == "" || scope == "all" {
return comments
}
out := make([]map[string]any, 0, len(comments))
for _, comment := range comments {
anchor, _ := comment["anchor"].(map[string]any)
commentScope, _ := anchor["scope"].(string)
if commentScope == scope {
out = append(out, comment)
}
}
return out
}
func fileCommentListPayload(page fileCommentPage, scope string) map[string]any {
comments := page.comments
if comments == nil {
comments = make([]map[string]any, 0)
}
nextCursor := any(nil)
if page.nextCursor != "" {
nextCursor = page.nextCursor
}
return map[string]any{
"nodeId": page.nodeID,
"total": page.total,
"count": len(page.comments),
"hasMore": page.hasMore,
"nextCursor": nextCursor,
"complete": !page.hasMore,
"scope": scope,
"comments": comments,
}
}
func validateFileCommentNextCursor(page fileCommentPage, currentCursor string) error {
if !page.hasMore {
return nil
}
if page.nextCursor == "" {
return fileCommentPaginationError("服务端返回 hasMore=true 但 nextCursor 为空,结果可能不完整")
}
if !allASCIIDigits(page.nextCursor) {
return fileCommentPaginationError("服务端返回的 nextCursor 不是非负数字游标,结果可能不完整")
}
if _, err := strconv.ParseInt(page.nextCursor, 10, 64); err != nil {
return fileCommentPaginationError("服务端返回的 nextCursor 超出 64 位整数范围,结果可能不完整")
}
if page.nextCursor == currentCursor {
return fileCommentPaginationError("服务端分页游标未前进,结果可能不完整")
}
return nil
}
func fileCommentPaginationError(message string) error {
return &CLIError{
Code: CodeContentTruncated,
Message: message,
Suggestion: "请稍后重试,或去掉 --all 后使用服务端返回的 --cursor 分页读取",
Operation: fileCommentServer + "/" + listFileCommentsTool,
}
}
func invalidFileCommentResponse(tool, reason string, cause error) error {
return &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("%s 返回结构异常:%s", tool, reason),
Suggestion: "请确认 doc-comment MCP 与当前 DWS 契约一致",
Operation: fileCommentServer + "/" + tool,
Cause: cause,
}
}
func nonEmptyStringField(payload map[string]any, key string) (string, bool) {
value, ok := payload[key].(string)
value = strings.TrimSpace(value)
return value, ok && value != ""
}
func stringArg(args map[string]any, key string) string {
value, _ := args[key].(string)
return strings.TrimSpace(value)
}
+647
View File
@@ -0,0 +1,647 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type fileCommentTestCall struct {
server string
tool string
args map[string]any
}
type fileCommentTestCaller struct {
calls []fileCommentTestCall
responses []string
err error
dryRun bool
}
func (c *fileCommentTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for key, value := range args {
copied[key] = value
}
c.calls = append(c.calls, fileCommentTestCall{server: server, tool: tool, args: copied})
if c.err != nil {
return nil, c.err
}
response := `{"result":{"fileId":"file-1","comment":{"commentId":"1","content":"ok","anchor":{"version":"v1","surface":"file","scope":"whole"}}}}`
if len(c.responses) > 0 {
response = c.responses[0]
c.responses = c.responses[1:]
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
}
func (c *fileCommentTestCaller) Format() string { return "json" }
func (c *fileCommentTestCaller) DryRun() bool { return c.dryRun }
func (c *fileCommentTestCaller) Fields() string { return "" }
func (c *fileCommentTestCaller) JQ() string { return "" }
func executeFileCommentCommand(t *testing.T, caller *fileCommentTestCaller, stdin string, args ...string) ([]byte, error) {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
deps.Out.errW = io.Discard
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.SetOut(&stdout)
root.SetErr(io.Discard)
root.SetIn(strings.NewReader(stdin))
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
drive := &cobra.Command{Use: "drive"}
drive.AddCommand(newDriveFileCommentCmd())
root.AddCommand(drive)
var setOutput func(*cobra.Command)
setOutput = func(command *cobra.Command) {
command.SetOut(&stdout)
command.SetErr(io.Discard)
for _, child := range command.Commands() {
setOutput(child)
}
}
setOutput(root)
root.SetArgs(args)
err := root.Execute()
return stdout.Bytes(), err
}
func decodeFileCommentTestOutput(t *testing.T, raw []byte) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode output %q: %v", raw, err)
}
return payload
}
func TestDriveFileCommentListMapsFiltersAndProjects(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{
"result": {
"fileId": "resolved-file",
"total": 2,
"count": 2,
"hasMore": false,
"nextToken": null,
"items": [
{
"commentId": "101",
"parentCommentId": null,
"content": "whole",
"creatorId": "user-1",
"creatorName": "Alice",
"creatorAvatar": "https://avatar/1",
"createdAt": 1785920000000,
"updatedAt": 1785920000100,
"commentCustomType": "common",
"anchor": {"version":"v1","surface":"file","scope":"whole"}
},
{
"commentId": "102",
"content": "partial",
"commentCustomType": "highlight",
"options": {"page":"1"},
"anchor": {"version":"v1","surface":"file","scope":"partial","selector":{"kind":"legacy-highlight"}}
}
]
}
}`}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list",
"--node", "https://alidocs.dingtalk.com/i/drive/file",
"--space-id", "123", "--limit", "20", "--scope", "whole",
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.server != fileCommentServer || call.tool != listFileCommentsTool {
t.Fatalf("target = %s/%s", call.server, call.tool)
}
wantArgs := map[string]any{
"fileId": "https://alidocs.dingtalk.com/i/drive/file",
"spaceId": "123",
"maxResults": 20,
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", call.args, wantArgs)
}
payload := decodeFileCommentTestOutput(t, out)
if payload["nodeId"] != "resolved-file" || payload["total"] != float64(2) ||
payload["count"] != float64(1) || payload["complete"] != true || payload["scope"] != "whole" {
t.Fatalf("list output = %#v", payload)
}
if payload["nextCursor"] != nil || payload["hasMore"] != false {
t.Fatalf("pagination output = %#v", payload)
}
comments := payload["comments"].([]any)
comment := comments[0].(map[string]any)
if comment["commentId"] != "101" || comment["customType"] != "common" {
t.Fatalf("comment projection = %#v", comment)
}
creator := comment["creator"].(map[string]any)
if creator["userId"] != "user-1" || creator["name"] != "Alice" || creator["avatar"] != "https://avatar/1" {
t.Fatalf("creator projection = %#v", creator)
}
if _, leaked := comment["creatorId"]; leaked {
t.Fatalf("raw creator fields leaked: %#v", comment)
}
}
func TestDriveFileCommentListKeepsEmptyCommentsAsArray(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":0,"count":0,"hasMore":false,"nextToken":null,"items":[]
}`}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list", "--node", "file-1",
)
if err != nil {
t.Fatal(err)
}
payload := decodeFileCommentTestOutput(t, out)
comments, ok := payload["comments"].([]any)
if !ok || len(comments) != 0 {
t.Fatalf("comments = %#v, want an empty JSON array", payload["comments"])
}
if payload["total"] != float64(0) || payload["count"] != float64(0) ||
payload["hasMore"] != false || payload["complete"] != true || payload["nextCursor"] != nil {
t.Fatalf("empty list output = %#v", payload)
}
}
func TestDriveFileCommentListAllAggregatesPages(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{
`{"fileId":"file-1","total":3,"count":2,"hasMore":true,"nextToken":"2","items":[
{"commentId":"1","anchor":{"scope":"whole"}},
{"commentId":"2","anchor":{"scope":"partial"}}
]}`,
`{"fileId":"file-1","total":3,"count":1,"hasMore":false,"nextToken":null,"items":[
{"commentId":"3","anchor":{"scope":"partial"}}
]}`,
}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list", "--node", "file-1", "--all", "--scope", "partial",
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["maxResults"] != fileCommentMaxPageSize {
t.Fatalf("first args = %#v", caller.calls[0].args)
}
if _, ok := caller.calls[0].args["nextToken"]; ok {
t.Fatalf("first page unexpectedly has cursor: %#v", caller.calls[0].args)
}
if caller.calls[1].args["nextToken"] != "2" {
t.Fatalf("second args = %#v", caller.calls[1].args)
}
for _, call := range caller.calls {
if _, ok := call.args["all"]; ok {
t.Fatalf("local --all leaked to MCP: %#v", call.args)
}
if _, ok := call.args["scope"]; ok {
t.Fatalf("local --scope leaked to MCP: %#v", call.args)
}
}
payload := decodeFileCommentTestOutput(t, out)
if payload["total"] != float64(3) || payload["count"] != float64(2) ||
payload["hasMore"] != false || payload["complete"] != true || payload["nextCursor"] != nil {
t.Fatalf("aggregate output = %#v", payload)
}
comments := payload["comments"].([]any)
if comments[0].(map[string]any)["commentId"] != "2" || comments[1].(map[string]any)["commentId"] != "3" {
t.Fatalf("scope-filtered comments = %#v", comments)
}
}
func TestDriveFileCommentListRejectsPaginationAnomalies(t *testing.T) {
tests := []struct {
name string
args []string
response string
message string
}{
{
name: "missing cursor",
args: []string{"drive", "comment", "list", "--node", "file-1", "--all"},
response: `{"fileId":"file-1","total":1,"count":1,"hasMore":true,"nextToken":null,"items":[]}`,
message: "nextCursor 为空",
},
{
name: "stalled cursor",
args: []string{"drive", "comment", "list", "--node", "file-1", "--cursor", "2"},
response: `{"fileId":"file-1","total":2,"count":1,"hasMore":true,"nextToken":"2","items":[]}`,
message: "游标未前进",
},
{
name: "invalid server cursor",
args: []string{"drive", "comment", "list", "--node", "file-1"},
response: `{"fileId":"file-1","total":2,"count":1,"hasMore":true,"nextToken":"next","items":[]}`,
message: "不是非负数字游标",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{tt.response}}
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, tt.message) {
t.Fatalf("error = %#v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
})
}
}
func TestDriveFileCommentListValidatesLocalParameters(t *testing.T) {
tests := []struct {
name string
args []string
}{
{"all with cursor", []string{"--all", "--cursor", "1"}},
{"all with limit", []string{"--all", "--limit", "20"}},
{"zero limit", []string{"--limit", "0"}},
{"large limit", []string{"--page-size", "201"}},
{"nonnumeric cursor", []string{"--cursor", "next"}},
{"overflow cursor", []string{"--cursor", "999999999999999999999999999999"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{}
args := []string{"drive", "comment", "list", "--node", "file-1"}
args = append(args, tt.args...)
if _, err := executeFileCommentCommand(t, caller, "", args...); err == nil {
t.Fatal("invalid arguments unexpectedly succeeded")
}
if len(caller.calls) != 0 {
t.Fatalf("invalid arguments reached MCP: %#v", caller.calls)
}
})
}
}
func TestDriveFileCommentNumericNodeRequiresSpaceIDForListAndCreateAliases(t *testing.T) {
tests := []struct {
name string
args []string
}{
{
name: "list id alias",
args: []string{"drive", "comment", "list", "--id", "231773999335"},
},
{
name: "create file-id alias",
args: []string{"drive", "comment", "create", "--file-id", "231773999335", "--content", "test", "--yes"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{}
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeInvalidParam || !strings.Contains(cliErr.Message, "--space-id") {
t.Fatalf("error = %#v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("numeric node without space-id reached MCP: %#v", caller.calls)
}
})
}
caller := &fileCommentTestCaller{responses: []string{`{
"fileId":"resolved-file","total":0,"count":0,"hasMore":false,"nextToken":null,"items":[]
}`}}
if _, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "list", "--url", "231773999335", "--space-id", "2402756201",
); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].args["fileId"] != "231773999335" || caller.calls[0].args["spaceId"] != "2402756201" {
t.Fatalf("numeric node with space-id args = %#v", caller.calls)
}
if allASCIIDigits("") {
t.Fatal("empty string unexpectedly accepted as numeric")
}
}
func TestDriveFileCommentCreateValidatesMapsAndProjects(t *testing.T) {
validContent := strings.Repeat("a", fileCommentMaxContentLength)
caller := &fileCommentTestCaller{responses: []string{`{"result":{"fileId":"resolved-file","comment":{
"commentId":"912345","parentCommentId":null,"content":"created","creatorId":"user-1",
"createdAt":1785920000000,"commentCustomType":"common",
"anchor":{"version":"v1","surface":"file","scope":"whole","selector":null}
}}}`}}
out, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "create", "--node", "file-1", "--space-id", "123",
"--content", validContent, "--yes",
)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != fileCommentServer || caller.calls[0].tool != createFileCommentTool {
t.Fatalf("calls = %#v", caller.calls)
}
wantArgs := map[string]any{"fileId": "file-1", "spaceId": "123", "content": validContent}
if !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
t.Fatalf("args = %#v, want %#v", caller.calls[0].args, wantArgs)
}
payload := decodeFileCommentTestOutput(t, out)
if payload["nodeId"] != "resolved-file" || payload["commentId"] != "912345" ||
payload["content"] != "created" || payload["customType"] != "common" {
t.Fatalf("create output = %#v", payload)
}
if _, nested := payload["comment"]; nested {
t.Fatalf("create output was not flattened: %#v", payload)
}
for _, tc := range []struct {
name string
content string
}{
{"blank", " \t"},
{"ascii over limit", strings.Repeat("a", fileCommentMaxContentLength+1)},
{"utf16 over limit", strings.Repeat("😀", 1050)},
} {
t.Run(tc.name, func(t *testing.T) {
invalidCaller := &fileCommentTestCaller{}
_, err := executeFileCommentCommand(t, invalidCaller, "",
"drive", "comment", "create", "--node", "file-1", "--content", tc.content, "--yes",
)
if err == nil {
t.Fatal("invalid content unexpectedly succeeded")
}
if len(invalidCaller.calls) != 0 {
t.Fatalf("invalid content reached MCP: %#v", invalidCaller.calls)
}
})
}
}
func TestDriveFileCommentCreatePublishesAndEnforcesConfirmation(t *testing.T) {
group := newDriveFileCommentCmd()
create, remaining, err := group.Find([]string{"create"})
if err != nil || len(remaining) != 0 {
t.Fatalf("find create: remaining=%v err=%v", remaining, err)
}
final, ok := contractfinal.RuntimeContractFinal(create)
if !ok || final.Safety == nil {
t.Fatal("create command is missing ContractFinal Safety")
}
if safety := *final.Safety; safety.Effect != "write" || safety.Risk != "medium" ||
safety.Confirmation != "user_required" || safety.Idempotency != "unknown" {
t.Fatalf("create safety = %#v", safety)
}
caller := &fileCommentTestCaller{}
_, err = executeFileCommentCommand(t, caller, "",
"drive", "comment", "create", "--node", "file-1", "--content", "需要确认",
)
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
t.Fatalf("closed-stdin error = %#v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed create reached MCP: %#v", caller.calls)
}
}
func TestDriveFileCommentRejectsMalformedResponseInsteadOfReturningEmpty(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{"result":{"fileId":"file-1","total":0,"hasMore":false}}`}}
_, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, "缺少 items") {
t.Fatalf("error = %#v", err)
}
}
func TestDriveFileCommentDryRunAndCallErrors(t *testing.T) {
for _, tt := range []struct {
name string
args []string
tool string
}{
{
name: "list dry run",
args: []string{"drive", "comment", "list", "--node", "file-1"},
tool: listFileCommentsTool,
},
{
name: "create dry run",
args: []string{"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes"},
tool: createFileCommentTool,
},
} {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{dryRun: true}
out, err := executeFileCommentCommand(t, caller, "", tt.args...)
if err != nil {
t.Fatal(err)
}
if len(caller.calls) != 0 || !strings.Contains(string(out), `"tool": "`+tt.tool+`"`) {
t.Fatalf("calls = %#v, output = %s", caller.calls, out)
}
})
}
sentinel := errors.New("mcp unavailable")
for _, tt := range []struct {
name string
args []string
}{
{
name: "list call error",
args: []string{"drive", "comment", "list", "--node", "file-1", "--all"},
},
{
name: "create call error",
args: []string{"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes"},
},
} {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{err: sentinel}
_, err := executeFileCommentCommand(t, caller, "", tt.args...)
if !errors.Is(err, sentinel) {
t.Fatalf("error = %#v", err)
}
})
}
}
func TestDriveFileCommentListResponseValidationBranches(t *testing.T) {
tests := []struct {
name string
response string
message string
}{
{name: "empty", response: "", message: "返回为空"},
{name: "invalid json", response: "{", message: "不是有效 JSON"},
{name: "missing file id", response: `{}`, message: "缺少 fileId"},
{name: "missing total", response: `{"fileId":"file-1","hasMore":false,"items":[]}`, message: "缺少 total"},
{name: "missing has more", response: `{"fileId":"file-1","total":0,"items":[]}`, message: "缺少布尔字段 hasMore"},
{name: "next token type", response: `{"fileId":"file-1","total":1,"hasMore":true,"nextToken":2,"items":[]}`, message: "nextToken 不是字符串"},
{name: "items type", response: `{"fileId":"file-1","total":0,"hasMore":false,"items":{}}`, message: "items 不是数组"},
{name: "item type", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[1]}`, message: "items[0] 不是对象"},
{name: "missing comment id", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[{"anchor":{}}]}`, message: "缺少 commentId"},
{name: "missing anchor", response: `{"fileId":"file-1","total":1,"hasMore":false,"items":[{"commentId":"1"}]}`, message: "缺少 anchor 对象"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{tt.response}}
_, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, tt.message) {
t.Fatalf("error = %#v", err)
}
})
}
caller := &fileCommentTestCaller{responses: []string{`{
"data":{"fileId":"file-1","total":0,"hasMore":false,"nextToken":null,"items":null}
}`}}
out, err := executeFileCommentCommand(t, caller, "", "drive", "comment", "list", "--node", "file-1")
if err != nil {
t.Fatal(err)
}
if comments := decodeFileCommentTestOutput(t, out)["comments"].([]any); len(comments) != 0 {
t.Fatalf("comments = %#v", comments)
}
}
func TestDriveFileCommentCreateResponseValidationBranches(t *testing.T) {
tests := []struct {
name string
response string
message string
}{
{name: "invalid json", response: "{", message: "不是有效 JSON"},
{name: "missing file id", response: `{"comment":{"commentId":"1","anchor":{}}}`, message: "缺少 fileId"},
{name: "missing comment", response: `{"fileId":"file-1"}`, message: "缺少 comment 对象"},
{name: "missing comment id", response: `{"fileId":"file-1","comment":{"anchor":{}}}`, message: "缺少 commentId"},
{name: "missing anchor", response: `{"fileId":"file-1","comment":{"commentId":"1"}}`, message: "缺少 anchor 对象"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{tt.response}}
_, err := executeFileCommentCommand(t, caller, "",
"drive", "comment", "create", "--node", "file-1", "--content", "test", "--yes",
)
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || !strings.Contains(cliErr.Message, tt.message) {
t.Fatalf("error = %#v", err)
}
})
}
}
func TestDriveFileCommentPaginationCycleLimitAndPartialPage(t *testing.T) {
cycleCaller := &fileCommentTestCaller{responses: []string{
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"2","items":[]}`,
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"3","items":[]}`,
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"2","items":[]}`,
}}
_, err := executeFileCommentCommand(t, cycleCaller, "", "drive", "comment", "list", "--node", "file-1", "--all")
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "发生循环") {
t.Fatalf("cycle error = %#v", err)
}
limitResponses := make([]string, 0, fileCommentMaxAutoPages)
for index := 0; index < fileCommentMaxAutoPages; index++ {
limitResponses = append(limitResponses, fmt.Sprintf(
`{"fileId":"file-1","total":0,"hasMore":true,"nextToken":"%d","items":[]}`,
index+1,
))
}
limitCaller := &fileCommentTestCaller{responses: limitResponses}
_, err = executeFileCommentCommand(t, limitCaller, "", "drive", "comment", "list", "--node", "file-1", "--all")
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "10 页上限") {
t.Fatalf("page limit error = %#v", err)
}
partialCaller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":1,"hasMore":true,"nextToken":"2",
"items":[{"commentId":"1","anchor":{"scope":"whole"}}]
}`}}
out, err := executeFileCommentCommand(t, partialCaller, "", "drive", "comment", "list", "--node", "file-1")
if err != nil {
t.Fatal(err)
}
payload := decodeFileCommentTestOutput(t, out)
if payload["nextCursor"] != "2" || payload["complete"] != false || payload["hasMore"] != true {
t.Fatalf("partial page = %#v", payload)
}
overflowCaller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":0,"hasMore":true,
"nextToken":"999999999999999999999999999999","items":[]
}`}}
_, err = executeFileCommentCommand(t, overflowCaller, "", "drive", "comment", "list", "--node", "file-1")
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated || !strings.Contains(cliErr.Message, "超出 64 位整数范围") {
t.Fatalf("overflow cursor error = %#v", err)
}
}
func TestDriveFileCommentInternalDefaults(t *testing.T) {
caller := &fileCommentTestCaller{responses: []string{`{
"fileId":"file-1","total":0,"hasMore":false,"nextToken":null,"items":[]
}`}}
testseam.Protect(t, &deps)
InitDeps(caller)
var stdout bytes.Buffer
cmd := &cobra.Command{Use: "list"}
cmd.SetOut(&stdout)
if err := runFileCommentList(cmd, listFileCommentsTool, map[string]any{"fileId": "file-1", "maxResults": 200}); err != nil {
t.Fatal(err)
}
payload := decodeFileCommentTestOutput(t, stdout.Bytes())
if payload["scope"] != "all" {
t.Fatalf("scope = %#v", payload["scope"])
}
if comments := fileCommentListPayload(fileCommentPage{}, "all")["comments"].([]map[string]any); len(comments) != 0 {
t.Fatalf("nil comments projection = %#v", comments)
}
}
@@ -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,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
}
@@ -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": {},
+4
View File
@@ -22,6 +22,9 @@ 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"`
@@ -44,6 +47,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
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}
}
}
+1 -1
View File
@@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) {
}
knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"}
result, err := UpgradeSkillLocations(source)
if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 {
if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 {
t.Fatalf("skill upgrade = %#v, %v", result, err)
}
knownSkillDirs = []string{".real/skills"}
+70 -13
View File
@@ -35,8 +35,10 @@ const (
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
//
// The first entry (.agents/skills) is always updated; subsequent entries are
// only updated when their parent directory already exists.
// The first entry (.agents/skills) is a generic fallback. It is used only
// when no concrete Agent home is detected; otherwise publishing there would
// duplicate every Skill for Agents (including Codex) that scan both roots.
// Subsequent entries are updated when their parent directory already exists.
var knownSkillDirs = []string{
".agents/skills",
".claude/skills",
@@ -45,6 +47,7 @@ var knownSkillDirs = []string{
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".zcode/skills",
".github/skills",
".windsurf/skills",
".augment/skills",
@@ -201,9 +204,9 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
// `dws skill setup --mode mono` flow.
//
// Strategy (matches npm install.js installSkillsToHomes):
// - ~/.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)
// - Concrete agent dirs (claude, cursor, codex, ...) are updated when their
// parent directory exists (e.g. ~/.codex/ exists => user has Codex)
// - ~/.agents/skills/ is used only when no concrete Agent is detected
// - ~/.real/ and other blacklisted paths are NEVER touched
// - If no location was updated at all, fall back to ~/.agents/skills/
//
@@ -514,23 +517,57 @@ func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []str
return publishStagedSkillSet(homeDir, staged, victims)
}
func hasDetectedSpecificSkillRoot(homeDir string) bool {
for _, agentDir := range knownSkillDirs {
if isGenericSkillRoot(agentDir) || isBlacklisted(agentDir) {
continue
}
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if info, err := upgradeStat(parentGate); err == nil && info.IsDir() {
return true
}
}
return false
}
func isGenericSkillRoot(agentDir string) bool {
return filepath.Clean(agentDir) == filepath.Clean(".agents/skills")
}
func retireGenericSkillRoot(homeDir string, managed map[string]bool) error {
base := filepath.Join(homeDir, ".agents", "skills")
victims, err := managedMultiSkillVictims(base, managed)
if err != nil {
return err
}
victims = append(victims, filepath.Join(base, "dws"))
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("迁移通用 Skill 根目录失败: %w", err)
}
return nil
}
// upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory
// per agent home.
func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) {
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir)
for i, agentDir := range knownSkillDirs {
for _, agentDir := range knownSkillDirs {
destDir := filepath.Join(homeDir, agentDir, "dws")
if isBlacklisted(agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted})
continue
}
if isGenericSkillRoot(agentDir) && hasSpecificRoot {
continue
}
if i > 0 {
if !isGenericSkillRoot(agentDir) {
parentGate := filepath.Dir(filepath.Join(homeDir, agentDir))
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped})
continue
}
@@ -542,12 +579,20 @@ func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, e
}
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
}
if hasSpecificRoot && len(result.Succeeded()) > 0 {
genericBase := filepath.Join(homeDir, ".agents", "skills")
if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
}
}
// Fallback: if nothing succeeded, force the primary location. The multi
// leftovers under the primary base are the usual reason the primary
// install failed, so clean them first — failing loud like the multi
// fallback — instead of letting mono and multi co-exist marked OK.
if len(result.Succeeded()) == 0 {
if len(result.Succeeded()) == 0 && !hasSpecificRoot {
destBase := filepath.Join(homeDir, ".agents", "skills")
dest := filepath.Join(destBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil {
@@ -588,18 +633,22 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir)
for i, agentDir := range knownSkillDirs {
for _, agentDir := range knownSkillDirs {
destBase := filepath.Join(homeDir, agentDir)
if isBlacklisted(agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
continue
}
if isGenericSkillRoot(agentDir) && hasSpecificRoot {
continue
}
if i > 0 {
if !isGenericSkillRoot(agentDir) {
parentGate := filepath.Dir(destBase)
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
if _, err := upgradeStat(parentGate); os.IsNotExist(err) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
@@ -611,9 +660,17 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk
}
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
if hasSpecificRoot && len(result.Succeeded()) > 0 {
genericBase := filepath.Join(homeDir, ".agents", "skills")
if err := retireGenericSkillRoot(homeDir, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err})
} else {
result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped})
}
}
// Fallback: if nothing succeeded, force the primary location
if len(result.Succeeded()) == 0 {
if len(result.Succeeded()) == 0 && !hasSpecificRoot {
destBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
+84 -10
View File
@@ -124,7 +124,8 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) {
func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
home := withFakeHome(t)
// .agents always installs; .claude installs (parent exists); .cursor skipped.
// A concrete Agent root wins over the generic .agents fallback; .claude
// installs and .cursor is skipped.
agentsBase := filepath.Join(home, ".agents", "skills")
claudeBase := filepath.Join(home, ".claude", "skills")
for _, base := range []string{agentsBase, claudeBase} {
@@ -164,7 +165,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
t.Fatalf("expected 0 failures, got %v", failed)
}
for _, base := range []string{agentsBase, claudeBase} {
for _, base := range []string{claudeBase} {
if _, err := os.Stat(filepath.Join(base, "dws")); !os.IsNotExist(err) {
t.Errorf("mono leftover still present: %s", filepath.Join(base, "dws"))
}
@@ -192,15 +193,18 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
// Succeeded entries report the agent home base in multi mode.
succeeded := result.Succeeded()
if len(succeeded) != 2 {
t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results)
if len(succeeded) != 1 {
t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results)
}
wantDirs := map[string]bool{agentsBase: true, claudeBase: true}
wantDirs := map[string]bool{claudeBase: true}
for _, d := range succeeded {
if !wantDirs[d.Dir] {
t.Errorf("unexpected succeeded dir %q", d.Dir)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("generic mono duplicate still visible: %v", err)
}
// Multi cache refreshed under the fake home.
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "dingtalk-chat", "SKILL.md")); err != nil {
@@ -212,6 +216,75 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
}
}
func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"})
genericBase := filepath.Join(home, ".agents", "skills")
codexBase := filepath.Join(home, ".codex", "skills")
for _, base := range []string{genericBase, codexBase} {
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
}
legacyNested := filepath.Join(genericBase, "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(legacyNested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyNested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.2.3"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err)
}
want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md")
if _, err := os.Stat(want); err != nil {
t.Fatalf("canonical Codex Skill missing at %s: %v", want, err)
}
for _, duplicate := range []string{
filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"),
filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"),
} {
if _, err := os.Stat(duplicate); !os.IsNotExist(err) {
t.Fatalf("generic duplicate still visible at %s: %v", duplicate, err)
}
}
}
func TestCrossPlatformCoverageUpgradeUsesZCodeRootWithoutGenericDuplicate(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".zcode/skills"})
genericBase := filepath.Join(home, ".agents", "skills")
zcodeBase := filepath.Join(home, ".zcode", "skills")
if err := os.MkdirAll(zcodeBase, 0o755); err != nil {
t.Fatal(err)
}
legacyNested := filepath.Join(genericBase, "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(legacyNested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyNested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat")
result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.2.3"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err)
}
if _, err := os.Stat(filepath.Join(zcodeBase, "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("canonical ZCode Skill missing: %v", err)
}
if _, err := os.Stat(filepath.Join(genericBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("generic duplicate still visible: %v", err)
}
}
func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackPrimary(t *testing.T) {
home := withFakeHome(t)
// No agent parent dirs at all: only .agents (index 0) is attempted and the
@@ -508,7 +581,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *
if len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
}
wantDir := filepath.Join(agentsBase, "dws")
wantDir := agentsBase
if failed[0].Dir != wantDir || failed[0].Err == nil {
t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir)
}
@@ -931,8 +1004,8 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
t.Fatalf("blacklisted home must never be touched, stat err=%v", err)
}
// Per-skill backup failure fails the home; a second home still succeeds so
// the fallback never runs.
// A detected concrete Agent root wins over .agents. A failure while retiring
// the old generic copy is surfaced after the concrete root succeeds.
home2 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
@@ -956,12 +1029,13 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
}
testseam.Swap(t, &upgradeRename, os.Rename)
// Per-skill copy failure fails the home the same way.
// Per-skill copy failure on the concrete home fails that home; with no
// successful concrete target, the generic copy is left untouched.
home3 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil })
os.MkdirAll(filepath.Join(home3, ".claude"), 0o755)
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
if strings.Contains(dst, ".agents") {
if strings.Contains(dst, ".claude") {
return errors.New("copy denied")
}
return copyDir(src, dst)
+1
View File
@@ -23,6 +23,7 @@ SEMANTIC_PATHS = [
ROOT / "internal" / "shortcut" / "semantic_catalog_doc.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_aitable.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_minutes.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_drive.json",
]
+2 -2
View File
@@ -59,7 +59,7 @@ RUNTIME_CONTRACT_END = "<!-- DWS_RUNTIME_CONTRACT_END -->"
# here only after verifying that the product skill has its own reviewed routing
# section and intent table; compacting a sparse skill without an alternative
# route would make its shortcuts harder to discover.
COMPACT_PRODUCT_SERVICES = {"chat", "doc"}
COMPACT_PRODUCT_SERVICES = {"chat", "doc", "drive"}
def md_escape(value: Any) -> str:
@@ -170,7 +170,7 @@ def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str:
# to recover every runtime-normalized declaration. The reviewed public
# catalog is the count authority for this non-enumerating overview.
public_count = sum(1 for item_service, _ in load_public_catalog() if item_service == service)
if service == "doc":
if service in {"doc", "drive"}:
discovery = """已知意图按下方路由。"""
else:
discovery = """已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。"""
+56
View File
@@ -408,6 +408,28 @@ multi_tree_has_skills() {
return 1
}
# Move DWS-owned copies out of the generic root once a concrete Agent root is
# active. This prevents Agents such as Codex from discovering duplicates.
retire_generic_skill_root() {
_rgs_root="$1"
_rgs_base="$_rgs_root/.agents/skills"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1
_rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
[ -d "$_rgs_victim" ] || continue
if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then
continue
fi
if ! backup_and_record_skill_dir "$_rgs_victim" "$_rgs_backups"; then
restore_multi_skill_set /dev/null "$_rgs_backups" || true
rm -rf "$_rgs_stage"
return 1
fi
done
rm -rf "$_rgs_stage"
}
# Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
install_multi_skills_to_root() {
multi_src="$1"
@@ -416,6 +438,15 @@ install_multi_skills_to_root() {
attempted=0
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
for agent_dir in \
".agents/skills" \
".claude/skills" \
@@ -424,6 +455,7 @@ install_multi_skills_to_root() {
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".zcode/skills" \
".github/skills" \
".windsurf/skills" \
".augment/skills" \
@@ -434,6 +466,10 @@ install_multi_skills_to_root() {
".openclaw/skills" \
".hermes/skills"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
@@ -449,6 +485,9 @@ install_multi_skills_to_root() {
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
installed=$((installed + 1))
fi
@@ -618,6 +657,15 @@ install_skills_to_root() {
attempted=0
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
for agent_dir in \
".agents/skills" \
".claude/skills" \
@@ -626,6 +674,7 @@ install_skills_to_root() {
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".zcode/skills" \
".github/skills" \
".windsurf/skills" \
".augment/skills" \
@@ -636,6 +685,10 @@ install_skills_to_root() {
".openclaw/skills" \
".hermes/skills"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
@@ -655,6 +708,9 @@ install_skills_to_root() {
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ]; then
if [ "$root" = "$HOME" ]; then
flabel="~/.agents/skills/$SKILL_NAME"
+47
View File
@@ -61,6 +61,7 @@ $AgentDirs = @(
".qoderwork\skills",
".gemini\skills",
".codex\skills",
".zcode\skills",
".github\skills",
".windsurf\skills",
".augment\skills",
@@ -457,6 +458,38 @@ function Restore-MultiSkillSet {
return $ok
}
function Move-GenericSkillRootToBackup {
param([string]$Root)
$baseDir = Join-Path $Root ".agents\skills"
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $baseDir $SkillName))
foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) {
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) {
$victims.Add($existing.FullName)
}
}
$backups = @()
try {
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($victim in $victims) {
if (!$seen.Add($victim)) { continue }
$backupPath = ""
if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) {
throw "通用 Skill 副本备份失败: $victim"
}
if ($backupPath) {
$backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath }
}
}
return $true
} catch {
Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null
Write-Say "⚠️ 通用 Skill 副本迁移失败,已回滚: $_"
return $false
}
}
function Copy-SkillToDir {
param([string]$SkillSrc, [string]$Dest, [string]$Label)
@@ -780,7 +813,11 @@ function Install-SkillsToHomes {
$installed = 0
$attempted = 0
$failed = 0
$specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
Test-Path (Split-Path (Join-Path $Root $_) -Parent)
})
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
$agentDir = $AgentDirs[$i]
$baseDir = Join-Path $Root $agentDir
$parentGate = Split-Path $baseDir -Parent
@@ -800,6 +837,9 @@ function Install-SkillsToHomes {
$failed++
}
}
if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
}
if ($attempted -eq 0) {
$fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName
if ($Root -eq $HOME) {
@@ -851,7 +891,11 @@ function Install-MultiSkillsToHomes {
$installed = 0
$attempted = 0
$failed = 0
$specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object {
Test-Path (Split-Path (Join-Path $Root $_) -Parent)
})
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue }
$agentDir = $AgentDirs[$i]
$baseDir = Join-Path $Root $agentDir
$parentGate = Split-Path $baseDir -Parent
@@ -866,6 +910,9 @@ function Install-MultiSkillsToHomes {
$failed++
}
}
if ($specificAgents.Count -gt 0 -and $installed -gt 0) {
if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ }
}
if ($attempted -eq 0) {
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") {
$installed++
+57
View File
@@ -643,6 +643,29 @@ _install_mono_to_base() {
say "✅ Skills → ${_mono_label} (${_mono_count} files)"
}
# Move DWS-owned copies out of the generic root once a concrete Agent root is
# active. This prevents Agents such as Codex from discovering the same Skill
# through both ~/.agents/skills and ~/.codex/skills.
retire_generic_skill_root() {
_rgs_root="$1"
_rgs_base="$_rgs_root/.agents/skills"
_rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1
_rgs_backups="$_rgs_stage/backups"
: > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; }
for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do
[ -d "$_rgs_victim" ] || continue
if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then
continue
fi
if ! backup_and_record_skill_dir "$_rgs_victim" "$_rgs_backups"; then
restore_multi_skill_set /dev/null "$_rgs_backups" || true
rm -rf "$_rgs_stage"
return 1
fi
done
rm -rf "$_rgs_stage"
}
# Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
# Installing mono removes proven DWS-managed multi leftovers for mutual exclusion,
# mirroring `dws skill setup --mode mono`.
@@ -653,6 +676,15 @@ install_skills_to_homes() {
attempted=0
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
for agent_dir in \
".agents/skills" \
".claude/skills" \
@@ -661,6 +693,7 @@ install_skills_to_homes() {
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".zcode/skills" \
".github/skills" \
".windsurf/skills" \
".augment/skills" \
@@ -671,6 +704,10 @@ install_skills_to_homes() {
".openclaw/skills" \
".hermes/skills"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
@@ -693,6 +730,9 @@ install_skills_to_homes() {
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ]; then
case "$root" in
"$HOME")
@@ -746,6 +786,15 @@ install_multi_skills_to_homes() {
attempted=0
failed=0
idx=0
specific_agents=0
for specific_dir in \
".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \
".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \
".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \
".trae/skills" ".openclaw/skills" ".hermes/skills"
do
[ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1))
done
for agent_dir in \
".agents/skills" \
".claude/skills" \
@@ -754,6 +803,7 @@ install_multi_skills_to_homes() {
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".zcode/skills" \
".github/skills" \
".windsurf/skills" \
".augment/skills" \
@@ -764,6 +814,10 @@ install_multi_skills_to_homes() {
".openclaw/skills" \
".hermes/skills"
do
if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then
idx=$((idx + 1))
continue
fi
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
@@ -779,6 +833,9 @@ install_multi_skills_to_homes() {
fi
idx=$((idx + 1))
done
if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then
retire_generic_skill_root "$root" || failed=$((failed + 1))
fi
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
installed=$((installed + 1))
fi
+11
View File
@@ -119,6 +119,17 @@ content-only)
fi
exit 1
fi
if ! awk -F ' ' '
$1 == "M" && $2 == "CHANGELOG.md" && NF == 2 { next }
$1 == "D" && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 2 { next }
$1 == "A" && $2 ~ /^\.changes\/released\/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 2 { next }
$1 ~ /^R[0-9]+$/ && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && $3 ~ /^\.changes\/released\/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?\/[a-z0-9][a-z0-9._-]*\.md$/ && NF == 3 { next }
{ invalid = 1 }
END { exit invalid }
' "$TMP_ROOT/name-status"; then
printf '%s\n' 'error: CHANGELOG.md may accompany only release-fragment archival; ordinary PRs must add .changes/<unique-name>.md without editing CHANGELOG.md' >&2
exit 1
fi
;;
esac
+191
View File
@@ -0,0 +1,191 @@
#!/bin/sh
set -eu
# Fragment names are matched with ASCII ranges, so keep collation deterministic.
LC_ALL=C
export LC_ALL
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
usage() { printf '%s\n' 'usage: check-release-fragments.sh BASE HEAD' >&2; }
[ "$#" -eq 2 ] || { usage; exit 2; }
base="$(git -C "$ROOT" rev-parse --verify --quiet "$1^{commit}")" || { usage; exit 2; }
head="$(git -C "$ROOT" rev-parse --verify --quiet "$2^{commit}")" || { usage; exit 2; }
merge_base="$(git -C "$ROOT" merge-base "$base" "$head")"
tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-fragment-policy.XXXXXX")"
cleanup() { rm -rf "$tmp_root"; }
trap cleanup EXIT HUP INT TERM
git -C "$ROOT" diff --no-ext-diff --find-renames --name-status "$merge_base" "$head" >"$tmp_root/status"
archive_changed=false
if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) found = 1 } END { exit !found }' "$tmp_root/status"; then
archive_changed=true
fi
if [ "$archive_changed" = true ]; then
release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')"
[ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || {
printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section' >&2
exit 1
}
# The archive directory is matched as a literal prefix, never as a regex:
# interpolating the version into one would make `.` match any character, so
# `1.0.1-beta.1` would also admit `.changes/released/1x0x1-betaX1/` and break
# the documented audit trail. Only the fragment basename, whose character class
# is fixed, stays a pattern.
if ! awk -F '\t' -v prefix=".changes/released/$release_version/" '
$1 == "M" && $2 == "CHANGELOG.md" && NF == 2 { changelog = 1; next }
$1 == "R100" && NF == 3 && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ && index($3, prefix) == 1 {
target = substr($3, length(prefix) + 1)
if (target !~ /^[a-z0-9][a-z0-9._-]*\.md$/) { invalid = 1; next }
source = $2; sub(/^.*\//, "", source)
if (source != target) invalid = 1
moved++; next
}
{ invalid = 1 }
END { exit !(changelog && moved > 0 && !invalid) }
' "$tmp_root/status"; then
printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/<name>.md to .changes/released/<new-version>/<name>.md in the matching release-seal PR' >&2
exit 1
fi
source_changes="$tmp_root/source-changes"
mkdir -p "$source_changes"
git -C "$ROOT" ls-tree -r --name-only "$merge_base" -- .changes |
while IFS= read -r path; do
case "$path" in
.changes/*.md)
name="${path#.changes/}"
mkdir -p "$(dirname "$source_changes/$name")"
git -C "$ROOT" show "$merge_base:$path" >"$source_changes/$name"
;;
esac
done
"$ROOT/scripts/release/render-release-fragments.sh" "$source_changes" >"$tmp_root/expected-notes"
git -C "$ROOT" show "$head:CHANGELOG.md" |
awk -v heading="## [$release_version] - " '
index($0, heading) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' >"$tmp_root/actual-notes"
normalize_notes() {
awk '
/^[[:space:]]*$/ && !started { next }
{ started = 1; lines[++count] = $0 }
END {
while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count--
for (line_no = 1; line_no <= count; line_no++) print lines[line_no]
}
' "$1"
}
normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized"
normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized"
if ! cmp -s "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized"; then
printf '%s\n' 'error: release-seal CHANGELOG section does not exactly match the rendered active release fragments' >&2
diff -u "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized" >&2 || true
exit 1
fi
else
if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) invalid = 1 } END { exit !invalid }' "$tmp_root/status"; then
printf '%s\n' 'error: archived release fragments are immutable outside their release-seal PR' >&2
exit 1
fi
if awk -F '\t' '
$1 == "D" && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ { invalid = 1 }
$1 ~ /^R[0-9]+$/ && $2 ~ /^\.changes\/[a-z0-9][a-z0-9._-]*\.md$/ { invalid = 1 }
END { exit !invalid }
' "$tmp_root/status"; then
printf '%s\n' 'error: active release fragments may be deleted or renamed only by the matching release-seal archival move' >&2
exit 1
fi
fi
# `.changes/released/**` carries its own immutability and release-seal checks
# above, so every other `.changes` change must revalidate the top-level tree.
# This trigger must not be narrowed to single-level paths or to the legal
# fragment name pattern: git records no diff entry for a directory itself, so
# adding `.changes/foo/bar.md` only ever shows the nested path, and an illegally
# named or non-regular entry only ever shows its own path. Either one would skip
# validation here and then break the next unrelated PR that adds a legal
# fragment.
git -C "$ROOT" diff --no-ext-diff --name-only "$merge_base" "$head" -- .changes >"$tmp_root/changes-paths"
changes_tree_changed=false
if awk '
$0 ~ /^\.changes\/released\// { next }
{ found = 1 }
END { exit !found }
' "$tmp_root/changes-paths"; then
changes_tree_changed=true
fi
# Re-rendering is driven by fragment changes only. `.changes/README.md` is the
# contributor contract rather than release content, and it may be edited while no
# fragment is pending, which the renderer would reject as an empty fragment set.
fragment_changed=false
if awk '
$0 ~ /^\.changes\/released\// { next }
$0 == ".changes/README.md" { next }
{ found = 1 }
END { exit !found }
' "$tmp_root/changes-paths"; then
fragment_changed=true
fi
if [ "$changes_tree_changed" = true ]; then
# `.changes` itself must stay a directory: replacing it with a blob or a
# symlink leaves the child listing below empty, which would report no invalid
# entries while silently discarding every fragment.
changes_root_type="$(git -C "$ROOT" ls-tree "$head" -- .changes | awk 'NR == 1 { print $2 }')"
if [ "$changes_root_type" != tree ]; then
printf '%s\n' 'error: .changes must remain a directory holding README.md, released/, and release fragments' >&2
exit 1
fi
git -C "$ROOT" ls-tree "$head" -- .changes/ >"$tmp_root/head-entries"
awk '
{
mode = $1
type = $2
path = $0
sub(/^[^\t]*\t/, "", path)
name = path
sub(/^.*\//, "", name)
if (path == ".changes/README.md") {
if (mode == "100644" && type == "blob") next
print path
next
}
if (path == ".changes/released") {
if (type == "tree") next
print path
next
}
if (mode != "100644" || type != "blob") { print path; next }
if (name !~ /^[a-z0-9][a-z0-9._-]*\.md$/) { print path; next }
}
' "$tmp_root/head-entries" >"$tmp_root/invalid-entries"
if [ -s "$tmp_root/invalid-entries" ]; then
printf '%s\n' 'error: .changes/ accepts only README.md, released/, and release fragments named <name>.md matching ^[a-z0-9][a-z0-9._-]*\.md$ stored as regular 100644 files' >&2
sed 's/^/ /' "$tmp_root/invalid-entries" >&2
exit 1
fi
# Only an ordinary fragment change is re-rendered here: a release-seal branch
# is already held to the stricter rendered-notes comparison above and its
# fragments have moved into the archive, and a README-only edit carries no
# release content to render.
if [ "$fragment_changed" = true ] && [ "$archive_changed" = false ]; then
head_changes="$tmp_root/head-changes"
mkdir -p "$head_changes"
awk '{ path = $0; sub(/^[^\t]*\t/, "", path); print path }' "$tmp_root/head-entries" |
while IFS= read -r path; do
case "$path" in
.changes/released) continue ;;
esac
git -C "$ROOT" show "$head:$path" >"$head_changes/${path#.changes/}"
done
"$ROOT/scripts/release/render-release-fragments.sh" "$head_changes" >/dev/null
fi
fi
+1 -1
View File
@@ -14,7 +14,7 @@ runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
chat_target_bytes=10000
chat_max_overage_percent=5
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
doc_max_bytes=9000
doc_max_bytes=10000
event_max_bytes=10000
runtime_contract_max_bytes=3000
File diff suppressed because it is too large Load Diff
+19 -8
View File
@@ -10,6 +10,7 @@ VERSION="${2:-}"
FROM_BETA=""
FROM_REF=""
CHANGELOG="$ROOT/CHANGELOG.md"
CHANGES_DIR="$ROOT/.changes"
usage() {
cat >&2 <<'EOF'
@@ -19,6 +20,7 @@ Options:
--from-beta <tag> Required for stable release notes
--from-ref <ref> Commit-list baseline for prerelease notes
--changelog <path> Override CHANGELOG.md path
--changes-dir <path> Override release fragment directory
EOF
}
@@ -29,6 +31,7 @@ while [ "$#" -gt 0 ]; do
--from-beta) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_BETA="$2"; shift 2 ;;
--from-ref) [ "$#" -ge 2 ] || { usage; exit 2; }; FROM_REF="$2"; shift 2 ;;
--changelog) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGELOG="$2"; shift 2 ;;
--changes-dir) [ "$#" -ge 2 ] || { usage; exit 2; }; CHANGES_DIR="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
@@ -60,21 +63,19 @@ fi
release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}"
section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")"
output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")"
cleanup() { rm -f "$section" "$output"; }
fragments="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragments.XXXXXX")"
cleanup() { rm -f "$section" "$output" "$fragments"; }
trap cleanup EXIT HUP INT TERM
{
printf '## [%s] - %s\n\n' "$semver" "$release_date"
if [ "$CHANNEL" = "stable" ]; then
printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA"
else
printf '<!-- Summarize what this beta validates. Remove every TODO before publishing. -->\n\n'
fi
printf '### Changed\n\n'
if [ "$CHANNEL" = "stable" ]; then
printf '### Changed\n\n'
printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA"
else
printf -- '- TODO: summarize this beta candidate and its validation scope.\n'
"$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments"
cat "$fragments"
fi
} > "$section"
@@ -105,7 +106,17 @@ fi
[ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; }
cp "$output" "$CHANGELOG"
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
if [ "$CHANNEL" = "prerelease" ]; then
archive_dir="$CHANGES_DIR/released/$semver"
mkdir -p "$archive_dir"
find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -exec mv {} "$archive_dir"/ \;
fi
if [ "$CHANNEL" = "stable" ]; then
printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION"
else
printf 'Prepared CHANGELOG and archived release fragments for %s. Review, commit, and merge the release-seal PR before release.\n' "$VERSION"
fi
if [ -n "$FROM_REF" ] && git rev-parse --verify --quiet "$FROM_REF^{commit}" >/dev/null; then
printf '\nCommits since %s:\n' "$FROM_REF"
git log --oneline "$FROM_REF..HEAD"
+116
View File
@@ -0,0 +1,116 @@
#!/bin/sh
set -eu
# Fragment names are matched with ASCII ranges, so keep collation deterministic.
LC_ALL=C
export LC_ALL
CHANGES_DIR="${1:-.changes}"
usage() {
printf '%s\n' 'usage: render-release-fragments.sh [changes-dir]' >&2
}
[ "$#" -le 1 ] || { usage; exit 2; }
[ -d "$CHANGES_DIR" ] || { printf 'release fragments directory not found: %s\n' "$CHANGES_DIR" >&2; exit 1; }
tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-fragments.XXXXXX")"
cleanup() { rm -rf "$tmp_root"; }
trap cleanup EXIT HUP INT TERM
# A fragment must be a regular file named ^[a-z0-9][a-z0-9._-]*\.md$. Anything
# else is rejected rather than skipped, so a symlink or an illegally named
# fragment can never be silently dropped from the rendered notes.
validate_fragment_name() {
name="$1"
path="$2"
case "$name" in
*.md) ;;
*) printf 'invalid release fragment filename: %s\n' "$path" >&2; return 1 ;;
esac
case "$name" in
[a-z0-9]*) ;;
*) printf 'invalid release fragment filename: %s\n' "$path" >&2; return 1 ;;
esac
case "$name" in
*[!a-z0-9._-]*) printf 'invalid release fragment filename: %s\n' "$path" >&2; return 1 ;;
esac
}
find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -print | sort >"$tmp_root/entries"
: >"$tmp_root/files"
while IFS= read -r entry; do
base="${entry##*/}"
if [ "$base" = 'README.md' ]; then
continue
fi
if [ -L "$entry" ]; then
printf 'release fragment must be a regular file, not a symbolic link: %s\n' "$entry" >&2
exit 1
fi
if [ -d "$entry" ]; then
if [ "$base" = 'released' ]; then
continue
fi
printf 'unexpected directory in release fragments directory: %s\n' "$entry" >&2
exit 1
fi
if [ ! -f "$entry" ]; then
printf 'release fragment must be a regular file: %s\n' "$entry" >&2
exit 1
fi
validate_fragment_name "$base" "$entry"
printf '%s\n' "$entry" >>"$tmp_root/files"
done <"$tmp_root/entries"
[ -s "$tmp_root/files" ] || {
printf '%s\n' 'no release fragments found; add .changes/<unique-name>.md before preparing a prerelease changelog' >&2
exit 1
}
validate_fragment() {
fragment="$1"
validate_fragment_name "${fragment##*/}" "$fragment"
[ "$(sed -n '1p' "$fragment")" = '---' ] &&
[ "$(sed -n '3p' "$fragment")" = '---' ] || {
printf 'invalid release fragment header: %s\n' "$fragment" >&2
return 1
}
case "$(sed -n '2p' "$fragment")" in
'category: Added'|'category: Changed'|'category: Deprecated'|'category: Removed'|'category: Fixed'|'category: Security') ;;
*) printf 'invalid release fragment category: %s\n' "$fragment" >&2; return 1 ;;
esac
body="$(sed -n '4,$p' "$fragment")"
printf '%s\n' "$body" | grep -Eq '^- [^[:space:]].*' || {
printf 'release fragment must contain a non-empty Markdown list item: %s\n' "$fragment" >&2
return 1
}
if printf '%s\n' "$body" | grep -Eqi '(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)'; then
printf 'release fragment must not contain TODO/TBD: %s\n' "$fragment" >&2
return 1
fi
}
while IFS= read -r fragment; do
validate_fragment "$fragment"
done <"$tmp_root/files"
for category in Added Changed Deprecated Removed Fixed Security; do
category_files="$tmp_root/$category"
: >"$category_files"
while IFS= read -r fragment; do
if [ "$(sed -n '2p' "$fragment")" = "category: $category" ]; then
printf '%s\n' "$fragment" >>"$category_files"
fi
done <"$tmp_root/files"
[ -s "$category_files" ] || continue
printf '### %s\n\n' "$category"
while IFS= read -r fragment; do
awk 'NR >= 4 { if ($0 ~ /[^[:space:]]/) started = 1; if (started) print }' "$fragment"
printf '\n'
done <"$category_files"
done
@@ -61,6 +61,7 @@ HOME_AGENT_PARENTS="
.qoderwork
.gemini
.codex
.zcode
.github
.windsurf
.augment
@@ -79,6 +80,7 @@ HOME_SKILL_BASES="
.qoderwork/skills
.gemini/skills
.codex/skills
.zcode/skills
.github/skills
.windsurf/skills
.augment/skills
+1 -1
View File
@@ -51,7 +51,7 @@ cli_version: ">=1.0.15"
| `devapp` | 19 | `dingtalk-misc` |
| `ding` | 4 | `dingtalk-misc` |
| `doc` | 45 | `dingtalk-doc` |
| `drive` | 7 | `dingtalk-drive` |
| `drive` | 28 | `dingtalk-drive` |
| `mail` | 10 | `dingtalk-mail` |
| `minutes` | 27 | `dingtalk-minutes` |
| `oa` | 7 | `dingtalk-misc` |
+4
View File
@@ -565,6 +565,7 @@ Flags:
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
--group 指定群聊 openConversationId 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。纯文本/Markdown 单聊传 --user 时直接走 userId 发送能力,不需要先手动查询 openDingTalkId。推荐使用 --text flag 传递消息内容(也支持位置参数)。可选 --title 作为消息标题。
图文混排 Markdown 中,公网图片 URL 需要写成 `![图片标题](https://example.com/image.png)` 才会以内联图片展示;省略开头的 `!` 时会按链接/URL 展示,不会渲染为图片。
若用户只提供了数字群号而非 openConversationId,需先调用 `chat group get-by-group-id` 将群号转为 openConversationId,再传入 --group。
--群聊时可选 --at-all @所有人,或 --at-open-dingtalk-ids 指定成员(仅群聊时生效)。
--本地图片、文件、音频或视频统一用 --msg-type file --file-path;图片会作为可下载的文件附件发送。--msg-type image --media-id 仅用于上游已经提供有效 mediaId 的场景。
@@ -585,6 +586,8 @@ Example:
dws chat message send --open-dingtalk-id <openDingTalkId> --text "请查收"
dws chat message send --group <openconversation_id> "hello"
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
# 图文混排 Markdown:公网图片 URL 需要写成 ![图片标题](URL) 才会以内联图片展示
dws chat message send --group <openconversation_id> --text $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)'
# 幂等发送(24h 内相同 uuid 不重复投递)
dws chat message send --group <openconversation_id> --text "hello" --uuid "unique-id-123"
dws chat message send --group <openconversation_id> --at-all "<@all> 请大家注意"
@@ -624,6 +627,7 @@ Flags:
- **换行符**:消息内容按 Markdown 渲染,换行有两层要求,缺一不可:
1. 必须使用**真实换行符**(Unicode `U+000A`),而非字面量字符串 `\n`(反斜杠 + 字母 n)。程序或大模型构造参数时,须确保已正确反转义;否则全部内容会渲染在同一行
2. Markdown 规范下**单个换行不产生换行效果**。需要换行时请使用:段落分隔(连续两个真实换行符 `\n\n`)、行尾两个空格 + 真实换行符(硬换行 `<br>`),或直接写 HTML 的 `<br>` 标签
- **图文混排**:公网图片 URL 需要写成 `![图片标题](https://example.com/image.png)` 才会以内联图片展示;如果省略开头的 `!`,例如 `[图片标题](https://example.com/image.png)`,将按链接/URL 展示,不会渲染为图片
- 本地图片、文档、压缩包、音频和视频统一使用 `--msg-type file --file-path <本地路径>`;图片会成为可下载的 file 附件,不会内联渲染,也不会生成 mediaId
- `--msg-type image --media-id` 仅接受上游已经提供的有效 mediaId;DWS CLI 不提供本地文件到 mediaId 的上传或转换能力
- audio/video 仍是兼容的 file 语义别名,但本地文件的推荐路径保持为 `--msg-type file --file-path`
@@ -33,6 +33,7 @@
- 发送位置消息前必须确认纬度、经度、地址名称;地图缩略图需先通过旧媒体上传链路拿到 mediaId。
- 分享联系人名片前必须确认联系人 `openDingTalkId`,不要把 userId 直接当 `--contact-id`。
- 消息内容按 Markdown 渲染,换行必须是真实换行符;需要换行效果时用空行、行尾两个空格或 `<br>`。
- 图文混排 Markdown 中,公网图片 URL 需要写成 `![图片标题](https://example.com/image.png)` 才会以内联图片展示;省略开头的 `!` 时会按链接/URL 展示,不会渲染为图片。
- 建议发送时带 `--uuid`,失败重试复用同一个值。
- Bot/Webhook 只支持文本/Markdown;Bot 多群使用 `+messages-send --groups/--groups-file` 的逐项
ledger。不要把 user 文件/图片能力外推到 Bot。
@@ -54,6 +55,7 @@ dws chat message send --group <openConversationId> --text "hello"
dws chat message send --user <userId> --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --text "请查收"
dws chat message send --group <openConversationId> --title "周报提醒" --text "请大家本周五前提交周报" --uuid <uuid>
dws chat message send --group <openConversationId> --text $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)'
# @ 群成员
dws chat message send --group <openConversationId> --at-all "<@all> 请大家注意"
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-doc
description: 钉钉在线文字文档(adoc)的查找、读取、创建、编辑、块、评论、附件、导入导出、模板、版本和权限协作。普通文件走 dingtalk-drive,知识库空间走 dingtalk-wiki。命令前缀:dws doc。
description: 钉钉在线文字文档(adoc,「文档空间」里的在线文档)的查找、读取、创建、编辑、块、评论、附件、导入、导出(docx/markdown/pdf)、模板、版本、权限协作及 Markdown/JSONML 写入。文档空间与钉盘的文件管理走 dingtalk-drive(doc 同名原子命令已弃用),知识库空间与空间内节点走 dingtalk-wiki,原生 .md 文件读写走 dingtalk-misc。命令前缀:dws doc。
metadata:
cli_version: ">=0.2.14"
category: product
+33 -37
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-drive
description: 钉钉文件管理(存储层)。Use when 用户说 钉盘/上传文件/下载文件/文件夹/查文件/找文件/全局搜索文件/复制/移动/重命名/删除/回收站/还原删除文件/权限管理/普通文件下载。任何文件类型都适用;文档内容编辑走 dingtalk-doc,知识库空间和空间内节点管理走 dingtalk-wiki。命令前缀:dws drive。
description: 钉钉文件管理(存储层,覆盖钉盘与文档空间两个存储域)。Use when 用户说 钉盘/上传文件/下载文件/文件夹/查文件/找文件/全局搜索文件/复制/移动/重命名/删除/回收站/还原删除文件/权限管理/普通文件下载;也承接钉钉文档的这些管理动作(doc 侧同名原子命令已弃用)。文档正文编辑与导出 docx/markdown/pdf 走 dingtalk-doc,知识库空间与空间内节点组织走 dingtalk-wiki。命令前缀:dws drive。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -18,34 +18,30 @@ metadata:
> 命令参考:[drive.md](references/drive.md)。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
## Shortcut 发现(按需)
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "drive +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws drive <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service drive --format json` 批量发现。
`drive` 当前有 28 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图按下方路由。
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws drive +copy` | write | 复制文件/文档到指定位置 |
| `dws drive +find-file` | read | 按名称关键词搜索钉盘文件并投影关键字段(只读) |
| `dws drive +info` | read | 获取钉盘文件/文件夹元数据 |
| `dws drive +move` | write | 移动文件/文档到指定位置 |
| `dws drive +recent` | read | 获取最近访问/编辑的文档列表 |
| `dws drive +search` | read | 搜索钉盘文件 |
| `dws drive +search-docs` | read | 搜索文档空间文档 |
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service drive --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
| 用户说 | 命令 |
|--------|------|
| "看钉盘文件 / 文件夹列表" | `dws drive list [--folder <dentryUuid>]` |
| "看钉盘文件 / 文件夹列表" | `dws drive +list [--folder <dentryUuid>]` |
| "钉盘目录树" | `python scripts/drive_tree_list.py --depth 2` |
| "查文件元数据" | `dws drive info --node <dentryUuid>` |
| "搜文件 / 找文件" | `dws drive search --query "<关键词>"` |
| "下载文件" | `dws drive download --node <dentryUuid> --output <path>` |
| "上传文件" | `dws drive upload --file <path> [--folder <id>]` |
| "建钉盘文件夹" | `dws drive mkdir --name "<名称>" [--folder <id>]` |
| "复制/移动/重命名/删除/权限管理" | `dws drive copy/move/rename/delete/permission ...` |
| "回收站 / 还原删除的文件" | `dws drive recycle list` / `dws drive recycle restore --id <recycleItemId>` |
| "查文件元数据/统计/公开状态/封面" | `dws drive +inspect --node <dentryUuid> [--include-stats/--include-publish/--include-cover]` |
| "搜文件 / 找文件" | `dws drive +search --query "<关键词>"` |
| "下载文件" | `dws drive +download --node <dentryUuid> --output <工作目录内相对路径>` |
| "上传文件" | `dws drive +upload --file <工作目录内相对路径> [--folder <id>]` |
| "建钉盘文件夹 / 建快捷方式" | `dws drive +create-folder ...` / `dws drive +create-shortcut ...` |
| "复制/移动/重命名/删除" | `dws drive +copy/+move/+rename/+delete ...` |
| "回收站 / 还原删除的文件" | `dws drive +recycle-list` / `dws drive +recycle-restore --id <recycleItemId>` |
| "收藏 / 取消收藏 / 收藏列表" | `dws drive +star-add/+star-remove/+star-list ...` |
| "查/关互联网公开" | `dws drive +publish-get/+publish-unset ...`;开启公开当前无已验证 eligible 节点,不推荐 Agent 调用 |
| "普通文件历史版本" | `dws drive +version-history/+version-get/+version-download/+version-revert ...` |
| "在线文档评论/导入导出/权限" | 切到 `dws doc +comment-* / +import / +export / +access-*` |
## 标准 SOP(必遵流程)
@@ -55,29 +51,29 @@ metadata:
**触发**:找文件/搜文件/我的文件/最近文件/某文档在哪。
1. **选源(必须)**:最近访问 → `dws drive recent --limit <n> --format json`(翻页用上次返回的 `nextCursor` 传 `--cursor`);按内容/名称全局搜 → `dws drive search --query "<关键词>" --format json`;浏览某目录 → `dws drive list --folder <dentryUuid> --format json`。
1. **选源(必须)**:最近访问 → `dws drive +recent --limit <n> --format json`(翻页用上次返回的 `nextCursor` 传 `--cursor`);按内容/名称全局搜 → `dws drive +search --query "<关键词>" --format json`;浏览某目录 → `dws drive +list --folder <dentryUuid> --format json`。
2. **解析(必须)**:取真实 `dentryUuid`(= `id`/`nodeId`);多候选让用户确认,**禁止**默认取第一个。
3. **下钻(必须)**:根目录没命中时,进入最相关文件夹继续 `drive list --folder`,必要时 `python scripts/drive_tree_list.py --depth 2` 递归,**禁止**只看根目录就放弃。
4. **回读元数据(必须)**:命中后 `dws drive info --node <dentryUuid> --format json`,按 `extension` 确认类型。
3. **下钻(必须)**:根目录没命中时,进入最相关文件夹继续 `drive +list --folder`,必要时 `python scripts/drive_tree_list.py --depth 2` 递归,**禁止**只看根目录就放弃。
4. **回读元数据(必须)**:命中后 `dws drive +inspect --node <dentryUuid> --format json`,按 `extension` 确认类型。
**禁止**:编造 dentryUuid、只看根目录放弃、用 `drive list` 替代 `drive search` 做全局查找。
**禁止**:编造 dentryUuid、只看根目录放弃、用 `drive +list` 替代 `drive +search` 做全局查找。
### SOP-2 上传 / 下载(upload-download)
**触发**:上传文件/下载文件/传到钉盘/用本地文件覆盖已有文件。
1. **上传(必须)**:`dws drive upload --file <本地路径> [--folder <dentryUuid>] --format json`;返回取 `dentryUuid`,用 `drive info --node` 回读确认。
2. **覆盖(必须)**:先 `dws drive info --node <dentryUuid> --format json`,记录真实 `extension` 和原 `name`。`extension=md` 切 `dingtalk-misc` 的 `references/markdown.md`,先 `markdown overwrite --dry-run` 再确认执行;其他普通文件在用户确认后执行 `dws drive upload --node <dentryUuid> --file <本地路径> --file-name "<原name>" --format json`,随后再次 `drive info` 回读。`adoc` / `axls` / `able` 切对应内容 skill/reference,不按普通文件覆盖。
3. **下载(必须)**:先 `dws drive info --node <dentryUuid> --format json` 判断类型——`extension=adoc` 切 `dingtalk-doc` 用 `doc export`;普通文件执行 `dws drive download --node <dentryUuid> --output <本地路径> --format json`。
1. **上传(必须)**:先把文件暂存到工作目录,再执行 `dws drive +upload --file <相对路径> [--folder <dentryUuid>] --format json`;shortcut 内部已提交并回读远端元数据,返回取 `data.nodeId`。
2. **覆盖(必须)**:先 `dws drive +inspect --node <dentryUuid> --format json`,记录真实 `extension` 和原 `name`。`extension=md` 切 `dingtalk-misc` 的 `references/markdown.md`;其他普通文件在用户确认后执行 `dws drive +upload --node <dentryUuid> --file <相对路径> --file-name "<原name>" --format json`。`adoc` / `axls` / `able` 切对应内容 skill/reference,不按普通文件覆盖。
3. **下载(必须)**:先 `dws drive +inspect --node <dentryUuid> --format json` 判断类型——`extension=adoc` 切 `dingtalk-doc` 用 `doc +export`;普通文件执行 `dws drive +download --node <dentryUuid> --output <工作目录内相对路径> --format json`,并校验 `data.sizeBytes > 0` 和本地文件真实存在。
**禁止**:对在线文档用 `drive download`(会失败)、普通文件覆盖时省略 `--file-name` 导致隐式重命名、上传或覆盖后不回读。
**禁止**:对在线文档用 `drive +download`(会失败)、普通文件覆盖时省略 `--file-name` 导致隐式重命名、只看退出码而不检查统一结果。
### SOP-3 文件夹 / 复制 / 移动 / 重命名(folder-ops)
**触发**:建文件夹/复制/移动/重命名。
1. **执行(必须)**:建钉盘文件夹 `dws drive mkdir --name "<名称>" [--folder <id>]`;复制 `drive copy --node <dentryUuid> --folder <目标>`;移动 `drive move --node <dentryUuid> --folder <目标>`;重命名 `drive rename --node <dentryUuid> --name "<新名>"`。全部加 `--format json`。
2. **验证(必须)**:操作后 `drive info --node <新dentryUuid>` 或 `drive list --folder <目标>` 回读。
1. **执行(必须)**:建钉盘文件夹 `dws drive +create-folder --name "<名称>" [--folder <id>]`;复制 `drive +copy --node <dentryUuid> --folder <目标>`;移动 `drive +move --node <dentryUuid> --folder <目标>`;重命名 `drive +rename --node <dentryUuid> --name "<新名>"`。全部加 `--format json`。
2. **验证(必须)**:这些 shortcut 内部已经读回;调用方仍须检查 `ok=true`、`outcome=success` 和 `data` 中的 nodeId/对象,不能只看进程退出码。
**禁止**:未确认就移动/覆盖他人文件、跳过回读。
@@ -85,8 +81,8 @@ metadata:
**触发**:删文件/回收站/还原。
1. **删除(必须)**:`dws drive delete --node <dentryUuid> --format json`(**必须**先与用户确认)。
2. **还原(必须)**:`dws drive recycle list --format json` 取 `recycleItemId` → `dws drive recycle restore --id <recycleItemId> --format json`。
1. **删除(必须)**:`dws drive +delete --node <dentryUuid> --format json`(**必须**先与用户确认,再由执行层添加 `--yes`)。
2. **还原(必须)**:`dws drive +recycle-list --format json` 按 `originalName/originalPath` 确认目标并取 `recycleItemId` → 用户确认后执行 `dws drive +recycle-restore --id <recycleItemId> --format json`;成功时 Shortcut 会用服务返回的 nodeId 读回。
**禁止**:未确认就删除、把 `dentryUuid` 当 `recycleItemId` 传给 restore。
@@ -94,16 +90,16 @@ metadata:
**触发**:互联网公开/取消公开/查公开状态。
1. **执行(必须)**:查状态 `dws drive publish get --node <dentryUuid> --format json`;开启公开 `dws drive publish set --node <dentryUuid> --yes`(**[危险]** 必须用户确认);关闭公开 `dws drive publish unset --node <dentryUuid> --yes`。
1. **执行(必须)**:查状态 `dws drive +publish-get --node <dentryUuid> --format json`;用户确认后关闭公开 `dws drive +publish-unset --node <dentryUuid> --format json`。`+publish-set` 在普通文件和在线文档真实夹具上均返回 `operation.notSupported`,当前不进入 Agent 公开入口;只有未来先找到服务端明确支持的 eligible 节点并通过真实 set→get→unset 闭环后才能启用。
2. **边界(必须)**:对外公开前**必须**与用户确认边界与后果。
**禁止**:未确认就 `publish set`、跳过 `--yes`。
**禁止**:未确认就改变公开状态;把 `operation.notSupported` 当成功;在没有 eligible 节点真实闭环的情况下启用 `+publish-set`。
## 高频硬约束
- 查找文件不要只看根目录后放弃;根目录没命中时,进入最相关的目标文件夹继续 `drive list --folder <dentryUuid>`,必要时用目录树脚本递归到合理深度。
- `drive list` 默认 `--limit 20`,自动化场景里保守使用 `--limit 50` 以内并处理 `nextToken` 翻页;不要因为参数边界报错反复重试。
- 全局找文件优先 `drive search --query`;指定目录浏览用 `drive list`,命中后必须 `drive info --node <dentryUuid> --format json` 回读元数据。
- 查找文件不要只看根目录后放弃;根目录没命中时,进入最相关的目标文件夹继续 `drive +list --folder <dentryUuid>`,必要时用目录树脚本递归到合理深度。
- `drive +list` 默认 `--limit 20`,自动化场景里保守使用 `--limit 50` 以内并处理 `nextCursor` 翻页;不要因为参数边界报错反复重试。
- 全局找文件优先 `drive +search --query`;指定目录浏览用 `drive +list`,命中后必须 `drive +inspect --node <dentryUuid> --format json` 回读元数据。
- 删除、覆盖、移动等破坏性操作必须确认;上传、创建文件夹、下载后要读回或列目录验证。
- 所有 `dws drive` 命令加 `--format json`。
@@ -5,13 +5,13 @@
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|---|---|---|---|---|
| "参照这个生成同样的 / 按模板生成 / 复刻 X / 同样的模板 X 月份的" + 已有 alidocs URL | 模板保形生成同形态变体 | `drive copy + drive rename + doc block update` → 见 [best_practices/04-document.md `template-based-generation`](../../dingtalk-doc/references/04-document.md#template-based-generation) | `doc read + doc create`(重写链) | adoc → markdown 是有损投影,read+create 会丢行高/单元格背景色/字号;copy 在 adoc 层保形复制后只在副本上局部修改 |
| "读一下这个 xlsx 的数据" / xlsx 节点链接 | 下载本地表格文件 | `dws drive download --node` | `sheet range read` | xlsx / xls / xlsm / csv 是上传的本地文件(`contentType=DOCUMENT`),sheet 命令只支持在线表格,必须下载后本地解析 |
| "读一下这个 xlsx 的数据" / xlsx 节点链接 | 下载本地表格文件 | `dws drive +download --node --output <相对路径>` | `sheet range read` | xlsx / xls / xlsm / csv 是上传的本地文件(`contentType=DOCUMENT`),sheet 命令只支持在线表格;Shortcut 会验证真实本地字节 |
| "把这个在线表格导出为 xlsx 文件" | 在线表格格式转换 | `dws sheet export` | `dws drive download` | `export` 是 axls → xlsx 的导出转换;`download` 只能下载已有的 xlsx 节点 |
| "帮我把这个文件传到网盘" | 钉盘上传 | `drive upload` | — | 文件上传是存储层操作,归 drive |
| "上传文件到钉盘/我的文件" | 钉盘上传 | `drive upload` | — | 提到"钉盘/网盘/我的文件"→ drive |
| "上传文件"(未指定目标) | 默认钉盘 | `drive upload` | — | 未明确目标时默认上传到钉盘 |
| "帮我把这个文件传到网盘" | 钉盘上传 | `drive +upload` | — | 文件上传是存储层操作,归 drive;Shortcut 负责提交和读回 |
| "上传文件到钉盘/我的文件" | 钉盘上传 | `drive +upload` | — | 提到"钉盘/网盘/我的文件"→ drive |
| "上传文件"(未指定目标) | 默认钉盘 | `drive +upload` | — | 未明确目标时默认上传到钉盘 |
| "帮我看看知识库里的文件" | 知识库节点列表 | `wiki node list --workspace` | `drive list` | 明确"知识库"上下文 → wiki node list |
| "列出钉盘团队空间" | 列出钉盘空间 | `wiki space list --type orgSpace` | `drive list-spaces` | 空间管理归 wiki,drive list-spaces 已 deprecated |
| "在知识库里搜方案" | 空间内搜索 | `wiki node search --workspace` | `drive search` | 指定了空间上下文 → wiki node search |
| "搜一下有没有叫XX的文件" | 全局搜索 | `drive search` | `wiki node search` | 未指定空间 → drive search 全局聚合搜索 |
| "搜一下有没有叫XX的文件" | 全局搜索 | `drive +search` | `wiki node search` | 未指定空间 → Drive Shortcut 严格搜索 |
| "整理一下XX项目的所有讨论" | 跨源主题归档 | #5 generate-topic-report | #4 write-doc | #4 侧重单篇文档创作;按主题跨听记/群消息汇总属于工作汇报 |
@@ -6,13 +6,13 @@
### query-doc
1. 全局搜索:`drive search --query "<关键词>"` → `nodeId`(聚合钉盘+文档空间)
1. 钉盘全局搜索:`drive +search --query "<关键词>"` → `nodeId`;在线文档统一使用 `doc +search`
2. 空间内搜索:`wiki node search --workspace <WS_ID> --query "<关键词>"` → `nodeId`
3. `doc read --node <nodeId>`(按需;大文档只抽章节)
### list-folder-docs
`drive list --workspace <WS_ID>` 或 `wiki node list --workspace <WS_ID>`
`wiki node list --workspace <WS_ID>`;普通钉盘目录使用 `drive +list --folder <ID>`
### import-file
@@ -42,4 +42,3 @@ dws doc import --file ./report.docx --format json
> **禁止先 Read 文件再 `doc create` + `doc update`**。`doc import` 是服务端格式转换,客户端无需解析文件内容。
> 详见 [./doc/doc-import.md](../../dingtalk-doc/references/doc/doc-import.md)。
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-wiki
description: 钉钉知识库与空间管理。Use when 用户说 知识库/wiki/创建知识库/搜索知识库空间/我的文档/团队空间/空间成员/空间内节点创建/列出/搜索/复制/移动/删除/知识库动态。知识库节点复制移动走本 skill,普通钉盘文件复制移动走 dingtalk-drive;空间内单文档内容读写先用本 skill 定位再切到 dingtalk-doc。命令前缀:dws wiki。
description: 钉钉知识库与空间管理。Use when 用户说 知识库/wiki/创建知识库/搜索知识库空间/我的文档/团队空间/空间成员/在指定知识库内的节点创建/列出/搜索/复制/移动/删除/知识库动态。知识库空间与空间内节点管理走本 skill(节点操作需 workspace);未指定空间的全局文件管理与搜索走 dingtalk-drive,空间内单文档内容读写先用本 skill 定位再切到 dingtalk-doc。命令前缀:dws wiki。
metadata:
cli_version: ">=0.2.14"
category: product
+409 -14
View File
@@ -9,9 +9,10 @@ import (
)
type changelogGateRepo struct {
root string
gate string
base string
root string
gate string
fragmentPolicy string
base string
}
const changelogGateBase = `# Changelog
@@ -42,6 +43,26 @@ const changelogGateValidRelease = `# Changelog
- Initial release.
`
// A release-seal section whose 1.0.1-beta.1 notes are exactly the rendered form
// of changelogGateValidFragment. A seal PR built from this therefore satisfies
// the rendered-notes comparison, which isolates the archive-path assertions.
const changelogGateSealedRelease = `# Changelog
## [Unreleased]
## [1.0.1-beta.1] - 2026-07-17
### Added
- Chat reply mentions.
## [1.0.0] - 2026-07-01
### Added
- Initial release.
`
func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
t.Helper()
@@ -69,8 +90,10 @@ func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
}
for _, path := range []string{
"scripts/policy/check-changelog-pr.sh",
"scripts/policy/check-release-fragments.sh",
"scripts/policy/open-source-audit.sh",
"scripts/release/release-lib.sh",
"scripts/release/render-release-fragments.sh",
} {
data, err := os.ReadFile(filepath.Join(sourceRoot, path))
if err != nil {
@@ -91,12 +114,21 @@ func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
changelogGateGit(t, root, "commit", "-m", "seed repository")
return &changelogGateRepo{
root: root,
gate: filepath.Join(root, "scripts", "policy", "check-changelog-pr.sh"),
base: strings.TrimSpace(changelogGateGit(t, root, "rev-parse", "HEAD")),
root: root,
gate: filepath.Join(root, "scripts", "policy", "check-changelog-pr.sh"),
fragmentPolicy: filepath.Join(root, "scripts", "policy", "check-release-fragments.sh"),
base: strings.TrimSpace(changelogGateGit(t, root, "rev-parse", "HEAD")),
}
}
func (r *changelogGateRepo) runFragmentPolicy(t *testing.T, base, head string) (string, error) {
t.Helper()
cmd := exec.Command("sh", r.fragmentPolicy, base, head)
cmd.Dir = r.root
output, err := cmd.CombinedOutput()
return string(output), err
}
func changelogGateWrite(t *testing.T, root, path, content string, mode os.FileMode) {
t.Helper()
full := filepath.Join(root, path)
@@ -203,22 +235,386 @@ func TestChangelogPRGateAcceptsTargetedChanges(t *testing.T) {
}
}
func TestChangelogPRContentOnlyAllowsOtherFiles(t *testing.T) {
func TestChangelogPRContentOnlyRejectsOrdinaryChangesAlongsideChangelog(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
changelogGateWrite(t, repo.root, "internal/change.go", "package internal\n", 0o644)
repo.commit(t, "change code with release notes")
output, err := repo.runMode(t, "--content-only")
if err != nil {
t.Fatalf("content-only gate error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "CHANGELOG PR check: ok (mode=content-only") {
t.Fatalf("content-only gate output missing success marker:\n%s", output)
if err == nil || !strings.Contains(output, "may accompany only release-fragment archival") {
t.Fatalf("content-only gate accepted ordinary source change: err=%v\noutput:\n%s", err, output)
}
}
func TestChangelogPRContentOnlyStillValidatesContentWithOtherFiles(t *testing.T) {
func TestChangelogPRContentOnlyAcceptsReleaseFragmentArchival(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", "---\ncategory: Added\n---\n\n- Chat reply mentions.\n", 0o644)
repo.commit(t, "add release fragment")
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog
## [Unreleased]
## [1.0.1-beta.1] - 2026-07-17
### Added
- Chat reply mentions.
## [1.0.0] - 2026-07-01
### Added
- Initial release.
`, 0o644)
if err := os.MkdirAll(filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1"), 0o755); err != nil {
t.Fatalf("MkdirAll archive: %v", err)
}
if err := os.Rename(filepath.Join(repo.root, ".changes", "1234-chat.md"), filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1", "1234-chat.md")); err != nil {
t.Fatalf("Rename release fragment: %v", err)
}
repo.commit(t, "seal release notes")
output, err := repo.runMode(t, "--content-only")
if err != nil {
t.Fatalf("content-only release seal error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "CHANGELOG PR check: ok (mode=content-only") {
t.Fatalf("content-only release seal output missing success marker:\n%s", output)
}
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
t.Fatalf("release fragment policy rejected matching seal: %v\noutput:\n%s", err, output)
}
}
func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) {
t.Run("invalid active fragment", func(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/1234-invalid.md", "---\ncategory: Added\n---\n\n- TODO: fill this in.\n", 0o644)
repo.commit(t, "add invalid fragment")
output, err := repo.runFragmentPolicy(t, repo.base, "HEAD")
if err == nil || !strings.Contains(output, "must not contain TODO/TBD") {
t.Fatalf("invalid active fragment passed: err=%v\noutput:\n%s", err, output)
}
})
t.Run("archive version differs from changelog", func(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", "---\ncategory: Added\n---\n\n- Chat reply mentions.\n", 0o644)
repo.commit(t, "add release fragment")
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
if err := os.MkdirAll(filepath.Join(repo.root, ".changes", "released", "1.0.2-beta.1"), 0o755); err != nil {
t.Fatalf("MkdirAll archive: %v", err)
}
if err := os.Rename(filepath.Join(repo.root, ".changes", "1234-chat.md"), filepath.Join(repo.root, ".changes", "released", "1.0.2-beta.1", "1234-chat.md")); err != nil {
t.Fatalf("Rename release fragment: %v", err)
}
repo.commit(t, "archive under wrong release")
output, err := repo.runFragmentPolicy(t, sealBase, "HEAD")
if err == nil || !strings.Contains(output, "unchanged R100 moves") {
t.Fatalf("wrong archive version passed: err=%v\noutput:\n%s", err, output)
}
})
t.Run("archive notes differ from rendered fragment", func(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", "---\ncategory: Added\n---\n\n- Chat reply mentions.\n", 0o644)
repo.commit(t, "add release fragment")
sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
if err := os.MkdirAll(filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1"), 0o755); err != nil {
t.Fatalf("MkdirAll archive: %v", err)
}
if err := os.Rename(filepath.Join(repo.root, ".changes", "1234-chat.md"), filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1", "1234-chat.md")); err != nil {
t.Fatalf("Rename release fragment: %v", err)
}
repo.commit(t, "seal mismatched release notes")
output, err := repo.runFragmentPolicy(t, sealBase, "HEAD")
if err == nil || !strings.Contains(output, "does not exactly match") {
t.Fatalf("mismatched release notes passed: err=%v\noutput:\n%s", err, output)
}
})
}
const changelogGateValidFragment = "---\ncategory: Added\n---\n\n- Chat reply mentions.\n"
// sealFragmentInto stages a release-seal PR that archives the single active
// fragment into .changes/released/<archiveDir>/, with a CHANGELOG section whose
// rendered notes already match. Only archiveDir varies, so a rejection can only
// come from the archive-path contract.
func (r *changelogGateRepo) sealFragmentInto(t *testing.T, archiveDir string) string {
t.Helper()
changelogGateWrite(t, r.root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
r.commit(t, "add release fragment")
sealBase := strings.TrimSpace(changelogGateGit(t, r.root, "rev-parse", "HEAD"))
changelogGateWrite(t, r.root, "CHANGELOG.md", changelogGateSealedRelease, 0o644)
target := filepath.Join(r.root, ".changes", "released", archiveDir)
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", target, err)
}
if err := os.Rename(filepath.Join(r.root, ".changes", "1234-chat.md"), filepath.Join(target, "1234-chat.md")); err != nil {
t.Fatalf("Rename release fragment error = %v", err)
}
r.commit(t, "seal release into "+archiveDir)
return sealBase
}
// The archive directory used to be matched by interpolating the version into an
// awk regex, where `.` matches any character. Version `1.0.1-beta.1` therefore
// also admitted `1x0x1-betaX1`, letting the archive drift from the CHANGELOG
// version while every other seal assertion still passed.
func TestReleaseFragmentPolicyRejectsArchiveDirectoryMatchedAsRegex(t *testing.T) {
for _, archiveDir := range []string{"1x0x1-betaX1", "1.0.1-betaX1", "1x0.1-beta.1"} {
t.Run(archiveDir, func(t *testing.T) {
repo := newChangelogGateRepo(t)
sealBase := repo.sealFragmentInto(t, archiveDir)
output, err := repo.runFragmentPolicy(t, sealBase, "HEAD")
if err == nil {
t.Fatalf("archive directory %q passed:\n%s", archiveDir, output)
}
if !strings.Contains(output, "unchanged R100 moves") {
t.Fatalf("gate output missing the archive-move contract:\n%s", output)
}
})
}
}
// Guards the fix against over-blocking: the archive directory that exactly
// equals the CHANGELOG version must still seal cleanly.
func TestReleaseFragmentPolicyAcceptsArchiveDirectoryMatchingChangelogVersion(t *testing.T) {
repo := newChangelogGateRepo(t)
sealBase := repo.sealFragmentInto(t, "1.0.1-beta.1")
if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil {
t.Fatalf("matching archive directory rejected: %v\noutput:\n%s", err, output)
}
}
// A top-level `.changes/` entry that violates the naming rule or is not a
// regular 100644 blob must fail the gate. Selecting the trigger by the legal
// name pattern used to let these entries through untouched, which both bypassed
// validation here and broke the next PR that added a legal fragment.
func TestReleaseFragmentPolicyRejectsIllegalFragmentNamesAndModes(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, root string)
wantPath string
}{
{
name: "uppercase fragment name",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/Chat.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/Chat.md",
},
{
name: "fragment name with a space",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/chat reply.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/chat reply.md",
},
{
name: "fragment name starting with a dash",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/-chat.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/-chat.md",
},
{
name: "non-markdown entry",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/notes.txt", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/notes.txt",
},
{
name: "symlinked fragment",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
if err := os.Symlink("1234-chat.md", filepath.Join(root, ".changes", "5678-alias.md")); err != nil {
t.Fatalf("Symlink release fragment: %v", err)
}
},
wantPath: ".changes/5678-alias.md",
},
{
name: "executable fragment",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/1234-chat.md", changelogGateValidFragment, 0o755)
},
wantPath: ".changes/1234-chat.md",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := newChangelogGateRepo(t)
test.seed(t, repo.root)
repo.commit(t, test.name)
output, err := repo.runFragmentPolicy(t, repo.base, "HEAD")
if err == nil {
t.Fatalf("illegal release fragment entry passed:\n%s", output)
}
if !strings.Contains(output, ".changes/ accepts only") {
t.Fatalf("gate output missing the entry contract:\n%s", output)
}
if !strings.Contains(output, test.wantPath) {
t.Fatalf("gate output missing offending entry %q:\n%s", test.wantPath, output)
}
})
}
}
func TestReleaseFragmentPolicyAcceptsLegalFragmentAlongsideReadmeAndArchive(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
changelogGateWrite(t, repo.root, ".changes/released/1.0.0/0001-seed.md", changelogGateValidFragment, 0o644)
repo.commit(t, "seed fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
repo.commit(t, "add release fragment")
if output, err := repo.runFragmentPolicy(t, base, "HEAD"); err != nil {
t.Fatalf("legal release fragment rejected: %v\noutput:\n%s", err, output)
}
}
// Git records no diff entry for a directory itself, so adding
// `.changes/foo/bar.md` only ever surfaces the nested path. Triggering the
// top-level validation on single-level paths let such a directory reach main
// untouched and then broke every later fragment render with
// `unexpected directory`.
func TestReleaseFragmentPolicyRejectsNestedChangesEntries(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, root string)
wantPath string
}{
{
name: "nested directory only",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/foo/bar.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/foo",
},
{
name: "nested directory alongside a legal fragment",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/1234-chat.md", changelogGateValidFragment, 0o644)
changelogGateWrite(t, root, ".changes/foo/bar.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/foo",
},
{
name: "nested directory shadowing the archive name",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/released.d/bar.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/released.d",
},
{
name: "deeply nested directory",
seed: func(t *testing.T, root string) {
changelogGateWrite(t, root, ".changes/a/b/c/note.md", changelogGateValidFragment, 0o644)
},
wantPath: ".changes/a",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repo := newChangelogGateRepo(t)
test.seed(t, repo.root)
repo.commit(t, test.name)
output, err := repo.runFragmentPolicy(t, repo.base, "HEAD")
if err == nil {
t.Fatalf("nested .changes entry passed:\n%s", output)
}
if !strings.Contains(output, ".changes/ accepts only") {
t.Fatalf("gate output missing the entry contract:\n%s", output)
}
if !strings.Contains(output, test.wantPath) {
t.Fatalf("gate output missing offending entry %q:\n%s", test.wantPath, output)
}
})
}
}
// Replacing `.changes` itself empties the child listing, so scanning entries
// alone would report nothing invalid while the whole fragment directory
// disappears. Seeded without an active fragment so the archival-move guard
// cannot mask the directory contract being asserted here.
func TestReleaseFragmentPolicyRejectsReplacedChangesDirectory(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
repo.commit(t, "seed fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
if err := os.RemoveAll(filepath.Join(repo.root, ".changes")); err != nil {
t.Fatalf("RemoveAll(.changes) error = %v", err)
}
if err := os.Symlink("docs", filepath.Join(repo.root, ".changes")); err != nil {
t.Fatalf("Symlink(.changes) error = %v", err)
}
repo.commit(t, "replace .changes with a symlink")
output, err := repo.runFragmentPolicy(t, base, "HEAD")
if err == nil {
t.Fatalf("replaced .changes directory passed:\n%s", output)
}
if !strings.Contains(output, ".changes must remain a directory") {
t.Fatalf("gate output missing the directory contract:\n%s", output)
}
}
// `.changes/README.md` is the contributor contract, not release content, so a
// documentation-only edit must pass even though no fragment is pending — the
// renderer rejects an empty fragment set.
func TestReleaseFragmentPolicyAcceptsReadmeOnlyChangeWithoutPendingFragments(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
repo.commit(t, "seed fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n\nName each fragment `<name>.md`.\n", 0o644)
repo.commit(t, "document the fragment naming rule")
if output, err := repo.runFragmentPolicy(t, base, "HEAD"); err != nil {
t.Fatalf("README-only change rejected: %v\noutput:\n%s", err, output)
}
}
// A README-only edit still revalidates the tree, so pollution that somehow
// reached the branch is reported by the PR that touches `.changes/` rather than
// deferred to whichever unrelated PR next adds a fragment.
func TestReleaseFragmentPolicyReadmeOnlyChangeStillValidatesTree(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n", 0o644)
changelogGateWrite(t, repo.root, ".changes/Foo.md", changelogGateValidFragment, 0o644)
repo.commit(t, "seed polluted fragment directory")
base := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
changelogGateWrite(t, repo.root, ".changes/README.md", "# Release fragments\n\nUpdated contract.\n", 0o644)
repo.commit(t, "edit only the fragment readme")
output, err := repo.runFragmentPolicy(t, base, "HEAD")
if err == nil {
t.Fatalf("README-only change skipped tree validation:\n%s", output)
}
if !strings.Contains(output, ".changes/Foo.md") {
t.Fatalf("gate output missing the pre-existing illegal entry:\n%s", output)
}
}
func TestChangelogPRContentOnlyStillValidatesReleaseNotes(t *testing.T) {
tests := []struct {
name string
changelog string
@@ -250,7 +646,6 @@ func TestChangelogPRContentOnlyStillValidatesContentWithOtherFiles(t *testing.T)
t.Run(test.name, func(t *testing.T) {
repo := newChangelogGateRepo(t)
changelogGateWrite(t, repo.root, "CHANGELOG.md", test.changelog, 0o644)
changelogGateWrite(t, repo.root, "internal/change.go", "package internal\n", 0o644)
repo.commit(t, test.name)
output, err := repo.runMode(t, "--content-only")
+59
View File
@@ -193,6 +193,65 @@ scenario("multi install lays out sibling skills and caches", () => {
}
});
scenario("Codex uses its canonical root without a generic duplicate", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
"multi/dws-shared/SKILL.md": "# dws-shared\n",
});
try {
writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n");
writeFile(
path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"),
"old nested duplicate\n",
);
const res = runInstall(pkg, home, "multi");
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.ok(
fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")),
"Codex canonical Skill installed",
);
assert.ok(
!fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")),
"generic flat duplicate not installed",
);
assert.ok(
!fs.existsSync(path.join(home, ".agents", "skills", "dws")),
"legacy nested duplicate retired",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("ZCode uses its canonical root without a generic duplicate", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n",
});
try {
writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n");
writeFile(
path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"),
"old nested duplicate\n",
);
const res = runInstall(pkg, home, "multi");
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.ok(
fs.existsSync(path.join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")),
"ZCode canonical Skill installed",
);
assert.ok(
!fs.existsSync(path.join(home, ".agents", "skills", "dws")),
"legacy generic duplicate retired",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("empty multi/ tree falls back to mono and keeps the old multi cache", () => {
const { tmp, pkg, home } = stagePkg({
"SKILL.md": "# mono root copy\n",
+200 -10
View File
@@ -15,11 +15,6 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
)
var expectedHomeSkillTargets = []string{
".agents/skills/dws",
".cursor/skills/dws",
}
func assertSkillProvenance(t *testing.T, home, skillDir, name, source string) {
t.Helper()
state, readable, err := skillstate.Read(home)
@@ -187,11 +182,13 @@ func TestInstallScriptSourceModeInstallsSkillsIntoAgentsDir(t *testing.T) {
t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output))
}
for _, rel := range expectedHomeSkillTargets {
skillPath := filepath.Join(fixture.fakeHome, filepath.FromSlash(rel), "SKILL.md")
if _, err := os.Stat(skillPath); err != nil {
t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output))
}
skillPath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws", "SKILL.md")
if _, err := os.Stat(skillPath); err != nil {
t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output))
}
genericPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws")
if _, err := os.Stat(genericPath); !os.IsNotExist(err) {
t.Fatalf("generic Skill root must not duplicate detected Cursor: Stat(%s) = %v\noutput:\n%s", genericPath, err, string(output))
}
}
@@ -1245,6 +1242,199 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT"
assertSkillProvenance(t, home, filepath.Join(base, "dingtalk-test"), "dingtalk-test", "install-skills.sh")
}
func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) {
for _, scriptName := range []string{"install.sh", "install-skills.sh"} {
t.Run(scriptName, func(t *testing.T) {
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName))
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
cut := strings.LastIndex(string(data), "\nmain\n")
if cut < 0 {
t.Fatalf("%s final main invocation not found", scriptName)
}
library := filepath.Join(t.TempDir(), scriptName+"-lib.sh")
mustWriteFile(t, library, data[:cut], 0o755)
home := t.TempDir()
source := filepath.Join(t.TempDir(), "multi")
mustWriteFile(t, filepath.Join(home, ".codex", "config.toml"), []byte("model=test\n"), 0o644)
mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), []byte("old nested\n"), 0o644)
mustWriteFile(t, filepath.Join(source, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644)
installCall := `install_multi_skills_to_homes "$DWS_TEST_SOURCE"`
if scriptName == "install-skills.sh" {
installCall = `install_multi_skills_to_root "$DWS_TEST_SOURCE" "$HOME"`
}
cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"
`+installCall+`
`)
cmd.Env = append(os.Environ(), "HOME="+home, "DWS_TEST_LIBRARY="+library, "DWS_TEST_SOURCE="+source)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s Codex-root harness failed: %v\n%s", scriptName, err, output)
}
if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("%s canonical Codex Skill missing: %v", scriptName, err)
}
for _, duplicate := range []string{
filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md"),
filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"),
} {
if _, err := os.Stat(duplicate); !os.IsNotExist(err) {
t.Fatalf("%s generic duplicate remains at %s: %v", scriptName, duplicate, err)
}
}
})
}
}
func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) {
for _, scriptName := range []string{"install.sh", "install-skills.sh"} {
t.Run(scriptName, func(t *testing.T) {
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName))
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
cut := strings.LastIndex(string(data), "\nmain\n")
if cut < 0 {
t.Fatalf("%s final main invocation not found", scriptName)
}
library := filepath.Join(t.TempDir(), scriptName+"-lib.sh")
mustWriteFile(t, library, data[:cut], 0o755)
home := t.TempDir()
source := filepath.Join(t.TempDir(), "multi")
mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644)
mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), []byte("old nested\n"), 0o644)
mustWriteFile(t, filepath.Join(source, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644)
installCall := `install_multi_skills_to_homes "$DWS_TEST_SOURCE"`
if scriptName == "install-skills.sh" {
installCall = `install_multi_skills_to_root "$DWS_TEST_SOURCE" "$HOME"`
}
cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"
`+installCall+`
`)
cmd.Env = append(os.Environ(), "HOME="+home, "DWS_TEST_LIBRARY="+library, "DWS_TEST_SOURCE="+source)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s ZCode-root harness failed: %v\n%s", scriptName, err, output)
}
if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("%s canonical ZCode Skill missing: %v", scriptName, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) {
t.Fatalf("%s generic duplicate remains: %v", scriptName, err)
}
})
}
}
func TestInstallPowerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) {
pwsh, err := exec.LookPath("pwsh")
if err != nil {
if runtime.GOOS == "windows" {
pwsh, err = exec.LookPath("powershell")
}
if err != nil {
t.Skip("PowerShell is not available")
}
}
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1"))
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
cut := strings.LastIndex(string(data), "# ── Main")
if cut < 0 {
t.Fatal("install.ps1 main section not found")
}
prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME")
prefix += `
if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 }
exit 0
`
harnessPath := filepath.Join(t.TempDir(), "install-codex-root.ps1")
mustWriteFile(t, harnessPath, []byte(prefix), 0o644)
home := t.TempDir()
multi := filepath.Join(t.TempDir(), "multi")
mustWriteFile(t, filepath.Join(home, ".codex", "config.toml"), []byte("model=test\n"), 0o644)
mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), []byte("old nested\n"), 0o644)
mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644)
cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath)
cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("PowerShell Codex-root harness failed: %v\n%s", err, output)
}
if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("PowerShell canonical Codex Skill missing: %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) {
t.Fatalf("PowerShell generic duplicate remains: %v", err)
}
}
func TestInstallPowerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) {
pwsh, err := exec.LookPath("pwsh")
if err != nil {
if runtime.GOOS == "windows" {
pwsh, err = exec.LookPath("powershell")
}
if err != nil {
t.Skip("PowerShell is not available")
}
}
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1"))
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatal(err)
}
cut := strings.LastIndex(string(data), "# ── Main")
if cut < 0 {
t.Fatal("install.ps1 main section not found")
}
prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME")
prefix += `
if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 }
exit 0
`
harnessPath := filepath.Join(t.TempDir(), "install-zcode-root.ps1")
mustWriteFile(t, harnessPath, []byte(prefix), 0o644)
home := t.TempDir()
multi := filepath.Join(t.TempDir(), "multi")
mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644)
mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), []byte("old nested\n"), 0o644)
mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644)
cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath)
cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("PowerShell ZCode-root harness failed: %v\n%s", err, output)
}
if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("PowerShell canonical ZCode Skill missing: %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) {
t.Fatalf("PowerShell generic duplicate remains: %v", err)
}
}
func TestInstallPowerShellBackupFailureWritesNoMultiSkills(t *testing.T) {
pwsh, err := exec.LookPath("pwsh")
if err != nil {
+1
View File
@@ -24,6 +24,7 @@ var expectedPackagedSkillTargets = []string{
".qoderwork/skills/dingtalk-shared",
".gemini/skills/dingtalk-shared",
".codex/skills/dingtalk-shared",
".zcode/skills/dingtalk-shared",
".github/skills/dingtalk-shared",
".windsurf/skills/dingtalk-shared",
".augment/skills/dingtalk-shared",
+102 -37
View File
@@ -73,6 +73,7 @@ type releaseTestRepo struct {
remote string
contract string
prepare string
render string
releaseCmd string
lib string
verify string
@@ -107,6 +108,7 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
remote: remote,
contract: filepath.Join(sourceRoot, "scripts", "release", "release-contract.sh"),
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
render: filepath.Join(sourceRoot, "scripts", "release", "render-release-fragments.sh"),
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
@@ -1658,10 +1660,13 @@ func TestReleaseContractCIAllowsMainToAdvanceAfterTagSeal(t *testing.T) {
}
}
func TestReleasePrepareChangelogCreatesGuardedTemplate(t *testing.T) {
func TestReleasePrepareChangelogRendersAndArchivesPrereleaseFragments(t *testing.T) {
r := newReleaseTestRepo(t)
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
mustWriteFile(t, filepath.Join(r.root, ".changes", "1234-chat-reply.md"), []byte("---\ncategory: Added\n---\n\n- **Chat reply mentions** (#1234) — supports selected member mentions.\n"), 0o644)
mustWriteFile(t, filepath.Join(r.root, ".changes", "1235-chat-fix.md"), []byte("---\ncategory: Fixed\n---\n\n- **Chat reply fallback** (#1235) — keeps replies compatible with older clients.\n"), 0o644)
r.commitAndPush(t, "install changelog preparation")
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
@@ -1675,55 +1680,115 @@ func TestReleasePrepareChangelogCreatesGuardedTemplate(t *testing.T) {
if err != nil {
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
}
if !strings.Contains(string(changelog), "## [1.0.1-beta.1] - 2026-07-11") || !strings.Contains(string(changelog), "TODO") {
t.Fatalf("prepared changelog missing guarded template:\n%s", changelog)
content := string(changelog)
if !strings.Contains(content, "## [1.0.1-beta.1] - 2026-07-11") ||
!strings.Contains(content, "### Added") ||
!strings.Contains(content, "Chat reply mentions") ||
!strings.Contains(content, "### Fixed") ||
!strings.Contains(content, "Chat reply fallback") ||
strings.Contains(content, "TODO") {
t.Fatalf("prepared changelog did not render release fragments:\n%s", changelog)
}
r.commitAndPush(t, "commit unfinished release notes")
contractOutput, contractErr := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.1",
"--remote", "origin",
)
if contractErr == nil || !strings.Contains(contractOutput, "TODO/TBD") {
t.Fatalf("unfinished template was not blocked: err=%v\noutput:\n%s", contractErr, contractOutput)
for _, name := range []string{"1234-chat-reply.md", "1235-chat-fix.md"} {
if _, err := os.Stat(filepath.Join(r.root, ".changes", name)); !os.IsNotExist(err) {
t.Fatalf("unconsumed release fragment %s still present: %v", name, err)
}
if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1-beta.1", name)); err != nil {
t.Fatalf("archived release fragment %s missing: %v", name, err)
}
}
}
func TestReleasePrepareChangelogKeepsUnreleasedContentAboveNewVersion(t *testing.T) {
func TestReleasePrepareChangelogRejectsInvalidReleaseFragment(t *testing.T) {
r := newReleaseTestRepo(t)
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte("# Changelog\n\n## [Unreleased]\n\n### Changed\n\n- Keep this unreleased note.\n\n## [1.0.0] - 2026-07-01\n\n### Changed\n\n- Initial release.\n"), 0o644)
r.commitAndPush(t, "add unreleased changelog note")
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
mustWriteFile(t, filepath.Join(r.root, ".changes", "invalid.md"), []byte("---\ncategory: Added\n---\n\n- TODO: write this later.\n"), 0o644)
r.commitAndPush(t, "add invalid release fragment")
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
cmd.Dir = r.root
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("prepare changelog error = %v\noutput:\n%s", err, output)
if output, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(output), "must not contain TODO/TBD") {
t.Fatalf("invalid release fragment was accepted: err=%v\noutput:\n%s", err, output)
}
data, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md"))
if err != nil {
t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err)
}
const releaseValidFragment = "---\ncategory: Added\n---\n\n- **Chat reply mentions** (#1234) — supports selected member mentions.\n"
// The renderer used to skip anything `find -type f -name '*.md'` did not match,
// so a symlinked or illegally named fragment was dropped from the notes without
// a word. Every unexpected top-level entry must fail the release instead.
func TestReleasePrepareChangelogRejectsUnsafeReleaseFragmentEntries(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, changesDir string)
wantMsg string
}{
{
name: "symlinked fragment",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "1234-chat.md"), []byte(releaseValidFragment), 0o644)
if err := os.Symlink("1234-chat.md", filepath.Join(changesDir, "5678-alias.md")); err != nil {
t.Fatalf("Symlink release fragment: %v", err)
}
},
wantMsg: "not a symbolic link",
},
{
name: "fragment name with a space",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "chat reply.md"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "invalid release fragment filename",
},
{
name: "uppercase fragment name",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "Chat.md"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "invalid release fragment filename",
},
{
name: "non-markdown entry",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "notes.txt"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "invalid release fragment filename",
},
{
// The PR gate is what keeps a nested directory out of `.changes/`;
// this covers the release-side backstop that reports it instead of
// rendering notes from an unexpected tree shape.
name: "nested fragment directory",
seed: func(t *testing.T, changesDir string) {
mustWriteFile(t, filepath.Join(changesDir, "foo", "bar.md"), []byte(releaseValidFragment), 0o644)
},
wantMsg: "unexpected directory in release fragments directory",
},
}
content := string(data)
positions := []int{
strings.Index(content, "## [Unreleased]"),
strings.Index(content, "- Keep this unreleased note."),
strings.Index(content, "## [1.0.1-beta.1] - 2026-07-11"),
strings.Index(content, "## [1.0.0] - 2026-07-01"),
}
for index, position := range positions {
if position < 0 {
t.Fatalf("prepared changelog is missing expected marker %d:\n%s", index, content)
}
}
for index := 1; index < len(positions); index++ {
if positions[index-1] >= positions[index] {
t.Fatalf("prepared changelog order is invalid: %v\n%s", positions, content)
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := newReleaseTestRepo(t)
releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644)
releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755)
releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755)
test.seed(t, filepath.Join(r.root, ".changes"))
r.commitAndPush(t, "add unsafe release fragment entry")
cmd := exec.Command("sh", filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), "prerelease", "v1.0.1-beta.1")
cmd.Dir = r.root
cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11")
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("unsafe release fragment entry was accepted:\n%s", output)
}
if !strings.Contains(string(output), test.wantMsg) {
t.Fatalf("renderer output missing %q:\n%s", test.wantMsg, output)
}
})
}
}