Compare commits

...
Author SHA1 Message Date
chichuan d1bd518043 Merge pull request #957 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.4
docs: seal v1.0.58-beta.4 changelog
2026-08-12 10:32:34 +08:00
chichuan bac4fded0d docs: seal v1.0.58-beta.4 changelog 2026-08-12 10:25:48 +08:00
github-actions[bot] 82bfddc1c2 Merge pull request #922 from typefield/feat/skill-mode-migration
feat(skill): default installs and upgrades to multi-skill layout
2026-08-12 09:04:30 +08:00
玉澜 8cf23ee7cb Merge remote-tracking branch 'upstream/main' into feat/skill-mode-migration 2026-08-12 08:47:01 +08:00
玉澜 5777ea36e9 fix(skill): roll back partial mono installs 2026-08-12 08:44:48 +08:00
github-actions[bot] 6eceebd701 Merge pull request #953 from Anonymity-0/feat/card-send-native-mentions
feat(chat): support mentions in native card creation
2026-08-12 02:41:26 +08:00
玉澜 4b898e9011 test(auth): remove PAT polling timing race 2026-08-12 02:41:14 +08:00
玉澜 cb14ae96b3 Merge remote-tracking branch 'upstream/main' into feat/skill-mode-migration 2026-08-12 02:07:42 +08:00
前津 aeb4b2dcaa fix(chat): reject conflicting card update responses 2026-08-12 02:01:13 +08:00
玉澜 09f9289deb fix(skill): match managed names literally 2026-08-12 01:56:17 +08:00
前津 bbb14c24dc Merge remote-tracking branch 'upstream/main' into feat/card-send-native-mentions 2026-08-12 01:28:00 +08:00
github-actions[bot] 79f4be31d5 Merge pull request #956 from DingTalk-Real-AI/codex/fix-text-input-bounds
fix(localio): bound all text input paths
2026-08-11 17:16:44 +00:00
玉澜 e2a1be5e93 fix(skill): roll back partial setup transactions 2026-08-12 01:09:19 +08:00
Dennis 69911543c3 Merge remote-tracking branch 'origin/main' into codex/fix-text-input-bounds 2026-08-12 00:58:25 +08:00
john d4eba7fa96 Merge branch 'main' into feat/skill-mode-migration 2026-08-12 00:54:52 +08:00
前津 bbc2eb111c Merge remote-tracking branch 'upstream/main' into feat/card-send-native-mentions 2026-08-12 00:54:37 +08:00
github-actions[bot] b58b8c51bf Merge pull request #955 from DingTalk-Real-AI/codex/fix-eval-dispatch-403
fix(ci): restore eval dispatch PR comments
2026-08-12 00:52:54 +08:00
Dennis a7678472ab test(localio): scope path replacement to unix 2026-08-12 00:40:36 +08:00
Dennis f413db06be fix(localio): reject special files before open 2026-08-12 00:32:56 +08:00
Dennis 3d67d83110 test(localio): isolate input boundary e2e 2026-08-12 00:28:43 +08:00
玉澜 9de722ab34 fix(skill): roll back failed installer transactions 2026-08-12 00:22:09 +08:00
Dennis df088573fb fix(localio): bound all text input paths 2026-08-11 23:58:11 +08:00
chichuan a0c64e5ef4 fix(ci): restore eval dispatch PR comments 2026-08-11 23:57:12 +08:00
前津 eebd6b2a1c fix(chat): accept card update acknowledgement 2026-08-11 23:44:53 +08:00
玉澜 181f030350 test(skill): normalize backup paths on Windows 2026-08-11 23:40:28 +08:00
john 6140e503ec Merge branch 'main' into feat/skill-mode-migration 2026-08-11 23:31:08 +08:00
玉澜 9539ae8e40 fix(skill): centralize managed skill metadata 2026-08-11 23:26:49 +08:00
github-actions[bot] 7a140e59c3 Merge pull request #946 from DingTalk-Real-AI/codex/minutes-shortcuts
feat(minutes): align and expand shortcut workflows
2026-08-11 23:20:21 +08:00
前津 b1bfe6002d Revert "docs(skill): route create-only cards to native command"
This reverts commit 8e8e3a3ce8.
2026-08-11 22:54:02 +08:00
Dennis 38832448d2 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 22:53:51 +08:00
前津 8e8e3a3ce8 docs(skill): route create-only cards to native command 2026-08-11 22:52:24 +08:00
Dennis 3d4e43f4fc fix(minutes): align search scope enums 2026-08-11 22:49:27 +08:00
github-actions[bot] 155ce984c9 Merge pull request #916 from gtezg30062/feat/pull_knowledge_base_dynamic_1
Feat/pull knowledge base dynamic 1
2026-08-11 14:21:30 +00:00
john 4b93a1cb28 Merge branch 'main' into feat/pull_knowledge_base_dynamic_1 2026-08-11 22:05:50 +08:00
github-actions[bot] 1d384b9189 Merge pull request #952 from DingTalk-Real-AI/feat/eval-devix-poll
feat(eval): 用可验证轮询中继替代受限网络直连
2026-08-11 21:51:30 +08:00
玉澜 9f4e748404 fix(skill): preserve installs during layout migration 2026-08-11 21:37:55 +08:00
玉澜 0d4bd28a08 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 20:46:15 +08:00
玉澜 9264323b29 revert(ci): keep existing pull request checkout policy 2026-08-11 20:45:32 +08:00
玉澜 1744880648 test(skill): cover managed marker failure 2026-08-11 20:34:00 +08:00
玉澜 246f4ebaf5 docs(skill): consolidate migration design into RFC 2026-08-11 20:24:07 +08:00
Dennis a3f5a83527 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 20:22:35 +08:00
玉澜 ec5f312fd6 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 20:19:30 +08:00
玉澜 3c81741e2e fix(skill): fail partial setup installs 2026-08-11 20:19:00 +08:00
Dennis 931af6af59 chore(pr): keep evidence out of merge tree 2026-08-11 19:51:57 +08:00
Dennis 44311d0160 docs(pr): publish minutes agent e2e evidence 2026-08-11 19:51:08 +08:00
玉澜 293c085634 fix(skill): preserve same-prefix user skills 2026-08-11 19:47:30 +08:00
Dennis f81d09fb95 fix(localio): pin verified upload file across retries 2026-08-11 19:45:06 +08:00
前津 f8258576ef feat(chat): support mentions in native card creation 2026-08-11 19:42:06 +08:00
Dennis cd1ba34d96 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 19:30:15 +08:00
Dennis 2cc410db6c docs: remove shortcut analysis artifacts 2026-08-11 19:09:30 +08:00
玉澜 f57c002ae7 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 19:06:57 +08:00
Dennis 06ec207d17 fix(minutes): harden end-to-end failure handling 2026-08-11 18:54:24 +08:00
玉澜 b17634ef7d fix(skill): fail incomplete bundled skill installs 2026-08-11 18:49:05 +08:00
玉澜 e0fd344a26 fix(skill): always refresh bundled skills 2026-08-11 18:13:44 +08:00
john c38e988b14 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 17:34:26 +08:00
Dennis 66468c703f fix(minutes): preserve upload recovery and schema compatibility 2026-08-11 17:06:22 +08:00
玉澜 773e76a1c6 Merge remote-tracking branch 'fork/feat/skill-mode-migration' into feat/skill-mode-migration 2026-08-11 17:01:44 +08:00
玉澜 f4e39a219b fix(skill): preserve state on partial setup 2026-08-11 17:01:32 +08:00
john c170a464e1 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 17:00:13 +08:00
Dennis 74ef426064 Merge remote-tracking branch 'origin/main' into codex/minutes-shortcuts 2026-08-11 16:55:06 +08:00
玉澜 4665b42bbf fix(skill): preserve installer caches during refresh 2026-08-11 16:16:49 +08:00
john 16273de554 Merge branch 'main' into feat/skill-mode-migration 2026-08-11 16:13:48 +08:00
john aabee99e3f Merge branch 'main' into feat/skill-mode-migration 2026-08-11 15:35:23 +08:00
玉澜 9e3a083c27 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 14:12:43 +08:00
玉澜 3a0d814276 docs(skill): remove confirmation bypass examples 2026-08-11 13:52:24 +08:00
玉澜 f3ddbb2db0 fix(upgrade): preserve skill cache during refresh 2026-08-11 13:17:07 +08:00
玉澜 fbdb5e8d4d Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 12:07:58 +08:00
玉澜 cc7e7bf0e0 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration
# Conflicts:
#	internal/app/skill_setup.go
#	internal/app/skill_setup_test.go
2026-08-11 12:06:59 +08:00
Dennis 461b9b773a feat(minutes): align and expand shortcut workflows 2026-08-11 11:52:57 +08:00
liangxiaoqin.lxq 4bd9f75231 cr修复1 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 8f8f64c391 cr修复,增加测试 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq ae9caa06af cr修复 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq ee0c3507a5 补充测试 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 72a9902254 wiki feed list命令 2026-08-11 10:57:44 +08:00
liangxiaoqin.lxq 5f337e0ce5 wiki feed list命令:新增时间格式化/字段裁剪 2026-08-11 10:57:44 +08:00
玉澜 01a7b20026 fix(skill): keep setup confirmation and Windows tests safe 2026-08-11 10:46:40 +08:00
玉澜 62541947e7 Merge remote-tracking branch 'origin/main' into feat/skill-mode-migration 2026-08-11 10:10:01 +08:00
玉澜 596da1343e fix(skill): sync installed multi-skill set safely 2026-08-11 00:10:59 +08:00
玉澜 12c7b6eb89 test(skill): cover mono cleanup failure on Windows 2026-08-10 22:50:52 +08:00
玉澜 bc3d92ccaf Merge remote-tracking branch 'origin/main' into pr-922 2026-08-10 22:36:07 +08:00
玉澜 61adc87987 fix(skill): fail safely during layout migration 2026-08-10 22:35:36 +08:00
玉澜 c515f7c1e5 test(ci): cover aggregate changed-code edges 2026-08-10 21:28:58 +08:00
玉澜 8eae408e28 fix(ci): pin synthetic merge to event SHA 2026-08-10 21:08:55 +08:00
玉澜 9f3df91584 fix(ci): pin merge checkout and cover Windows edges 2026-08-10 21:04:08 +08:00
玉澜 37cccdbc0e Merge remote-tracking branch 'origin/main' into pr-922 2026-08-10 20:51:58 +08:00
玉澜andCursor b6851e641e fix(skill): back up skill dirs before removal and satisfy coverage gate
Address the two P1 review findings and the coverage-gate CI failures:
- Every install/upgrade path that removes a skill dir (opposite-mode
  leftovers, stale dingtalk-* / dws-shared, and same-name refreshes) now
  moves the directory to ~/.dws/skill-backups/<stamp>/ first across
  install.sh, install-skills.sh, install.ps1, install.js, `dws skill
  setup`, and `dws upgrade`. A backup failure preserves the original
  directory and never removes it.
- Remove --yes from every copyable `dws skill setup` example and document
  what the command may remove; add regression tests that declining the
  confirmation performs no removal and that the confirmation previews
  every directory slated for backup+removal.
- Rename the skill-mode tests to the TestCrossPlatformCoverage* prefix so
  the platform coverage gate selects them, and add edge tests for the
  backup/prune/cleanup fallback branches, restoring changed-code coverage
  to 100%.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 11:03:33 +08:00
玉澜andCursor d5c8982c00 feat(upgrade): always refresh to multi-skill layout (no sticky)
When a release zip contains multi/, upgrade one-shot refreshes to the
multi-skill layout and migrates existing mono installs. Docs drop the
cancelled runtime switch / sticky design.

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:13:45 +08:00
97 changed files with 18009 additions and 1439 deletions
+15 -29
View File
@@ -34,8 +34,8 @@ jobs:
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: read
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
pull-requests: write
steps:
- name: Check out default branch tooling
uses: actions/checkout@v4
@@ -69,12 +69,10 @@ jobs:
PARSE_ERROR: ${{ steps.parse.outputs.error }}
run: |
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$body" \
> /dev/null
exit 1
- name: Verify reviewed PR head
@@ -103,13 +101,9 @@ jobs:
set -euo pipefail
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
response="$(
jq -n --arg body "$placeholder_body" '{body: $body}' \
| curl --fail --silent --show-error \
-X POST \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
gh api --method POST \
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--raw-field body="$placeholder_body"
)"
comment_id="$(
printf '%s' "$response" \
@@ -277,13 +271,9 @@ jobs:
fi
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
response="$(
jq -n --arg body "$body" '{body: $body}' \
| curl --fail --silent --show-error \
-X PATCH \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}"
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$body"
)"
printf '%s' "$response" \
| jq -e \
@@ -299,12 +289,8 @@ jobs:
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
run: |
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
jq -n --arg body "$failure_body" '{body: $body}' \
| curl --fail --silent --show-error \
-X PATCH \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
--data @- \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
gh api --method PATCH \
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
--raw-field body="$failure_body" \
> /dev/null \
|| true
+45
View File
@@ -6,6 +6,51 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58-beta.4] - 2026-08-12
### Added
- **Multi-skill installation and upgrade** — fresh installs, `dws skill setup`,
and `dws upgrade` now use the multi-skill layout by default. Existing mono
installations migrate during upgrade; mono remains an explicit legacy option.
- **Native streaming-card mentions** — `dws chat message send-card` now accepts
`--at-open-dingtalk-ids` and `--at-all` for group cards and forwards them to
`create_and_send_card`, matching the existing shortcut behavior without
changing single-chat card creation.
- **Expanded Minutes workflows** — 27 public Minutes shortcuts now cover
upload, download, export, recording, analysis, sharing, and recovery flows;
every write command keeps an explicit confirmation requirement.
- **Chat command discovery** — 30 existing typed Chat commands are now
available in the runtime Schema and Agent catalog, with sensitive writes
carrying their required confirmation metadata.
### Changed
- **Chat read results** — typed commands and shortcuts now expose a consistent
top-level `messages` list with stable `messageId` and `text` fields while
retaining existing response envelopes and fields.
- **Wiki feed results** — Wiki feed list output now formats time fields and
trims excess fields. Its `--limit` default is 10 and maximum is 20.
- **Developer command results** — the `dev` and selected `devapp` commands now
use the unified result envelope for consistent success, pending, partial,
and failure reporting.
- **Evaluation dispatch hardening** — `/eval` now uses a verifiable polling
relay instead of direct access from the hosted runner, binding the workflow,
comment, PR head, parameters, and result provenance.
### Fixed
- **Streaming-card update acknowledgement** — accepts the pre-production
`success: true` response from `update_streaming_card` as affirmative write
evidence while preserving explicit negative, conflicting, and bizId-drift
failures, so Agents do not repeat an update that the service already applied.
- **Text input bounds** — literal input, stdin, and `@file` inputs now all
enforce the same byte limit; file reads validate the opened descriptor and
cannot exceed the limit after a path replacement or file growth.
- **Evaluation PR comments** — restores `/eval` PR conversation comments with
the least required pull-request write permission and actionable GitHub 403
diagnostics.
## [1.0.58-beta.3] - 2026-08-11
### Added
+28 -17
View File
@@ -70,15 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
How to pick:
- **Quick install** (one-liner above): non-interactive, installs `mono`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
- **Quick install** (one-liner above): non-interactive, installs `multi`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — review the listed paths and confirm interactively.
</details>
@@ -391,19 +393,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
```bash
# Install skills into current project (defaults to mono)
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
> `install.sh` installs under `$HOME/.agents/skills/` (global; multi layout is per-product siblings, mono is the `dws/` subdirectory); `install-skills.sh` installs under `./.agents/skills/` (current project).
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
@@ -413,13 +415,18 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
# Interactive: prompts for mode + target agents
dws skill setup
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# Preview the exact directories that mono setup would back up and replace
dws skill setup --mode mono --target all --dry-run
# Install multi skills to a single agent home
dws skill setup --mode multi --target cursor --yes
# Run interactively and confirm the listed directories
dws skill setup --mode mono --target all
# Point at a local source tree (e.g. a fork or work-in-progress)
# Preview, then install multi skills to a single agent home with interactive confirmation
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# Point at a local source tree (e.g. a fork or work-in-progress), preview first
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
@@ -428,7 +435,11 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
| `--source` | path | Local source directory (overrides bundled skills) |
| `--yes` | — | Skip confirmation prompts |
| `--yes` | — | Scripting-only: skip the confirmation prompt. Removals are still backed up to `~/.dws/skill-backups/` first |
> The setup command can remove the opposite-mode layout (`dws/` for multi, DWS-managed multi Skills for mono) and stale managed Skills not in the bundle. DWS records ownership, installer version, source, and content digest centrally in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Exact official names shipped before the centralized state remain a frozen migration list. A `dingtalk-*` prefix alone never authorizes cleanup, so other same-prefix market/user Skills are preserved. Every removal is previewed before confirmation and preserved under `~/.dws/skill-backups/<timestamp>/`; a directory that cannot be backed up is never removed. In a non-interactive shell, first run `--dry-run` and inspect its output; only then may the caller explicitly choose the scripting-only confirmation bypass.
After a multi setup or upgrade, DWS stores the official bundle snapshot and centralized ownership metadata in `~/.dws/skills-state.json` (or `$DWS_CONFIG_DIR/skills-state.json`). Every upgrade installs and overwrites the complete bundled Skill set from that release. Deleting or excluding a bundled Skill is not sticky: the next upgrade restores it. `dws upgrade --force` additionally allows reinstalling the current CLI version when no newer version is available.
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
@@ -726,7 +737,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
<summary>Coming soon</summary>
- `conference` (video meetings)
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back after interactive confirmation
</details>
+28 -17
View File
@@ -70,15 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
怎么选:
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),核对列出的路径后交互确认。
</details>
@@ -385,19 +387,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
```bash
# 安装 skills 到当前项目(默认 mono)
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
> `install.sh` 安装到 `$HOME/.agents/skills/`(全局,multi 为按产品平铺,mono 为 `dws/` 子目录);`install-skills.sh` 安装到 `./.agents/skills/`(当前项目)。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
@@ -407,13 +409,18 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
# 交互式:提示选模式 + 目标 Agent
dws skill setup
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# 先预览 mono setup 将备份和替换的精确目录
dws skill setup --mode mono --target all --dry-run
# 只装到某一个 Agent home
dws skill setup --mode multi --target cursor --yes
# 交互执行并确认列出的目录
dws skill setup --mode mono --target all
# 指定本地源目录(比如 fork 或正在改的版本)
# 先预览,再交互确认装到某一个 Agent home
dws skill setup --mode multi --target cursor --dry-run
dws skill setup --mode multi --target cursor
# 指定本地源目录(比如 fork 或正在改的版本),先预览
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi --dry-run
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
@@ -422,7 +429,11 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
| `--yes` | — | 跳过确认提示 |
| `--yes` | — | 仅供脚本使用:跳过确认提示。删除操作仍会先备份到 `~/.dws/skill-backups/` |
> setup 命令可能移除对面模式残留(装 multi 删 `dws/`,装 mono 清理统一状态中登记或属于状态上线前精确官方名称集合的 multi Skill)以及不在 bundle 内的过期受管 Skill。DWS 在 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)集中记录所有权、安装版本、来源和内容摘要。仅有 `dingtalk-*` 前缀不能触发清理,因此其他同前缀市场/用户 Skill 会保留。所有删除都会先列入确认预览,并备份到 `~/.dws/skill-backups/<时间戳>/`;备份失败的目录会保留原样、绝不删除。非交互环境应先用 `--dry-run` 核对输出,再由调用方显式决定是否使用仅供脚本的确认跳过参数。
multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权元数据写入 `~/.dws/skills-state.json`(或 `$DWS_CONFIG_DIR/skills-state.json`)。每次 upgrade 都会安装并覆盖该版本的全部预制 Skill;手工删除或通过 setup 排除预制 Skill 不会永久保留,下次 upgrade 会恢复。`dws upgrade --force` 还允许在没有新版本时重装当前 CLI 版本。
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
@@ -715,7 +726,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
<summary>即将推出</summary>
- `conference`(视频会议)
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 交互确认后可切回单 skill
</details>
+570 -17
View File
@@ -3,6 +3,7 @@
"use strict";
const fs = require("fs");
const crypto = require("crypto");
const os = require("os");
const path = require("path");
const childProcess = require("child_process");
@@ -45,6 +46,58 @@ function ensureCleanDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
// backupStamp returns the UTC timestamp used for backup directory names,
// matching the shell installers' `date -u +%Y%m%d-%H%M%S` layout.
function backupStamp() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
);
}
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel-or-basename> instead of destroying it (non-interactive
// installs cannot confirm, so removals must stay reversible). Missing paths
// are a no-op success. On any backup failure the directory is left in place
// and false is returned so callers skip that target rather than silently
// deleting data.
function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return true;
}
const rel = path.relative(homeDir, dir);
const name =
rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel)
? rel.split(path.sep).join("-")
: path.basename(dir);
const stamp = backupStamp();
const backupRoot = path.join(homeDir, ".dws", "skill-backups");
let targetRoot = path.join(backupRoot, stamp);
let target = path.join(targetRoot, name);
for (let i = 1; fs.existsSync(target); i++) {
if (i > 1000) {
console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`);
return false;
}
targetRoot = path.join(backupRoot, `${stamp}-${i}`);
target = path.join(targetRoot, name);
}
try {
fs.mkdirSync(targetRoot, { recursive: true });
renameFn(dir, target);
} catch (err) {
console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`);
return false;
}
if (backups) {
backups.push({ original: dir, backup: target });
}
console.log(` × 已备份并移除 ${dir} → ${target}`);
return true;
}
function findBinary(root) {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
@@ -117,9 +170,86 @@ function copyChildren(srcDir, destDir) {
}
}
// publishCacheAtomically prepares a complete sibling tree before replacing a
// cache. If copying or publishing fails, the previous cache stays available.
// copyFn is injectable so the failure contract can be tested without relying
// on platform-specific permission behavior.
function publishCacheAtomically(sourceDir, cacheDir, copyFn = copyChildren) {
const cacheParent = path.dirname(cacheDir);
const cacheName = path.basename(cacheDir);
fs.mkdirSync(cacheParent, { recursive: true });
const stagedDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.tmp-`));
let rollbackDir = "";
let published = false;
try {
copyFn(sourceDir, stagedDir);
if (fs.existsSync(cacheDir)) {
rollbackDir = fs.mkdtempSync(path.join(cacheParent, `.${cacheName}.old-`));
fs.rmSync(rollbackDir, { recursive: true, force: true });
fs.renameSync(cacheDir, rollbackDir);
}
try {
fs.renameSync(stagedDir, cacheDir);
published = true;
} catch (publishErr) {
if (rollbackDir) {
try {
fs.renameSync(rollbackDir, cacheDir);
rollbackDir = "";
} catch (restoreErr) {
throw new Error(
`failed to publish cache ${cacheDir}: ${publishErr.message}; ` +
`failed to restore previous cache from ${rollbackDir}: ${restoreErr.message}`,
);
}
}
throw publishErr;
}
if (rollbackDir) {
try {
fs.rmSync(rollbackDir, { recursive: true, force: true });
} catch (cleanupErr) {
console.warn(
`⚠️ New cache is active, but old cache cleanup failed at ${rollbackDir}: ${cleanupErr.message}`,
);
}
rollbackDir = "";
}
} finally {
if (!published) {
fs.rmSync(stagedDir, { recursive: true, force: true });
}
}
}
function installSkillsToHomes(skillRoot) {
const homeDir = os.homedir();
const managedNames = readManagedSkillNames(homeDir);
let installed = 0;
let attempted = 0;
let failed = 0;
const installToBase = (baseDir) => {
const victims = [path.join(baseDir, "dws")];
if (fs.existsSync(baseDir)) {
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) {
victims.push(path.join(baseDir, entry.name));
}
}
}
try {
publishManagedMonoSkillSetAtomically(homeDir, skillRoot, baseDir, victims);
} catch (err) {
console.warn(`⚠️ 跳过 ${baseDir}(mono 集合发布失败,已回滚): ${err.message}`);
return false;
}
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
const baseDir = path.join(homeDir, agentDir);
@@ -127,37 +257,434 @@ function installSkillsToHomes(skillRoot) {
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
const destDir = path.join(baseDir, "dws");
fs.rmSync(destDir, { recursive: true, force: true });
copyChildren(skillRoot, destDir);
installed += 1;
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
if (installed === 0) {
copyChildren(skillRoot, path.join(homeDir, ".agents", "skills", "dws"));
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
}
}
if (installed === 0) {
throw new Error("未安装任何 mono Skill:所有检测到的 Agent 目标均失败");
}
if (failed > 0) {
throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`);
}
fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true });
}
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
// bundle carries at least one product skill (a subdir with SKILL.md). An
// empty or corrupt multi/ tree must never select the multi branch nor refresh
// the multi cache — installing it would wipe existing skills and lay down
// nothing.
function multiTreeHasSkills(dir) {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
return false;
}
return fs
.readdirSync(dir, { withFileTypes: true })
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
}
const MANAGED_SKILL_DIGEST_SCOPE = "skill-directory-v1";
// Frozen exact names shipped before centralized ownership metadata. Retired
// names stay here so old installs can be migrated without treating every
// dingtalk-* directory as DWS-owned.
const LEGACY_OFFICIAL_MULTI_SKILLS = new Set([
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
"dingtalk-todo", "dingtalk-wiki", "dws-shared",
]);
function skillStateDir(homeDir) {
return (process.env.DWS_CONFIG_DIR || "").trim() || path.join(homeDir, ".dws");
}
function readManagedSkillNames(homeDir) {
try {
const state = JSON.parse(fs.readFileSync(path.join(skillStateDir(homeDir), "skills-state.json"), "utf8"));
return new Set((state.managed_skills || []).map((record) => record.name).filter(Boolean));
} catch (_) {
return new Set();
}
}
function isManagedMultiSkillDir(dir, managedNames) {
const name = path.basename(dir);
return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name);
}
function skillDirectoryDigest(dir) {
const files = [];
const visit = (current, prefix) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
visit(full, rel);
} else {
files.push({ rel, full });
}
}
};
visit(dir, "");
files.sort((a, b) => Buffer.from(a.rel).compare(Buffer.from(b.rel)));
const hash = crypto.createHash("sha256");
for (const file of files) {
hash.update(file.rel, "utf8");
hash.update(Buffer.from([0]));
hash.update(fs.readFileSync(file.full));
hash.update(Buffer.from([0]));
}
return `sha256:${hash.digest("hex")}`;
}
// Publish a complete multi-skill set as one transaction. The entire new set
// is staged before any Agent-visible directory moves. If a later backup or
// publish fails, every partial publication is removed and all old directories
// are restored from their exact backup paths.
function publishManagedMultiSkillSetAtomically(
homeDir,
multiRoot,
baseDir,
skills,
victims,
options = {},
) {
const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-"));
const staged = [];
const backups = [];
const published = [];
const restore = () => {
const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) {
try {
removeFn(published[i]);
} catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
}
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
const item = backups[i];
try {
fs.mkdirSync(path.dirname(item.original), { recursive: true });
renameFn(item.backup, item.original);
} catch (err) {
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(restoreErrors.join("; "));
}
};
try {
for (const name of skills) {
const stagedDir = path.join(stageRoot, name);
copyFn(path.join(multiRoot, name), stagedDir);
staged.push({ staged: stagedDir, dest: path.join(baseDir, name) });
}
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
for (const item of staged) {
renameFn(item.staged, item.dest);
published.push(item.dest);
}
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
removeFn(stageRoot);
}
}
// Publish mono plus every mutually-exclusive managed multi victim as one
// transaction. The complete dws/ tree is staged before any live directory is
// moved; a later backup or publish failure restores the exact previous set.
function publishManagedMonoSkillSetAtomically(
homeDir,
monoRoot,
baseDir,
victims,
options = {},
) {
const copyFn = options.copyFn || copyChildren;
const renameFn = options.renameFn || fs.renameSync;
const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true }));
fs.mkdirSync(baseDir, { recursive: true });
const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-"));
const stagedDir = path.join(stageRoot, "dws");
const destDir = path.join(baseDir, "dws");
const backups = [];
const published = [];
const restore = () => {
const restoreErrors = [];
for (let i = published.length - 1; i >= 0; i -= 1) {
try {
removeFn(published[i]);
} catch (err) {
restoreErrors.push(`remove ${published[i]}: ${err.message}`);
}
}
for (let i = backups.length - 1; i >= 0; i -= 1) {
const item = backups[i];
try {
fs.mkdirSync(path.dirname(item.original), { recursive: true });
renameFn(item.backup, item.original);
} catch (err) {
restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`);
}
}
if (restoreErrors.length > 0) {
throw new Error(restoreErrors.join("; "));
}
};
try {
copyFn(monoRoot, stagedDir);
const seen = new Set();
for (const victim of victims) {
const normalized = path.resolve(victim);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) {
throw new Error(`failed to back up Skill directory ${victim}`);
}
}
published.push(destDir);
renameFn(stagedDir, destDir);
} catch (err) {
try {
restore();
} catch (restoreErr) {
throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`);
}
throw err;
} finally {
removeFn(stageRoot);
}
}
function writeSkillsState(homeDir, multiRoot, skills) {
const version = process.env.npm_package_version || process.env.DWS_PACKAGE_VERSION || "unknown";
const managedSkills = [...skills].sort().map((name) => ({
name,
version,
source: "npm-postinstall",
digest: skillDirectoryDigest(path.join(multiRoot, name)),
digest_scope: MANAGED_SKILL_DIGEST_SCOPE,
}));
const state = {
version,
official_skills: [...skills].sort(),
updated_skills: [...skills].sort(),
managed_skills: managedSkills,
updated_at: new Date().toISOString(),
};
const stateDir = skillStateDir(homeDir);
fs.mkdirSync(stateDir, { recursive: true });
const stage = fs.mkdtempSync(path.join(stateDir, ".skills-state.tmp-"));
const stagedFile = path.join(stage, "skills-state.json");
const statePath = path.join(stateDir, "skills-state.json");
const rollbackPath = path.join(stage, "skills-state.previous.json");
let movedPrevious = false;
let preserveRecovery = false;
try {
fs.writeFileSync(stagedFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
if (fs.existsSync(statePath)) {
fs.renameSync(statePath, rollbackPath);
movedPrevious = true;
}
try {
fs.renameSync(stagedFile, statePath);
} catch (err) {
if (movedPrevious && !fs.existsSync(statePath)) {
try {
fs.renameSync(rollbackPath, statePath);
movedPrevious = false;
} catch (restoreErr) {
preserveRecovery = true;
throw new Error(
`publish skills state failed: ${err.message}; restore also failed: ${restoreErr.message}; previous state retained at ${rollbackPath}`,
);
}
}
throw err;
}
} finally {
if (!preserveRecovery) {
fs.rmSync(stage, { recursive: true, force: true });
}
}
}
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
// every product skill becomes a sibling directory of the agent home. Mutual
// exclusion: the mono leftover (dws/) and stale, proven DWS-managed skills not
// present in the new bundle are removed first.
function installMultiSkillsToHomes(multiRoot) {
const homeDir = os.homedir();
const skills = fs
.readdirSync(multiRoot, { withFileTypes: true })
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
.map((e) => e.name);
if (skills.length === 0) {
throw new Error(`no product skills found under ${multiRoot}`);
}
const skillSet = new Set(skills);
const managedNames = readManagedSkillNames(homeDir);
let installed = 0;
let attempted = 0;
let failed = 0;
const installToBase = (baseDir) => {
fs.mkdirSync(baseDir, { recursive: true });
const victims = [path.join(baseDir, "dws")];
// Mutual exclusion: include the mono leftover and stale managed skills in
// the same transaction as every replaced bundled skill.
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
if (
entry.isDirectory() &&
(LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) &&
!skillSet.has(entry.name)
) {
victims.push(path.join(baseDir, entry.name));
}
}
for (const name of skills) {
victims.push(path.join(baseDir, name));
}
try {
publishManagedMultiSkillSetAtomically(homeDir, multiRoot, baseDir, skills, victims);
} catch (err) {
console.warn(`⚠️ 跳过 ${baseDir}(multi 集合发布失败,已回滚): ${err.message}`);
return false;
}
return true;
};
AGENT_DIRS.forEach((agentDir, index) => {
const baseDir = path.join(homeDir, agentDir);
const parentGate = path.dirname(baseDir);
if (index > 0 && !fs.existsSync(parentGate)) {
return;
}
attempted += 1;
if (installToBase(baseDir)) {
installed += 1;
} else {
failed += 1;
}
});
if (attempted === 0) {
if (installToBase(path.join(homeDir, ".agents", "skills"))) {
installed += 1;
} else {
failed += 1;
}
}
if (installed === 0) {
throw new Error("未安装任何 multi Skill:所有检测到的 Agent 目标均失败");
}
if (failed > 0) {
throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`);
}
writeSkillsState(homeDir, multiRoot, skills);
}
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
// wins; multi is the default. The --skill-mode flag accepts both the space
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
function resolveSkillMode() {
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
if (raw === "mono" || raw === "multi") {
return raw;
}
if (raw !== "") {
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
}
let fromFlag;
const flagIndex = process.argv.indexOf("--skill-mode");
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
fromFlag = process.argv[flagIndex + 1];
} else {
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
if (equalsArg) {
fromFlag = equalsArg.slice("--skill-mode=".length);
}
}
if (fromFlag !== undefined) {
const mode = fromFlag.trim().toLowerCase();
if (mode === "mono" || mode === "multi") {
return mode;
}
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
}
return "multi";
}
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
// can fall back to a user-local cache when --source is not provided. mono is
// already installed into agent homes by installSkillsToHomes; the cache is
// purely a source-of-truth for the setup command.
// can fall back to a user-local cache when --source is not provided. A cache
// is only refreshed when the new bundle actually carries that tree — an
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
// good cache.
function cacheUserSkills(extractedSkillsRoot) {
const cacheBase = path.join(os.homedir(), ".dws", "skills");
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
? path.join(extractedSkillsRoot, "mono")
: extractedSkillsRoot;
const monoCache = path.join(cacheBase, "mono");
fs.rmSync(monoCache, { recursive: true, force: true });
copyChildren(monoSource, monoCache);
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
const monoCache = path.join(cacheBase, "mono");
publishCacheAtomically(monoSource, monoCache);
}
const multiSource = path.join(extractedSkillsRoot, "multi");
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
if (multiTreeHasSkills(multiSource)) {
const multiCache = path.join(cacheBase, "multi");
fs.rmSync(multiCache, { recursive: true, force: true });
copyChildren(multiSource, multiCache);
publishCacheAtomically(multiSource, multiCache);
}
}
@@ -191,8 +718,34 @@ function main() {
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
? path.join(skillsStaging, "mono")
: skillsStaging;
installSkillsToHomes(monoRoot);
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
// multi-only zip monoRoot would degrade to the staging root and copy the
// whole bundle (multi/ included) into a dws/ directory — skip instead.
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
const multiRoot = path.join(skillsStaging, "multi");
const skillMode = resolveSkillMode();
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
console.log(`Skill mode: multi — installing per-product skills`);
installMultiSkillsToHomes(multiRoot);
} else {
if (skillMode === "multi") {
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
}
if (monoHasSkill) {
installSkillsToHomes(monoRoot);
} else {
console.log("mono skill tree not found in bundle; skipping skill install.");
}
}
cacheUserSkills(skillsStaging);
}
main();
if (require.main === module) {
main();
}
module.exports = {
publishCacheAtomically,
publishManagedMonoSkillSetAtomically,
publishManagedMultiSkillSetAtomically,
};
+216
View File
@@ -0,0 +1,216 @@
# RFC:DWS 预制 Skill 安装、升级与模式迁移
| 字段 | 内容 |
|---|---|
| 状态 | Accepted / as implemented |
| 生效范围 | DWS CLI、升级器、npm 与平台安装脚本 |
| 事实源 | 本 RFC 与当前代码;两者冲突时以代码和测试为准 |
| 关联合同 | [Skill 内容框架](skill-content-framework.md)、[Mono↔Multi 内容质检](skill-mono-multi-qa.md) |
## 1. 背景
DWS 同时通过 CLI、升级器、npm、Shell 和 PowerShell 分发预制 Skill。multi
成为默认布局后,所有入口必须对安装集合、模式互斥、失败退出、缓存发布和目录
所有权保持一致。此前分散的调研、迁移计划、阶段性 roadmap 和 rollout 文档容易
相互冲突;本 RFC 将最终行为收敛为一个长期合同。
## 2. 目标与非目标
### 2.1 目标
- 新装与升级默认使用 multi 布局,mono 在兼容期内保留显式 opt-in。
- 每次升级使用当前版本的官方清单全量覆盖预制 Skill。
- 删除或替换任何目录前先创建可恢复备份,备份失败不修改该 Agent 目标。
- 只清理能够证明由 DWS 管理的目录,不通过名称前缀推断所有权。
- 所有安装入口对部分失败返回非零状态,不误报整体成功。
- 安装预览、确认和实际执行使用同一份计划。
### 2.2 非目标
- 不建设独立的 `dws skill mode status|set|rollback` 产品面。
- 不持久化用户对预制 Skill 的本地删除或排除意图。
- 不提供跨所有 Agent 目标的事务式回滚。
- 不把市场 Skill 纳入预制 Skill 的升级和清理范围。
## 3. 业内调研
对主流 CLI 与 Agent Skill 分发方式的公开实现进行归纳后,可以得到以下共性:
| 观察 | 对 DWS 的启示 |
|---|---|
| 多个产品能力通常以同级 Skill 目录安装,由 Agent 按目录发现 | multi 使用平铺的产品 Skill,并保留一个共享 Skill 承载公共协议 |
| CLI 本体安装和 Agent Skill 安装是两个生命周期 | DWS 可以在 CLI 安装、setup 和 upgrade 中触发 Skill 同步,但二者的失败与状态必须分别报告 |
| 生态安装器通常天然采用 multi,不提供 mono/multi 状态机 | DWS 的模式切换保持为重新执行 setup,不新增长期驻留的 mode lifecycle |
| 市场 Skill 与 CLI 预制 Skill 可能落在同一 Agent 根目录 | 必须使用统一所有权元数据识别受管目录,名称前缀不能作为删除依据 |
| 多 Skill 更新常以新清单刷新官方集合 | DWS 使用当前 bundle 官方清单全量覆盖,新增 Skill 自动加入,本地删除不视为持久化排除 |
| 制品可能需要同时服务无运行时依赖、离线和多镜像环境 | DWS 保留 embed、zip 和平台安装脚本,不把单一生态包管理器设为唯一入口 |
| 中断的复制和原地覆盖容易破坏最后一个可用版本 | 缓存与 Go upgrade 的 Agent 目标采用 staging publish;发布失败自动恢复该目标的完整旧集合 |
| Agent 通常以 `SKILL.md` 为入口,其他文件按引用或工具规则按需读取 | 安装元数据使用不被内容引用的隐藏文件,并保证其内容不包含 Agent 指令 |
本节只保留可复用的工程结论,不记录具体产品、仓库、版本或逐项能力对照,也不构成
DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约束和本 RFC 的行为合同为准。
## 4. 布局合同
| 模式 | Agent 目录布局 | 选择方式 |
|---|---|---|
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
不可恢复的删除。
## 5. 官方集合与升级策略
当前版本 bundle 中的 multi 目录清单是升级集合的唯一权威来源。普通 upgrade 和
`--force` 都安装并覆盖该版本的全部官方预制 Skill:
- 本地删除的预制 Skill 会在下一次升级恢复;
- setup 时通过 `--exclude` 暂时排除的 Skill 会在下一次升级恢复;
- 新版本新增的官方 Skill 会自动安装;
- 用户对预制 Skill 的本地修改会被官方版本覆盖;
- `dingtalk-shared` 始终随官方集合安装。
`~/.dws/skills-state.json`(设置 `DWS_CONFIG_DIR` 时位于该目录)不参与安装集合
求解,也不保存排除策略。它既记录结果快照,也集中记录 multi Skill 的所有权和
provenance,供安全清理、诊断与后续迁移使用。
## 6. 目录所有权
每次 multi setup 或 upgrade 全部成功后,DWS 在统一的
`~/.dws/skills-state.json` 中写入:
```json
{
"version": "v0.2.14",
"official_skills": ["dingtalk-aitable"],
"updated_skills": ["dingtalk-aitable"],
"managed_skills": [
{
"name": "dingtalk-aitable",
"version": "v0.2.14",
"source": "dws-upgrade",
"digest": "sha256:<64 个十六进制字符>",
"digest_scope": "skill-directory-v1"
}
],
"updated_at": "2026-08-11T12:34:56Z"
}
```
每条 `managed_skills` 记录代表一个由 DWS 管理的官方 Skill。`version` 记录安装该
副本的 DWS/发布包版本,`source` 记录安装入口,`digest` 是对 bundle 中 Skill 目录
全部普通文件按相对路径排序后计算的内容摘要。摘要用于诊断和来源追踪,不作为后续
升级的完整性门禁;用户修改 Skill 内容后,DWS 仍保有明确管理权并能在下一次升级时
覆盖恢复。
清理 stale Skill 或切换到 mono 时,只接受以下所有权证据:
1. Skill 名称存在于统一状态的 `managed_skills` 中;
2. 统一状态上线前曾发布过的官方 Skill 精确名称集合。
历史集合是冻结的迁移清单,包含 `dws-shared` 以及已退役、折叠或仍在发布的旧官方
目录名。仅有 `dingtalk-*` 前缀不构成所有权证据。因此,市场或用户创建的
`dingtalk-custom` 等非官方精确名称目录不会被迁走。
### 6.1 对 Agent 的影响
Skill 目录内不再放置 DWS 所有权文件,也不增加非通用 frontmatter 字段。支持的
Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agent Skill 目录之外,
不会成为提示词上下文或影响 Agent 行为。
## 7. Setup:Plan → Confirm → Execute
`dws skill setup` 分为三个阶段:
1. **Plan**:只读计算目标、安装集合以及所有待备份路径;
2. **Confirm**:`--dry-run` 和交互确认渲染同一份计划;
3. **Execute**:确认后严格执行计划中的备份和安装。
安全要求:
- 非交互环境未传 `--yes` 时拒绝执行;
- 用户拒绝确认时必须零文件写入;
- 备份失败时跳过整个 Agent 目标,不开始铺设相反布局;
- 同一目标先完成所有必要备份,再复制新集合;
- multi Skill 必须在同级 staging 中完成复制,再原子发布到正式目录;
- 任意 `skipped > 0` 都返回非零退出码,并且不写入完整成功快照;
- 一个 Agent 目标失败不阻止其他目标尝试,但最终结果仍为失败。
## 8. Upgrade 与恢复语义
升级器对每个 Agent 目标执行:
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
2. 在目标文件系统的 staging 中复制完整新集合;
3. staging 全部成功后,才将旧集合移入备份目录;
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
“不提供跨所有 Agent 目标的事务式回滚”非目标保持一致。
## 9. 备份合同
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
- 主要操作:同一文件系统内使用 rename 移动;
- 失败语义:备份失败时原目录保持不变,目标安装失败;
- 可见性:计划和执行日志显示原路径与备份路径;
- 保留策略:自动修剪,仅保留最近 5 批。
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
`dws skill setup --mode mono`。
## 10. 缓存与制品
发布制品和二进制内嵌内容同时携带 mono 与 multi 源树。`~/.dws/skills/` 只是
setup 在未显式指定 `--source` 时的本地回退缓存。
缓存刷新必须采用同级 staging + publish:
1. 在 staging 中完整复制并验证新树;
2. 发布前保留旧缓存;
3. 通过 rename 发布新缓存;
4. 复制或发布失败时保留或恢复旧缓存;
5. 空、缺失或损坏的 bundle 不能擦除有效缓存。
## 11. 安装入口一致性
以下入口都遵守本 RFC:
| 入口 | 默认模式 | 失败合同 |
|---|---|---|
| `dws skill setup` | multi | 部分失败返回非零;不写完整成功状态 |
| `dws upgrade` | bundle 含 multi 时安装 multi | 目标失败返回失败;下次全量重试 |
| `scripts/install.sh` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
## 12. 验收与回归门禁
合入和后续修改至少覆盖:
- mono → multi、multi → mono 互斥切换;
- 状态上线前的官方 multi 目录切换 mono 时能够被精确迁移;
- 未登记的同前缀市场/用户 Skill 在刷新和切换后仍存在;
- 统一状态中登记的过期官方 Skill 被备份并移除;
- 备份、复制、统一状态写入、缓存 publish 故障注入;
- 非交互确认拒绝与显式 `--yes`;
- 部分失败返回非零且不写错误状态快照;
- 复制失败不留下 Agent 可见的残缺官方目录;
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
- Windows、macOS、Linux 的路径和覆盖率门禁;
- npm、Shell、PowerShell 与包管理器安装冒烟。
## 13. 后续演进
- 收敛各安装入口中的 Agent home 清单,减少跨语言复制;
- 如确有运维需求,可单独设计备份查看和显式恢复命令;
- mono 的物理删除必须作为独立变更,在 multi 内容、安装入口和迁移回归稳定后推进;
- `managed_skills` 字段若演进,必须同步更新所有安装入口和跨平台回归。
-188
View File
@@ -1,188 +0,0 @@
# lark-cli Shortcut 深度对齐矩阵
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
## 2026-07-13 最新源码复核
对比基线:
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
- lark-cli:`main@e96c4fa5`
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
### 当前命令面快照
| 指标 | 数量 | 说明 |
|---|---:|---|
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
| 产品映射 | DWS | lark | 同名 |
|---|---:|---:|---:|
| aitable ↔ base | 82 | 87 | 31 |
| calendar ↔ calendar | 23 | 10 | 3 |
| chat ↔ im | 89 | 21 | 2 |
| contact ↔ contact | 16 | 2 | 1 |
| devapp ↔ apps | 30 | 63 | 3 |
| doc ↔ doc | 19 | 14 | 1 |
| drive ↔ drive | 9 | 26 | 3 |
| mail ↔ mail | 10 | 21 | 0 |
| minutes ↔ minutes | 13 | 9 | 1 |
| sheet ↔ sheets | 2 | 42 | 0 |
| todo ↔ task | 13 | 17 | 2 |
| wiki ↔ wiki | 7 | 12 | 3 |
### 最新优先差距
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
### 不建议机械追平
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
## 汇总(361 条 lark shortcut)
| dws_status | 数量 | 含义 |
|---|:---:|---|
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
### im → chat(7)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
### task → todo(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
### calendar → calendar(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
### doc (docs) → doc(2)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
### drive → drive(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
### mail → mail(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
### wiki → wiki(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
### minutes → minutes(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
### base → aitable(10)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
### sheets → sheet(14)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
### apps → devapp(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
- **task**: +complete +assign +get-my-tasks +get-related-tasks
- **contact**: +search-user
- **calendar**: +agenda +create +update +freebusy +suggestion
- **doc (docs)**: +history-revert
- **drive**: +upload +search +inspect
- **mail**: +triage
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
+243 -15
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-06T21:08:26.697858",
"count": 357,
"generated_at": "2026-08-10T17:25:46.245552",
"count": 378,
"results": [
{
"suite": "semantic",
@@ -2162,7 +2162,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -3004,46 +3004,274 @@
"status": "real-ok"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+action-items",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+apply-permission",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+detail",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+export-pack",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+latest",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+list-all",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of all accessible Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+list-mine",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of current-user Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "minutes",
"command": "+list-shared",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "Stable projection of shared Minutes with strict itemList response validation.",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "minutes",
"command": "+mindmap",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+prepare-asr",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-pause",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-pause facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-resume",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-resume facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-start",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-create facade with explicit confirmation and stable session parameter.",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "minutes",
"command": "+record-stop",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Friendly recording-stop facade with explicit task UUID validation and confirmation.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+record-wrap-up",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+replace-batch",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+search",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; +minutes-search is a compatibility alias.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+speaker-insights",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+speaker-replace",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+summary",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+transcript",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+unshare",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Owns local file validation, create-PUT-complete polling, cancellation compensation and final task read-back verification.",
"availability": "available"
},
{
"suite": "semantic",
"service": "minutes",
"command": "+upload-and-analyze",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.",
"availability": "available"
},
{
"suite": "read",
+3 -13
View File
@@ -2,7 +2,8 @@
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
> 安装、升级与模式迁移见
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
## 1. 两棵内容树
@@ -87,18 +88,7 @@ skills/mono/
质检可断言源树形状;**不**断言安装器默认解压哪棵。
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|---|---|---|
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
## 7. 变更流程
## 6. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
+2 -4
View File
@@ -3,6 +3,8 @@
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
> 安装、升级与模式迁移见
> [DWS 预制 Skill 安装、升级与模式迁移 RFC](rfc-skill-installation-and-upgrade.md)。
## 1. 质检矩阵
@@ -70,7 +72,3 @@ paired_files:
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
## 4. 与悟空
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
-272
View File
@@ -1,272 +0,0 @@
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
>
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
>
> 对照仓:
>
> | 仓 | 路径 | 基线 |
> |---|---|---|
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
---
## 0. TL;DR
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
### 0.1 IN SCOPE
| 类别 | 包含 |
|---|---|
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
### 0.2 OUT OF SCOPE
| 类别 | 去向 |
|---|---|
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
| 把质检做成「改安装默认值」的后门 | 拒绝 |
---
## 1. 内容现状盘点
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
│ └── best_practices/…
└── scripts/
```
### 1.2 DWS `skills/multi`(内容主体)
```text
skills/multi/
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
└── dingtalk-*/ # 19 产品 + 各 references、scripts
```
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
### 1.4 Zip 内容布局合同
| Zip 路径 | 内容含义 |
|---|---|
| `<root>/` | mono 副本(兼容) |
| `<root>/mono/` | 显式 mono 内容源 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
### 1.5 现有 DWS skill 内容质检资产(复用清单)
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|---|---|---|
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
### 1.6 悟空侧类比质检
| 悟空 | 说明 | 本分支 |
|---|---|---|
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
---
## 2. Diff(内容组织 + 质检视角)
### 2.1 已同构
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
### 2.2 分叉与已知内容风险(质检要盯的)
| 风险 ID | 现象(线索) | 质检类型 |
|---|---|---|
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
### 2.3 Reject
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
---
## 3. Goals / Non-goals
### 3.1 Goals
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
### 3.2 Non-goals
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
---
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
### Phase 0 — 方案冻结(本文)
| | |
|---|---|
| **范围** | 本文件;§7(含质检轨)勾选 |
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
| | |
|---|---|
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
| **触达** | `docs/skill-content-framework.md` |
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
| | |
|---|---|
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
| | |
|---|---|
| **范围** | G1–G4 自动门禁 |
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
| | |
|---|---|
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
### 延期登记(非本分支)
| 主题 | 载体 |
|---|---|
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
| skillhome / 安装面 bootstrap | 行为分支 |
---
## 5. Port / Adapt / Reject
| 项 | 决策 | 说明 |
|---|---|---|
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
| 行为 cherry-pick | **Defer** | 另分支 |
---
## 6. 与 `402429ac` / `d5c8982c`
| | |
|---|---|
| 本分支 cherry-pick? | **否** |
| 质检是否替代行为翻转? | **否** |
| 行为分支 | 另开;可与内容/质检并行 |
---
## 7. 批准清单(请重新勾选)
**范围**
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
- [x] 取消产品与悟空客户端链路仍拒绝
**内容框架 Phase**
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
**质检轨 Phase**
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
**可选**
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
**Follow-up 知悉**
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
---
## 8. 下一步
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
安装默认 multi 等行为仍走 **另一分支**。
---
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
+17 -9
View File
@@ -33,6 +33,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -2033,6 +2034,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatal(err)
}
state, readable, err := skillstate.Read(home)
if err != nil || !readable || len(state.OfficialSkills) != 3 || len(state.UpdatedSkills) != 2 {
t.Fatalf("setup state = %#v, readable=%v, err=%v", state, readable, err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
t.Fatalf("multi dry run = %q, %v", output, err)
}
@@ -2048,8 +2053,11 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
t.Fatalf("invalid setup %#v succeeded", args)
}
}
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
t.Fatalf("default mono setup: %v", err)
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
t.Fatalf("mono setup: %v", err)
}
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
t.Fatalf("default mode should be multi: %q, %v", output, err)
}
}
@@ -2082,7 +2090,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing multi source succeeded")
}
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
t.Fatalf("default setup mode = %q, %v", mode, err)
}
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
@@ -2117,7 +2125,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
for _, tc := range []struct{ path, mode string }{{"", skillSetupModeMono}, {mono, skillSetupModeMono}, {filepath.Dir(multi), skillSetupModeMulti}, {root, "bad"}} {
_ = isSkillSourceRoot(tc.path, tc.mode)
}
t.Setenv("HOME", t.TempDir())
setTestHome(t, t.TempDir())
for _, tc := range []struct{ target, mode string }{{"agents", skillSetupModeMono}, {"agents", skillSetupModeMulti}, {"all", skillSetupModeMono}, {"missing", skillSetupModeMono}} {
_, _ = resolveSkillSetupTargets(tc.target, tc.mode)
}
@@ -2125,8 +2133,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
_ = agentHomeForMode("base", skillSetupModeMulti)
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
_ = mutualExclusionVictims(root, mode)
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
_, _ = mutualExclusionVictims(root, mode)
}
if isCharDevice(nil) || isInteractiveTerminal() {
t.Fatal("test process unexpectedly interactive")
@@ -2134,17 +2142,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
multiDest := filepath.Join(t.TempDir(), "agent")
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
badParent := filepath.Join(t.TempDir(), "file")
_ = os.WriteFile(badParent, []byte("x"), 0o600)
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
t.Fatal("copy missing directory succeeded")
}
+1 -1
View File
@@ -58,7 +58,7 @@ func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
@@ -79,13 +79,15 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
t.Fatalf("timed out authorization = %v, %v", ok, err)
}
patAuthorizationTimeout = 5 * time.Millisecond
patAuthorizationTimeout = time.Second
patAuthorizationPollInterval = time.Millisecond
pollCtx, pollCancel := context.WithCancel(context.Background())
patResolveAccessToken = func(context.Context, string, string) (string, error) {
pollCancel()
return "", authpkg.ErrTokenDataNotFound
}
out.Reset()
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
if ok, err := WaitForPatAuthorization(pollCtx, "", &out); ok || !errors.Is(err, context.Canceled) || !strings.Contains(out.String(), "等待授权中") {
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
}
}
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 357
publicShortcutCount = 378
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 358
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 379
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 357
publiclyDeliveredShortcutCount = 378
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,707 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func useManagedSkillNames(t *testing.T, names ...string) {
t.Helper()
records := make([]skillprovenance.Record, 0, len(names))
for _, name := range names {
records = append(records, skillprovenance.Record{Name: name})
}
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
return &skillstate.State{ManagedSkills: records}, true, nil
})
}
// TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills verifies the
// confirmation prompt lists stale dingtalk-* / dws-shared directories that a
// full (unfiltered) multi install will back up and remove, and that a
// filtered install previews nothing extra.
func TestCrossPlatformCoverageSkillSetupConfirmPreviewsStaleSkills(t *testing.T) {
testseam.Swap(t, &skillSetupInteractive, func() bool { return false })
dest := filepath.Join(t.TempDir(), ".claude", "skills")
stale := filepath.Join(dest, "dingtalk-old")
if err := os.MkdirAll(stale, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stale, "SKILL.md"), []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, "dingtalk-old")
var out bytes.Buffer
ok, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, false)
if err == nil || ok || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
}
if !strings.Contains(out.String(), "将备份并移除过期 skill") {
t.Fatalf("full install preview must list stale skills, got %q", out.String())
}
if !strings.Contains(out.String(), filepath.Join(dest, "dingtalk-old")) {
t.Fatalf("preview must name the stale directory, got %q", out.String())
}
out.Reset()
ok, err = confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{dest}, []string{"dingtalk-chat"}, true)
if err == nil || ok {
t.Fatalf("filtered confirmSkillSetup = (%v, %v), want non-interactive confirmation error", ok, err)
}
if strings.Contains(out.String(), "将备份并移除过期 skill") {
t.Fatalf("filtered install must stay additive in the preview, got %q", out.String())
}
}
func TestCrossPlatformCoverageSkillSetupUnifiedOwnership(t *testing.T) {
dir := filepath.Join(t.TempDir(), "dingtalk-custom")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if isManagedDWSMultiSkillDir(dir) {
t.Fatal("an unregistered dingtalk-* directory must not be treated as DWS-owned")
}
managed := map[string]bool{"dingtalk-custom": true}
if !isManagedDWSMultiSkillDir(dir, managed) {
t.Fatal("unified metadata must prove ownership")
}
legacy := filepath.Join(t.TempDir(), legacySharedSkill)
if !isManagedDWSMultiSkillDir(legacy) {
t.Fatal("the exact legacy dws-shared name must remain managed")
}
}
func TestCrossPlatformCoveragePublishManagedSkillFailurePaths(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
skillSrc := filepath.Join(src, "dingtalk-a")
failure := errors.New("publish denied")
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishTemp, func(string, string) (string, error) { return "", failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "staging") {
t.Fatalf("mkdir error = %v", err)
}
})
t.Run("copy", func(t *testing.T) {
parent := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
if !errors.Is(err, failure) {
t.Fatalf("copy error = %v", err)
}
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
t.Fatalf("copy failure retained staging: %v, err=%v", entries, readErr)
}
})
t.Run("rename", func(t *testing.T) {
parent := t.TempDir()
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(parent, "dingtalk-a"))
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "发布 Skill") {
t.Fatalf("rename error = %v", err)
}
if entries, readErr := os.ReadDir(parent); readErr != nil || len(entries) != 0 {
t.Fatalf("rename failure retained staging: %v, err=%v", entries, readErr)
}
})
t.Run("cleanup", func(t *testing.T) {
renameErr := errors.New("rename denied")
cleanupErr := errors.New("cleanup denied")
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return renameErr })
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
err := publishDWSManagedSkillDir(skillSrc, filepath.Join(t.TempDir(), "dingtalk-a"))
if !errors.Is(err, renameErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("cleanup error = %v", err)
}
})
}
// TestCrossPlatformCoverageSkillSetupCleanupHomeFailure verifies that
// cleanupMutualExclusion keeps every victim in place with a warning when
// $HOME cannot be resolved, instead of destroying anything.
func TestCrossPlatformCoverageSkillSetupCleanupHomeFailure(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".agents", "skills")
victim := filepath.Join(dest, "dws")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(victim, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
homeErr := errors.New("home boom")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", homeErr })
var out, errOut bytes.Buffer
cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
t.Fatalf("expected HOME warning on errOut, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(victim, "SKILL.md")); err != nil {
t.Fatalf("victim must survive the HOME failure: %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupBackupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dws" {
return "", failure
}
return "", nil
})
dest := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
}
if copyCalls != 2 {
t.Fatalf("backup failure staged %d new Skills, want 2", copyCalls)
}
if !strings.Contains(errOut.String(), "跳过整个 Agent 目标") {
t.Fatalf("missing whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupCleanupMutualExclusionBackupFailure(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
victim := filepath.Join(dest, "dws")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
failure := errors.New("backup boom")
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if dir != victim {
t.Fatalf("backup victim = %q, want %q", dir, victim)
}
return "", failure
})
var out, errOut bytes.Buffer
err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut)
if !errors.Is(err, failure) {
t.Fatalf("cleanup error = %v, want %v", err, failure)
}
if out.Len() != 0 || !strings.Contains(errOut.String(), "互斥清理失败") {
t.Fatalf("cleanup output = %q / %q", out.String(), errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupMonoCleanupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("multi backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dingtalk-a" {
return "", failure
}
return "", nil
})
base := filepath.Join(home, ".agents", "skills")
multi := filepath.Join(base, "dingtalk-a")
if err := os.MkdirAll(multi, 0o755); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, filepath.Base(multi))
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if copyCalls != 1 {
t.Fatalf("multi cleanup failure staged mono %d times, want 1", copyCalls)
}
if _, err := os.Stat(multi); err != nil {
t.Fatalf("multi leftover must survive backup failure: %v", err)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("missing mono whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupStaleBackupFailureSkipsWholeTarget(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error {
copyCalls++
return nil
})
failure := errors.New("stale backup boom")
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, dir string) (string, error) {
if filepath.Base(dir) == "dingtalk-stale" {
return "", failure
}
return "", nil
})
dest := filepath.Join(home, ".agents", "skills")
stale := filepath.Join(dest, "dingtalk-stale")
if err := os.MkdirAll(stale, 0o755); err != nil {
t.Fatal(err)
}
useManagedSkillNames(t, filepath.Base(stale))
src := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, []string{"dingtalk-a", "dingtalk-shared"}, []string{dest}, &out, &errOut, false)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("install = (%d, %d, %v), want (0, 2, nil)", installed, skipped, err)
}
if copyCalls != 2 {
t.Fatalf("stale backup failure staged %d new Skills, want 2", copyCalls)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("missing stale whole-target warning: %q", errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *testing.T) {
for _, failureKind := range []string{"later_backup", "later_publish"} {
failureKind := failureKind
t.Run(failureKind, func(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
first := filepath.Join(dest, "dingtalk-first")
second := filepath.Join(dest, "dingtalk-second")
for path, body := range map[string]string{
filepath.Join(first, "SKILL.md"): "old first\n",
filepath.Join(second, "SKILL.md"): "old second\n",
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
src := writeMultiSkillSource(t, []string{"dingtalk-first", "dingtalk-second"})
if err := os.WriteFile(filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
failure := errors.New("injected " + failureKind + " failure")
if failureKind == "later_backup" {
originalBackup := skillSetupBackupAndRemove
testseam.Swap(t, &skillSetupBackupAndRemove, func(homeDir, dir string) (string, error) {
if dir == second {
return "", failure
}
return originalBackup(homeDir, dir)
})
} else {
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
}
return originalRename(oldPath, newPath)
})
}
var out, errOut bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(
src,
[]string{"dingtalk-first", "dingtalk-second"},
[]string{dest},
&out,
&errOut,
true,
)
if err != nil || installed != 0 || skipped != 2 {
t.Fatalf("transaction failure = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
}
for path, want := range map[string]string{
filepath.Join(first, "SKILL.md"): "old first\n",
filepath.Join(second, "SKILL.md"): "old second\n",
} {
got, readErr := os.ReadFile(path)
if readErr != nil || string(got) != want {
t.Fatalf("restored %s = %q, err=%v, want %q", path, got, readErr, want)
}
}
entries, readErr := os.ReadDir(dest)
if readErr != nil {
t.Fatal(readErr)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-setup-set-") {
t.Fatalf("transaction left staging directory %s", entry.Name())
}
}
if !strings.Contains(errOut.String(), "已执行回滚") || !strings.Contains(errOut.String(), failure.Error()) {
t.Fatalf("transaction failure output = %q", errOut.String())
}
})
}
}
func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
failure := errors.New("injected transaction failure")
t.Run("managed publish success", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := filepath.Join(t.TempDir(), "dingtalk-a")
if err := publishDWSManagedSkillDir(filepath.Join(src, "dingtalk-a"), dest); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
t.Fatalf("published Skill missing: %v", err)
}
})
t.Run("staging cleanup failure", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := t.TempDir()
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return failure })
cleanupErr := errors.New("staging cleanup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
_, _, err := stageSkillSetupTarget(
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
skillSetupTargetPlan{Destination: dest},
)
if !errors.Is(err, failure) || !errors.Is(err, cleanupErr) {
t.Fatalf("staging cleanup error = %v", err)
}
})
t.Run("staging directory failure", func(t *testing.T) {
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
dest := t.TempDir()
originalMkdirAll := skillSetupMkdirAll
testseam.Swap(t, &skillSetupMkdirAll, func(path string, mode os.FileMode) error {
if filepath.Base(path) == "dingtalk-a" && strings.HasPrefix(filepath.Base(filepath.Dir(path)), ".dws-setup-set-") {
return failure
}
return originalMkdirAll(path, mode)
})
_, _, err := stageSkillSetupTarget(
&skillSetupPlan{Mode: skillSetupModeMulti, Source: src, MultiSkillNames: []string{"dingtalk-a"}},
skillSetupTargetPlan{Destination: dest},
)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill staging 目录失败") {
t.Fatalf("staging directory error = %v", err)
}
})
t.Run("restore failure aggregation", func(t *testing.T) {
t.Run("remove published", func(t *testing.T) {
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure })
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
t.Fatalf("remove published error = %v", err)
}
})
t.Run("original still exists", func(t *testing.T) {
original := t.TempDir()
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: original, backup: "backup"}})
if err == nil || !strings.Contains(err.Error(), "恢复目标仍存在") {
t.Fatalf("existing restore target error = %v", err)
}
})
t.Run("stat", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") {
t.Fatalf("restore stat error = %v", err)
}
})
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") {
t.Fatalf("restore mkdir error = %v", err)
}
})
t.Run("rename", func(t *testing.T) {
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") {
t.Fatalf("restore rename error = %v", err)
}
})
})
t.Run("backup rollback failure", func(t *testing.T) {
calls := 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
calls++
if calls == 1 {
return "backup", nil
}
return "", failure
})
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
restoreErr := errors.New("restore failure")
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
_, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard)
if !errors.Is(err, failure) || !errors.Is(err, restoreErr) {
t.Fatalf("backup rollback error = %v", err)
}
})
t.Run("publish rollback failure", func(t *testing.T) {
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
err := publishSkillSetupTarget(
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
[]skillSetupBackedUpDir{{original: "dest", backup: "backup"}},
)
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "回滚不完整") {
t.Fatalf("publish rollback error = %v", err)
}
})
t.Run("execute cleanup errors", func(t *testing.T) {
newPlan := func(t *testing.T) *skillSetupPlan {
t.Helper()
src := writeMultiSkillSource(t, []string{"dingtalk-a"})
return &skillSetupPlan{
Mode: skillSetupModeMulti,
Source: src,
MultiSkillNames: []string{"dingtalk-a"},
Targets: []skillSetupTargetPlan{{Destination: t.TempDir()}},
}
}
t.Run("after backup failure", func(t *testing.T) {
plan := newPlan(t)
plan.Targets[0].Backups = []skillSetupBackup{{Path: filepath.Join(plan.Targets[0].Destination, "old")}}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
cleanupErr := errors.New("cleanup after backup failure")
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupErr })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("backup cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
}
})
t.Run("after publish failure", func(t *testing.T) {
plan := newPlan(t)
originalRename := skillSetupPublishRename
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
return failure
}
return originalRename(oldPath, newPath)
})
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after publish failure")
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
return cleanupErr
}
return originalRemoveAll(path)
})
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("publish cleanup = skipped %d, err %v, stderr %q", skipped, err, stderr.String())
}
})
t.Run("after success", func(t *testing.T) {
plan := newPlan(t)
originalRemoveAll := skillSetupRemoveAll
cleanupErr := errors.New("cleanup after success")
testseam.Swap(t, &skillSetupRemoveAll, func(path string) error {
if strings.HasPrefix(filepath.Base(path), ".dws-setup-set-") {
return cleanupErr
}
return originalRemoveAll(path)
})
var stderr bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, &stderr)
if err != nil || installed != 1 || skipped != 0 || !strings.Contains(stderr.String(), cleanupErr.Error()) {
t.Fatalf("success cleanup = installed %d, skipped %d, err %v, stderr %q", installed, skipped, err, stderr.String())
}
})
})
}
// TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips verifies both
// install paths skip (never destroy) every target when $HOME cannot be
// resolved for the pre-refresh backup.
func TestCrossPlatformCoverageSkillSetupInstallHomeFailureSkips(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if err := os.MkdirAll(monoDest, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(monoDest, "SKILL.md"), []byte("# old"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{monoDest}, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("mono install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过刷新") {
t.Fatalf("expected HOME skip warning, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
t.Fatalf("existing mono dir must be preserved: %v", err)
}
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-a"})
multiDest := filepath.Join(t.TempDir(), ".claude", "skills")
if err := os.MkdirAll(filepath.Join(multiDest, "dingtalk-a"), 0o755); err != nil {
t.Fatal(err)
}
out.Reset()
errOut.Reset()
installed, skipped, err = installMultiSkillToHomes(multiSrc, []string{"dingtalk-a"}, []string{multiDest}, &out, &errOut, true)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("multi install = (%d, %d, %v), want (0, 1, nil)", installed, skipped, err)
}
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过整个 Agent 目标") {
t.Fatalf("expected multi HOME skip warning, got %q", errOut.String())
}
if _, err := os.Stat(filepath.Join(multiDest, "dingtalk-a")); err != nil {
t.Fatalf("existing sub skill must be preserved: %v", err)
}
}
// TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges covers
// removeStaleMultiSkills and its preview companion staleMultiSkillVictims:
// scan failures, the HOME failure, backup failures, and the success path.
func TestCrossPlatformCoverageSkillSetupRemoveStaleMultiSkillsEdges(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".cursor", "skills")
keep := []string{"dingtalk-chat"}
entries := map[string]bool{ // dir entries; README below is a plain file
"dingtalk-chat": true, // kept (in bundle)
"dingtalk-stale": true, // stale product skill
"dws-shared": true, // legacy shared name is stale too
"other-skill": true, // non-DWS, must survive
}
for name := range entries {
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
if err := os.WriteFile(filepath.Join(dest, "README"), []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
// Non-ENOENT scan failure warns; ENOENT is silent.
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("scan boom") })
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "过期 skill 扫描失败") {
t.Fatalf("expected scan warning, got %q", errOut.String())
}
errOut.Reset()
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrNotExist })
removeStaleMultiSkills(dest, keep, &out, &errOut)
if errOut.Len() != 0 {
t.Fatalf("ENOENT scan must be silent, got %q", errOut.String())
}
testseam.Swap(t, &skillSetupReadDir, os.ReadDir)
// The preview companion sees the same victims and skips files/kept/non-DWS.
victims := staleMultiSkillVictims(dest, keep)
wantVictims := []string{filepath.Join(dest, "dingtalk-stale"), filepath.Join(dest, "dws-shared")}
if len(victims) != len(wantVictims) {
t.Fatalf("staleMultiSkillVictims = %v, want %v", victims, wantVictims)
}
// HOME failure keeps every stale directory with a warning.
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home boom") })
errOut.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "无法解析 HOME,跳过删除") {
t.Fatalf("expected HOME warning, got %q", errOut.String())
}
for name := range entries {
if _, err := os.Stat(filepath.Join(dest, name)); err != nil {
t.Fatalf("entry %s must survive the HOME failure: %v", name, err)
}
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
// Backup failure keeps the stale directory with a warning.
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup boom") })
errOut.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if !strings.Contains(errOut.String(), "过期 skill 清理失败(保留原目录") {
t.Fatalf("expected backup failure warning, got %q", errOut.String())
}
for _, stale := range wantVictims {
if _, err := os.Stat(stale); err != nil {
t.Fatalf("stale dir must survive the backup failure: %v", err)
}
}
// Success: both stale dirs are backed up and reported; the rest survives.
testseam.Swap(t, &skillSetupBackupAndRemove, func(_, dir string) (string, error) { return filepath.Join(t.TempDir(), "backup"), nil })
out.Reset()
removeStaleMultiSkills(dest, keep, &out, &errOut)
if count := strings.Count(out.String(), "已备份并清理过期 skill"); count != len(wantVictims) {
t.Fatalf("expected %d stale cleanup lines, got %d (out=%q)", len(wantVictims), count, out.String())
}
if _, err := os.Stat(filepath.Join(dest, "other-skill")); err != nil {
t.Fatalf("non-DWS dir must survive: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-chat")); err != nil {
t.Fatalf("bundle skill must survive: %v", err)
}
}
+39 -31
View File
@@ -47,18 +47,16 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
oldTargets := skillSetupResolveTargets
oldList := skillSetupListMulti
oldFilter := skillSetupFilterMulti
oldConfirm := skillSetupConfirm
oldMono := skillSetupInstallMono
oldMulti := skillSetupInstallMulti
oldConfirm := skillSetupConfirmPlan
oldExecute := skillSetupExecutePlan
t.Cleanup(func() {
skillSetupResolveMode = oldMode
skillSetupResolveSource = oldSource
skillSetupResolveTargets = oldTargets
skillSetupListMulti = oldList
skillSetupFilterMulti = oldFilter
skillSetupConfirm = oldConfirm
skillSetupInstallMono = oldMono
skillSetupInstallMulti = oldMulti
skillSetupConfirmPlan = oldConfirm
skillSetupExecutePlan = oldExecute
})
fail := errors.New("failure")
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
@@ -83,17 +81,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
_ = cmd.Root().PersistentFlags().Set("dry-run", "true")
cmd.Flags().Bool("dry-run", true, "")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("confirmation failure should propagate")
}
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
@@ -105,17 +103,17 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
t.Fatal("unknown resolved mode should fail")
}
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("mono install failure should propagate")
}
skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil }
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
skillSetupExecutePlan = func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("multi install failure should propagate")
@@ -123,6 +121,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
}
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
setTestHome(t, t.TempDir())
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
home := filepath.Join(t.TempDir(), "skills")
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
@@ -143,6 +142,7 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
[]string{home},
&out,
&errOut,
true,
)
if err != nil || installed != 2 || skipped != 0 {
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
@@ -156,7 +156,7 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
t.Fatalf("unrelated custom skill changed: %v", err)
}
if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
if !strings.Contains(out.String(), "已备份并清理过期 skill") {
t.Fatalf("legacy cleanup was not reported: %s", out.String())
}
@@ -178,12 +178,17 @@ func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *
[]string{failureHome},
&failureOut,
&failureErr,
true,
)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
}
if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
t.Fatalf("failed replacement removed legacy shared skill: %v", err)
got, readErr := os.ReadFile(filepath.Join(failureLegacy, "SKILL.md"))
if readErr != nil || string(got) != "legacy\n" {
t.Fatalf("failed replacement changed the live legacy copy: %q, err=%v", got, readErr)
}
if !strings.Contains(failureErr.String(), "Skill staging 失败,保留原集合") {
t.Fatalf("failed replacement did not report preserved live set: %s", failureErr.String())
}
})
}
@@ -226,6 +231,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
oldBackup := skillSetupBackupAndRemove
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
@@ -234,6 +240,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
skillSetupBackupAndRemove = oldBackup
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
@@ -246,7 +253,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
t.Fatal("interactive mode failure should propagate")
}
skillSetupRunForm = func(*huh.Form) error { return nil }
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMulti {
t.Fatalf("interactive default choice = %q, %v", got, err)
}
@@ -305,23 +312,24 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
var out, errOut bytes.Buffer
skillSetupRunForm = func(*huh.Form) error { return fail }
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}, false); err == nil {
t.Fatal("confirmation form failure should propagate")
}
skillSetupRunForm = func(*huh.Form) error { return nil }
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil, false); err != nil || ok {
t.Fatalf("EOF confirmation = %v, %v", ok, err)
}
skillSetupRemoveAll = func(string) error { return fail }
skillSetupUserHomeDir = func() (string, error) { return t.TempDir(), nil }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
skillSetupCopyDir = func(string, string) error { return fail }
skillSetupRemoveAll = func(string) error { return fail }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
_, skipped, _ := installSkillToHomes("src", []string{"a"}, &out, &errOut)
if skipped != 1 {
t.Fatal("mono remove failure not skipped")
t.Fatal("mono backup failure not skipped")
}
skillSetupRemoveAll = func(string) error { return nil }
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
_, skipped, _ = installSkillToHomes("src", []string{"b"}, &out, &errOut)
if skipped != 1 {
@@ -334,18 +342,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
}
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 2 {
t.Fatal("multi mkdir failure count mismatch")
}
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
skillSetupRemoveAll = func(string) error { return fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", fail }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 1 {
t.Fatal("multi remove failure count mismatch")
t.Fatal("multi backup failure count mismatch")
}
skillSetupRemoveAll = func(string) error { return nil }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut)
skillSetupBackupAndRemove = func(string, string) (string, error) { return "", nil }
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
if skipped != 1 {
t.Fatal("multi copy failure count mismatch")
}
@@ -516,15 +524,15 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing
})
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
return 0, 0, fail
})
migration := filepath.Join(t.TempDir(), "migration")
ordinary := filepath.Join(t.TempDir(), "ordinary")
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("ordinary install failure = %v", err)
}
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
t.Fatalf("prerequisite install failure = %v", err)
}
})
+345
View File
@@ -0,0 +1,345 @@
package app
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/charmbracelet/huh"
)
func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".claude", "skills")
source := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
for _, name := range []string{"dws", "dingtalk-a", "dingtalk-stale"} {
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return source, func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{dest}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupInteractive, func() bool { return true })
testseam.Swap(t, &skillSetupRunForm, func(*huh.Form) error { return nil })
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
wantBackups := []string{
filepath.Join(dest, "dingtalk-a"),
filepath.Join(dest, "dingtalk-stale"),
filepath.Join(dest, "dws"),
}
// Dry-run must disclose every exact path and perform no backup or copy.
backupCalls, copyCalls := []string{}, 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
backupCalls = append(backupCalls, path)
return "backup", nil
})
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(src, dest)
})
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var dryRunOut bytes.Buffer
dryRunCmd.SetOut(&dryRunOut)
if err := dryRunCmd.Root().PersistentFlags().Set("dry-run", "true"); err != nil {
t.Fatal(err)
}
if err := dryRunCmd.RunE(dryRunCmd, nil); err != nil {
t.Fatal(err)
}
for _, path := range wantBackups {
if strings.Count(dryRunOut.String(), path) != 1 {
t.Fatalf("dry-run path %s count != 1:\n%s", path, dryRunOut.String())
}
}
if len(backupCalls) != 0 || copyCalls != 0 {
t.Fatalf("dry-run mutated backup=%v copy=%d", backupCalls, copyCalls)
}
// The real confirmation renderer discloses the same paths. Its default
// negative answer must leave backup and copy at zero calls.
declineCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var declineOut bytes.Buffer
declineCmd.SetOut(&declineOut)
if err := declineCmd.RunE(declineCmd, nil); err != nil {
t.Fatal(err)
}
for _, path := range wantBackups {
if strings.Count(declineOut.String(), path) != 1 {
t.Fatalf("confirmation path %s count != 1:\n%s", path, declineOut.String())
}
}
if len(backupCalls) != 0 || copyCalls != 0 {
t.Fatalf("declined confirmation mutated backup=%v copy=%d", backupCalls, copyCalls)
}
// Explicit confirmation executes exactly the paths rendered from the plan.
var confirmedPlan *skillSetupPlan
testseam.Swap(t, &skillSetupConfirmPlan, func(out io.Writer, plan *skillSetupPlan) (bool, error) {
confirmedPlan = plan
renderSkillSetupPlan(out, plan)
return true, nil
})
confirmCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
if err := confirmCmd.RunE(confirmCmd, nil); err != nil {
t.Fatal(err)
}
var planned []string
for _, target := range confirmedPlan.Targets {
for _, backup := range target.Backups {
planned = append(planned, backup.Path)
}
}
if !reflect.DeepEqual(planned, wantBackups) || !reflect.DeepEqual(backupCalls, wantBackups) {
t.Fatalf("planned=%v executed=%v want=%v", planned, backupCalls, wantBackups)
}
if copyCalls != 2 {
t.Fatalf("copy calls = %d, want 2", copyCalls)
}
// A filtered multi plan replaces only selected same-name skills and leaves
// unselected siblings out of the backup set.
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
if err != nil {
t.Fatal(err)
}
var filteredPaths []string
for _, backup := range filtered.Targets[0].Backups {
filteredPaths = append(filteredPaths, backup.Path)
}
if !reflect.DeepEqual(filteredPaths, []string{filepath.Join(dest, "dingtalk-a"), filepath.Join(dest, "dws")}) {
t.Fatalf("filtered backups = %v", filteredPaths)
}
}
func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".agents", "skills", "dws")
if err := os.MkdirAll(dest, 0o755); err != nil {
t.Fatal(err)
}
plan, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{dest}, nil, false)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 1 || len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != dest || plan.Targets[0].Backups[0].Reason != skillSetupBackupReplace {
t.Fatalf("mono plan = %#v", plan)
}
}
func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
// "dws" is synthetic but makes the mutual-exclusion target and selected
// same-name target overlap, pinning path deduplication in the plan itself.
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dws"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != filepath.Join(dest, "dws") {
t.Fatalf("deduplicated plan = %#v", plan)
}
failure := errors.New("scan denied")
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if err := os.MkdirAll(filepath.Dir(monoDest), 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") {
t.Fatalf("replacement stat error = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") {
t.Fatalf("stale scan error = %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupInstallWrappersFailOnPlanErrors(t *testing.T) {
t.Run("multi mono-leftover stat failure", func(t *testing.T) {
failure := errors.New("stat denied")
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
return nil, failure
})
installed, skipped, err := installMultiSkillToHomes(
"source",
[]string{"dingtalk-a"},
[]string{filepath.Join(t.TempDir(), "skills")},
io.Discard,
io.Discard,
true,
)
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
t.Fatalf("installMultiSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
}
})
t.Run("mono multi-leftover scan failure", func(t *testing.T) {
failure := errors.New("scan denied")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) {
return nil, failure
})
installed, skipped, err := installSkillToHomes(
"source",
[]string{filepath.Join(t.TempDir(), "skills", "dws")},
io.Discard,
io.Discard,
)
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
t.Fatalf("installSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
}
})
}
func TestCrossPlatformCoverageSkillSetupMergedEventPlanEdges(t *testing.T) {
t.Run("legacy shared stat failure", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
failure := errors.New("legacy stat denied")
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(dest, legacySharedSkill) {
return nil, failure
}
return nil, os.ErrNotExist
})
_, err := buildSkillSetupPlan(
skillSetupModeMulti,
"source",
[]string{dest},
[]string{multiSharedSkill},
true,
)
if !errors.Is(err, failure) {
t.Fatalf("legacy shared stat error = %v, want %v", err, failure)
}
})
t.Run("migration plan retains unrelated backups", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
unrelated := filepath.Join(dest, "dws")
plan := &skillSetupPlan{Targets: []skillSetupTargetPlan{
{
Destination: dest,
Backups: []skillSetupBackup{
{Path: filepath.Join(dest, multiEventSkill)},
{Path: filepath.Join(dest, multiMiscSkill)},
{Path: unrelated},
},
},
}}
configureEventMiscMigrationPlan(plan, []string{dest}, true)
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != unrelated {
t.Fatalf("migration backups = %#v, want only %s", plan.Targets[0].Backups, unrelated)
}
})
t.Run("no migration targets delegates filtered install", func(t *testing.T) {
called := false
testseam.Swap(t, &skillSetupInstallMulti, func(_ string, _ []string, _ []string, _ io.Writer, _ io.Writer, filtered bool) (int, int, error) {
called = true
if !filtered {
t.Fatal("filtered flag was not forwarded")
}
return 1, 2, nil
})
installed, skipped, err := installMultiSkillsWithEventMigration(
"source", []string{"dingtalk-a"}, []string{"dest"}, nil, true, io.Discard, io.Discard,
)
if err != nil || installed != 1 || skipped != 2 || !called {
t.Fatalf("delegated install = (%d, %d, %v), called=%v", installed, skipped, err, called)
}
})
t.Run("migration cleanup scan failure", func(t *testing.T) {
failure := errors.New("cleanup stat denied")
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
return nil, failure
})
dest := filepath.Join(t.TempDir(), "skills")
installed, skipped, err := installMultiSkillsWithEventMigration(
"source",
[]string{multiEventSkill, multiSharedSkill},
[]string{dest},
[]string{dest},
true,
io.Discard,
io.Discard,
)
if installed != 0 || skipped != 2 || !errors.Is(err, failure) {
t.Fatalf("cleanup scan failure = (%d, %d, %v), want (0, 2, %v)", installed, skipped, err, failure)
}
})
}
func TestCrossPlatformCoverageSkillSetupEmptyCleanupPlansAreNoOps(t *testing.T) {
dest := t.TempDir()
var out, errOut bytes.Buffer
if err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut); err != nil {
t.Fatal(err)
}
if err := removeStaleMultiSkills(dest, []string{"dingtalk-a"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if out.Len() != 0 || errOut.Len() != 0 {
t.Fatalf("empty cleanup output = %q / %q", out.String(), errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupSameNameBackupFailureSkipsTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
plan := &skillSetupPlan{
Mode: skillSetupModeMulti,
Source: "source",
MultiSkillNames: []string{"dingtalk-a"},
Targets: []skillSetupTargetPlan{{
Destination: dest,
Backups: []skillSetupBackup{{
Path: filepath.Join(dest, "dingtalk-a"),
Reason: skillSetupBackupReplace,
}},
}},
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
return "", errors.New("backup denied")
})
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
var out, errOut bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 || copyCalls != 1 {
t.Fatalf("same-name failure = (%d, %d, %v), copy=%d", installed, skipped, err, copyCalls)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("same-name warning = %q", errOut.String())
}
}
+190
View File
@@ -0,0 +1,190 @@
package app
import (
"errors"
"io"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillSetupPersistsOfficialSnapshot(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) {
return &skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-existing"}}}, true, nil
})
testseam.Swap(t, &skillSetupExecutePlan, func(plan *skillSetupPlan, _ io.Writer, _ io.Writer) (int, int, error) {
if plan.Mode == skillSetupModeMono {
return 1, 0, nil
}
return 2, 0, nil
})
testseam.Swap(t, &skillSetupNow, func() time.Time {
return time.Date(2026, 8, 10, 1, 2, 3, 0, time.UTC)
})
var saved skillstate.State
testseam.Swap(t, &skillSetupWriteState, func(_ string, state skillstate.State) error {
saved = state
return nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(saved.OfficialSkills, []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}) ||
!reflect.DeepEqual(saved.UpdatedSkills, []string{"dingtalk-shared", "dingtalk-a"}) ||
!reflect.DeepEqual(skillprovenance.Names(saved.ManagedSkills), map[string]bool{"dingtalk-a": true, "dingtalk-existing": true, "dingtalk-shared": true}) {
t.Fatalf("saved = %#v", saved)
}
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return errors.New("denied") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "信息快照失败") {
t.Fatalf("write-state error = %v", err)
}
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return errors.New("denied") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "清理 multi") {
t.Fatalf("remove-state error = %v", err)
}
testseam.Swap(t, &skillSetupRemoveState, func(string) error { return nil })
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("no home") })
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true)
if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "无法解析 HOME") {
t.Fatalf("home error = %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupFilteredUnreadableStateStopsBeforeInstall(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
stateErr := errors.New("state denied")
testseam.Swap(t, &skillSetupReadState, func(string) (*skillstate.State, bool, error) { return nil, false, stateErr })
executed := 0
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
executed++
return 1, 0, nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
err := cmd.RunE(cmd, nil)
if !errors.Is(err, stateErr) || executed != 0 {
t.Fatalf("filtered setup = err %v, executed %d", err, executed)
}
}
func TestCrossPlatformCoverageSkillSetupProvenancePreflightFailures(t *testing.T) {
for _, tc := range []struct {
name string
filtered bool
buildError error
homeError error
}{
{name: "digest", buildError: errors.New("digest denied")},
{name: "filtered home", filtered: true, homeError: errors.New("home denied")},
} {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{filepath.Join(home, "skills")}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) { return []string{"dingtalk-a", "dingtalk-shared"}, nil })
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
if tc.buildError != nil {
return skillprovenance.Record{}, tc.buildError
}
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
if tc.homeError != nil {
return "", tc.homeError
}
return home, nil
})
executed := 0
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
executed++
return 1, 0, nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if tc.filtered {
if err := cmd.Flags().Set("skill", "a"); err != nil {
t.Fatal(err)
}
}
err := cmd.RunE(cmd, nil)
if err == nil || executed != 0 {
t.Fatalf("preflight = err %v, executed %d", err, executed)
}
})
}
}
func TestCrossPlatformCoverageSkillSetupPartialInstallDoesNotWriteState(t *testing.T) {
home := t.TempDir()
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return "source", func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) {
return []string{filepath.Join(home, "skills")}, nil
})
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupExecutePlan, func(*skillSetupPlan, io.Writer, io.Writer) (int, int, error) {
return 2, 1, nil
})
writes := 0
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error {
writes++
return nil
})
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
err := cmd.RunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "Skill 安装不完整") || !strings.Contains(err.Error(), "skipped=1") {
t.Fatalf("partial setup error = %v", err)
}
if writes != 0 {
t.Fatalf("partial setup wrote %d complete state snapshot(s)", writes)
}
}
+380 -24
View File
@@ -9,6 +9,10 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestSkillSetupCommandRegistered(t *testing.T) {
@@ -25,6 +29,189 @@ func TestSkillSetupCommandRegistered(t *testing.T) {
}
}
// TestCrossPlatformCoverageSkillSetupExamplesDoNotBypassConfirmation guards
// the P1 review finding: copyable examples must preview or enter the normal
// confirmation path, never carry the scripting-only confirmation bypass.
func TestCrossPlatformCoverageSkillSetupExamplesDoNotBypassConfirmation(t *testing.T) {
cmd := newSkillSetupCommand()
var examples []string
for _, line := range strings.Split(cmd.Example, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
examples = append(examples, line)
if strings.Contains(line, "--yes") {
t.Fatalf("skill setup example bypasses confirmation: %q", line)
}
}
if len(examples) != 2 || !strings.Contains(examples[0], "--dry-run") || strings.Contains(examples[1], "--dry-run") {
t.Fatalf("examples must show preview then interactive confirmation: %v", examples)
}
}
func TestCrossPlatformCoverageSkillSetupHelpDescribesFullUpgradeRefresh(t *testing.T) {
help := newSkillSetupCommand().Long
for _, want := range []string{"每次 dws upgrade", "全量覆盖预制 skill", "本地删除", "会在升级时恢复"} {
if !strings.Contains(help, want) {
t.Fatalf("skill setup help missing full-refresh contract %q:\n%s", want, help)
}
}
for _, stale := range []string{"跳过本地已删除", "--force 恢复全量"} {
if strings.Contains(help, stale) {
t.Fatalf("skill setup help still advertises retired incremental behavior %q:\n%s", stale, help)
}
}
}
// TestCrossPlatformCoverageSkillSetupDeclinedConfirmationNeverRemoves verifies
// the destructive half of the setup contract: when the user declines the
// confirmation prompt, nothing is installed and nothing is removed (neither
// the opposite-mode leftovers nor stale skills). Confirming must then run the
// exact cleanup previewed earlier: leftovers are backed up to
// ~/.dws/skill-backups/ before they disappear.
func TestCrossPlatformCoverageSkillSetupDeclinedConfirmationNeverRemoves(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-aitable", "dingtalk-calendar"})
agentHome := filepath.Join(home, ".claude", "skills")
// Opposite-mode leftover (mono dws/) plus a stale multi skill the full
// install would clean; both must survive a declined confirmation.
for _, leftover := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
if err := os.MkdirAll(leftover, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(leftover, "SKILL.md"), []byte("keep-me"), 0o644); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
oldConfirm := skillSetupConfirmPlan
t.Cleanup(func() { skillSetupConfirmPlan = oldConfirm })
// Declined confirmation: nothing may change on disk.
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return false, nil }
cmd := newSkillSetupCommand()
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--mode", "multi", "--target", "claude", "--source", multiSrc})
if err := cmd.Execute(); err != nil {
t.Fatalf("declined setup should succeed as a no-op: %v (%s)", err, errOut.String())
}
if !strings.Contains(out.String(), "已取消") {
t.Fatalf("expected cancellation notice, got %q", out.String())
}
for _, survivor := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
if _, err := os.Stat(filepath.Join(survivor, "SKILL.md")); err != nil {
t.Fatalf("declined confirmation removed %s: %v", survivor, err)
}
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable")); !os.IsNotExist(err) {
t.Fatalf("declined confirmation must not install either: %v", err)
}
// Confirmed: the previewed victims are backed up + removed, bundle skills land.
skillSetupConfirmPlan = func(io.Writer, *skillSetupPlan) (bool, error) { return true, nil }
out.Reset()
errOut.Reset()
cmd = newSkillSetupCommand()
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--mode", "multi", "--target", "claude", "--source", multiSrc})
if err := cmd.Execute(); err != nil {
t.Fatalf("confirmed setup failed: %v (%s)", err, errOut.String())
}
for _, gone := range []string{filepath.Join(agentHome, "dws"), filepath.Join(agentHome, "dingtalk-stale")} {
if _, err := os.Stat(gone); !os.IsNotExist(err) {
t.Fatalf("confirmed setup should remove %s (stat err=%v)", gone, err)
}
}
for _, n := range []string{"dingtalk-aitable", "dingtalk-calendar"} {
if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
t.Fatalf("confirmed setup missing %s: %v", n, err)
}
}
// Every removal went through the reversible backup path, not a hard delete.
backupRoot := filepath.Join(home, ".dws", "skill-backups")
entries, err := os.ReadDir(backupRoot)
if err != nil || len(entries) == 0 {
t.Fatalf("confirmed setup must preserve victims under %s (entries=%v, err=%v)", backupRoot, entries, err)
}
if !strings.Contains(out.String(), "已备份并清理对面模式残留") || !strings.Contains(out.String(), "已备份并清理过期 skill") {
t.Fatalf("expected backup-and-remove log lines, got %q", out.String())
}
}
// TestCrossPlatformCoverageSkillSetupNonInteractiveRequiresYes pins the real
// non-TTY safety boundary: an explicit mode alone is not consent to move
// directories. The same command with --yes performs the previewed backup and
// installation.
func TestCrossPlatformCoverageSkillSetupNonInteractiveRequiresYes(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
oldInteractive := skillSetupInteractive
skillSetupInteractive = func() bool { return false }
t.Cleanup(func() { skillSetupInteractive = oldInteractive })
multiSrc := writeMultiSkillSource(t, []string{"dingtalk-aitable"})
agentHome := filepath.Join(home, ".claude", "skills")
mono := filepath.Join(agentHome, "dws")
if err := os.MkdirAll(mono, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("keep-me"), 0o644); err != nil {
t.Fatal(err)
}
run := func(extra ...string) error {
cmd := newSkillSetupCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
args := []string{"--mode", "multi", "--target", "claude", "--source", multiSrc}
cmd.SetArgs(append(args, extra...))
return cmd.Execute()
}
if err := run(); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("non-interactive setup error = %v, want explicit --yes requirement", err)
}
if data, err := os.ReadFile(filepath.Join(mono, "SKILL.md")); err != nil || string(data) != "keep-me" {
t.Fatalf("unconfirmed setup changed mono (data=%q, err=%v)", string(data), err)
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable")); !os.IsNotExist(err) {
t.Fatalf("unconfirmed setup installed multi, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups")); !os.IsNotExist(err) {
t.Fatalf("unconfirmed setup created backup state, stat err=%v", err)
}
if err := run("--yes"); err != nil {
t.Fatalf("explicitly confirmed setup failed: %v", err)
}
if _, err := os.Stat(mono); !os.IsNotExist(err) {
t.Fatalf("confirmed setup kept mono, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("confirmed setup did not install multi: %v", err)
}
backupFound := false
_ = filepath.Walk(filepath.Join(home, ".dws", "skill-backups"), func(path string, info os.FileInfo, walkErr error) error {
if walkErr == nil && info != nil && !info.IsDir() && info.Name() == "SKILL.md" {
if data, readErr := os.ReadFile(path); readErr == nil && string(data) == "keep-me" {
backupFound = true
}
}
return nil
})
if !backupFound {
t.Fatal("confirmed setup did not back up mono")
}
}
func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
got, err := resolveSkillSetupMode("mono", true, &bytes.Buffer{})
if err != nil || got != skillSetupModeMono {
@@ -39,14 +226,14 @@ func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
}
}
func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
func TestResolveSkillSetupModeNonInteractiveDefaultsMulti(t *testing.T) {
var buf bytes.Buffer
got, err := resolveSkillSetupMode("", true, &buf)
if err != nil || got != skillSetupModeMono {
t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err)
if err != nil || got != skillSetupModeMulti {
t.Fatalf("non-interactive empty mode should default to multi, got %q err=%v", got, err)
}
if !strings.Contains(buf.String(), "mono") {
t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
if !strings.Contains(buf.String(), "multi") {
t.Fatalf("expected output to mention multi fallback, got %q", buf.String())
}
}
@@ -143,7 +330,7 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
}
}
func TestInstallSkillToHomesEndToEnd(t *testing.T) {
func TestCrossPlatformCoverageInstallSkillToHomesEndToEnd(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
t.Fatal(err)
@@ -205,7 +392,7 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
return root
}
func TestInstallMultiSkillToHomes(t *testing.T) {
func TestCrossPlatformCoverageInstallMultiSkillToHomes(t *testing.T) {
names := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"}
src := writeMultiSkillSource(t, names)
@@ -221,7 +408,7 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr, false)
if err != nil {
t.Fatalf("installMultiSkillToHomes err: %v", err)
}
@@ -245,12 +432,14 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
}
}
func TestSkillSetupMutualExclusion(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupMutualExclusion(t *testing.T) {
names := []string{"dingtalk-aitable", "dingtalk-calendar"}
src := writeMultiSkillSource(t, names)
// Simulate a pre-existing mono install under <agent-home>/dws/
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
homeRoot := t.TempDir()
setTestHome(t, homeRoot)
agentHome := filepath.Join(homeRoot, ".claude", "skills")
monoLeftover := filepath.Join(agentHome, "dws")
if err := os.MkdirAll(filepath.Join(monoLeftover, "references"), 0o755); err != nil {
t.Fatal(err)
@@ -265,13 +454,16 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
}
// Confirm mutualExclusionVictims sees the leftover
victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
victims, vErr := mutualExclusionVictims(agentHome, skillSetupModeMulti)
if vErr != nil {
t.Fatalf("mutualExclusionVictims err: %v", vErr)
}
if len(victims) != 1 || victims[0] != monoLeftover {
t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims)
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr)
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
@@ -290,7 +482,7 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
}
}
// the cleanup line should appear in stdout (best-effort observability)
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
if !strings.Contains(stdout.String(), "已备份并清理对面模式残留") {
t.Fatalf("expected cleanup log line, got stdout=%q", stdout.String())
}
@@ -318,11 +510,48 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
t.Fatalf("mono SKILL.md missing: %v", err)
}
if !strings.Contains(stdout.String(), "已清理对面模式残留") {
if !strings.Contains(stdout.String(), "已备份并清理对面模式残留") {
t.Fatalf("expected cleanup log line on mono install, got stdout=%q", stdout.String())
}
}
func TestCrossPlatformCoverageSkillSetupMonoPreservesUnregisteredDingtalkSkill(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
base := filepath.Join(home, ".agents", "skills")
managed := filepath.Join(base, "dingtalk-managed-old")
legacyOfficial := filepath.Join(base, "dingtalk-aitable")
custom := filepath.Join(base, "dingtalk-custom")
for _, dir := range []string{managed, legacyOfficial, custom} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(filepath.Base(dir)), 0o644); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, filepath.Base(managed))
monoSrc := t.TempDir()
if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("mono"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
installed, skipped, err := installSkillToHomes(monoSrc, []string{filepath.Join(base, "dws")}, &out, &errOut)
if err != nil || installed != 1 || skipped != 0 {
t.Fatalf("mono install = (%d, %d, %v), stderr=%s", installed, skipped, err, errOut.String())
}
if _, err := os.Stat(managed); !os.IsNotExist(err) {
t.Fatalf("centrally managed DWS multi Skill must be removed during mono switch: %v", err)
}
if _, err := os.Stat(legacyOfficial); !os.IsNotExist(err) {
t.Fatalf("pre-state official multi Skill must be removed during mono switch: %v", err)
}
if got, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(got) != "dingtalk-custom" {
t.Fatalf("unregistered market/user dingtalk-* Skill changed: data=%q err=%v", got, err)
}
}
// TestSkillSourceCandidatesIncludesUserCache verifies that the user-level
// cache populated by install.sh / install.ps1 / npm install.js is part of the
// fallback candidate list, so `dws skill setup` can find a source on a fresh
@@ -464,10 +693,11 @@ func TestFilterMultiSkillNames(t *testing.T) {
// TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of
// `dws skill setup --mode multi -s aitable`: installing a subset must NOT
// touch already-installed dingtalk-* siblings (additive semantics).
func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
src := writeMultiSkillSource(t, []string{
"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
})
setTestHome(t, t.TempDir())
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
// Pretend the user already installed two dingtalk-* skills earlier.
@@ -493,7 +723,7 @@ func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr, true)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
@@ -561,6 +791,135 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
}
}
// TestCrossPlatformCoverageSkillSetupMultiFullInstallCleansStale verifies that a full (unfiltered)
// multi install removes stale dingtalk-* / dws-shared directories that are no
// longer part of the bundle, matching install.sh / install.js / upgrade paths.
// The additive counterpart (filtered install) is covered by
// TestCrossPlatformCoverageSkillSetupMultiAdditivePreservesSiblings.
func TestCrossPlatformCoverageSkillSetupMultiFullInstallCleansStale(t *testing.T) {
names := []string{"dingtalk-aitable"}
src := writeMultiSkillSource(t, names)
homeRoot := t.TempDir()
setTestHome(t, homeRoot)
agentHome := filepath.Join(homeRoot, ".claude", "skills")
// Stale multi skills absent from the bundle, plus a non-DWS dir that must survive.
for _, n := range []string{"dingtalk-stale", "dws-shared", "other-skill"} {
dir := filepath.Join(agentHome, n)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
custom := filepath.Join(agentHome, "dingtalk-custom")
if err := os.MkdirAll(custom, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
if err != nil {
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
}
if installed != 1 || skipped != 0 {
t.Fatalf("expected installed=1 skipped=0, got %d/%d", installed, skipped)
}
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Errorf("missing installed skill: %v", err)
}
for _, stale := range []string{"dingtalk-stale", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentHome, stale)); !os.IsNotExist(err) {
t.Errorf("stale %q should be removed by a full multi install (stat err=%v)", stale, err)
}
}
body, err := os.ReadFile(filepath.Join(agentHome, "other-skill", "SKILL.md"))
if err != nil || !strings.HasPrefix(string(body), "OLD ") {
t.Errorf("non-DWS dir must be preserved (body=%q, err=%v)", string(body), err)
}
if body, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(body) != "market skill" {
t.Errorf("unregistered market/user dingtalk-* dir must survive (body=%q, err=%v)", string(body), err)
}
if !strings.Contains(stdout.String(), "已备份并清理过期 skill") {
t.Errorf("expected stale cleanup log line, got stdout=%q", stdout.String())
}
}
// TestSkillSetupMutualExclusionScanWarning verifies that a victim-scan failure
// surfaces as an errOut warning instead of silently skipping cleanup.
func TestCrossPlatformCoverageSkillSetupMutualExclusionScanWarning(t *testing.T) {
oldReadDir := skillSetupReadDir
t.Cleanup(func() { skillSetupReadDir = oldReadDir })
scanFail := errors.New("scan boom")
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, scanFail }
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if _, err := mutualExclusionVictims(monoDest, skillSetupModeMono); err == nil {
t.Fatal("scan failure should surface as an error")
}
var out, errOut bytes.Buffer
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
if !strings.Contains(errOut.String(), "互斥清理扫描失败") {
t.Fatalf("expected scan warning on errOut, got %q", errOut.String())
}
}
// TestRunSkillSetupThreadsFilteredFlag verifies runSkillSetup tells
// installMultiSkillToHomes whether -s/--skill or -x/--exclude was used, so a
// full install cleans stale siblings while a filtered install stays additive.
func TestRunSkillSetupThreadsFilteredFlag(t *testing.T) {
oldMode, oldSource, oldTargets := skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets
oldList, oldFilter, oldExecute := skillSetupListMulti, skillSetupFilterMulti, skillSetupExecutePlan
t.Cleanup(func() {
skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets = oldMode, oldSource, oldTargets
skillSetupListMulti, skillSetupFilterMulti, skillSetupExecutePlan = oldList, oldFilter, oldExecute
})
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
skillSetupResolveTargets = func(string, string) ([]string, error) {
return []string{filepath.Join(t.TempDir(), "dest")}, nil
}
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-aitable", "dws-shared"}, nil }
skillSetupFilterMulti = filterMultiSkillNames
home := t.TempDir()
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBuildProvenance, func(name, _ string, version, source string) (skillprovenance.Record, error) {
return skillprovenance.Record{Name: name, Version: version, Source: source, Digest: "sha256:test", DigestScope: skillprovenance.DigestScope}, nil
})
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
var gotFiltered []bool
skillSetupExecutePlan = func(plan *skillSetupPlan, _, _ io.Writer) (int, int, error) {
gotFiltered = append(gotFiltered, plan.Filtered)
return 1, 0, nil
}
// Full install (no -s/-x): filtered must be false.
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("full install run err: %v", err)
}
// Filtered install: filtered must be true.
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.Flags().Set("skill", "aitable"); err != nil {
t.Fatal(err)
}
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("filtered install run err: %v", err)
}
if len(gotFiltered) != 2 || gotFiltered[0] != false || gotFiltered[1] != true {
t.Fatalf("filtered flag threading = %v, want [false true]", gotFiltered)
}
}
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
t.Helper()
originalTargets := skillSetupResolveTargets
@@ -633,7 +992,7 @@ func assertNoEventMigrationStages(t *testing.T, agentHome string) {
}
}
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
@@ -653,13 +1012,10 @@ func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
t.Fatal(err)
}
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
if err != nil {
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
}
if !strings.Contains(stdout, "迁移伴侣") || !strings.Contains(stdout, foldedHome) {
t.Fatalf("confirmation output should expose folded misc migration: %s", stdout)
}
if !strings.Contains(stdout, "重新加载 Skills") {
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
}
@@ -703,7 +1059,7 @@ func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
}
}
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
func TestCrossPlatformCoverageSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
src := writeMultiSkillSource(t, []string{
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
})
@@ -854,7 +1210,7 @@ func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("misc migration companion ran after a primary install skip")
@@ -891,7 +1247,7 @@ func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
originalInstallMulti := skillSetupInstallMulti
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
calls := 0
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
calls++
if calls > 1 {
t.Fatal("folded target prerequisites ran after fresh target failure")
+9 -5
View File
@@ -57,9 +57,9 @@ var (
downloadUpgradeProgress = upgrade.DownloadWithProgress
extractUpgradeZip = upgrade.ExtractZip
findExtractedBinary = upgrade.FindBinaryInDir
locateUpgradeSkill = upgrade.LocateSkillMD
locateUpgradeSkill = upgrade.LocateSkillsRoot
replaceUpgradeSelf = upgrade.ReplaceSelf
installUpgradeSkills = upgrade.UpgradeSkillLocations
installUpgradeSkills = upgrade.UpgradeSkillLocationsWithOptions
upgradeMkdirTemp = os.MkdirTemp
upgradeRemoveAll = os.RemoveAll
upgradeReadFile = os.ReadFile
@@ -99,7 +99,8 @@ func newUpgradeCommand() *cobra.Command {
Long: `检查并升级 DWS CLI 到最新版本。
自动下载匹配当前平台的二进制文件和技能包,通过 SHA256 校验后原子替换。
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
升级前会自动备份当前版本,可通过 --rollback 回滚。
每次升级都会按新版本官方清单全量覆盖预制 Skill;--force 仅额外允许重装当前版本。`,
Example: ` dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出最近版本
@@ -107,6 +108,7 @@ func newUpgradeCommand() *cobra.Command {
dws upgrade --beta # 升级到最新 beta 预发布版本
dws upgrade --version v1.0.7 # 升级到指定正式版本
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
dws upgrade --force # 即使已是最新版本也重装当前版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade --dry-run # 仅预览升级步骤,不实际执行
dws upgrade -y # 跳过确认直接升级`,
@@ -158,7 +160,7 @@ func newUpgradeCommand() *cobra.Command {
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
cmd.Flags().BoolVar(&flagBeta, "beta", false, "使用最新 beta 预发布版本(默认使用正式 release)")
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
cmd.Flags().BoolVar(&flagForce, "force", false, "即使已是最新版本也强制重新安装当前版本")
cmd.Flags().BoolVar(&flagSkipSkills, "skip-skills", false, "跳过技能包更新")
return cmd
@@ -596,7 +598,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
}
if hasSkills {
result, installErr := installUpgradeSkills(skillSrc)
result, installErr := installUpgradeSkills(skillSrc, upgrade.SkillUpgradeOptions{
Version: release.Version,
})
if installErr != nil {
fmt.Printf(" %s\n", ugRed("✗"))
return fmt.Errorf("技能包安装失败: %w", installErr)
+1 -1
View File
@@ -285,7 +285,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
}
return nil
}
installUpgradeSkills = func(string) (*upgradepkg.SkillUpgradeResult, error) {
installUpgradeSkills = func(string, upgradepkg.SkillUpgradeOptions) (*upgradepkg.SkillUpgradeResult, error) {
if stage == "install" {
return nil, fail
}
@@ -0,0 +1,156 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
)
// TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti is the fake-HOME E2E for
// the 2026-08-05 owner decision: upgrade is not disk-sticky. Seeding a mono
// layout then calling UpgradeSkillLocations with a multi bundle must install
// product skills, remove dws/, and leave non-DWS dirs alone.
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoSeedMigratesToMulti(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
agentsBase := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644); err != nil {
t.Fatal(err)
}
extract := t.TempDir()
multiRoot := filepath.Join(extract, "multi")
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
dir := filepath.Join(multiRoot, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
t.Fatal(err)
}
}
result, err := upgrade.UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("expected 0 failures, got %v", failed)
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("mono leftover dws/ must be gone, stat err=%v", err)
}
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Errorf("multi skill missing: %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
}
// TestUpgradeSkillLocationsRealBundleMigratesMono runs the upgrade path against
// the repository's actual multi bundle (skills/multi, post-#887 layout with
// dingtalk-shared). Disk is seeded with a mono install plus the leftovers the
// rename and stale releases leave behind: a pre-rename dws-shared directory
// and a dingtalk-* skill no longer in the bundle. All three must be gone after
// the upgrade, every bundle skill installed, non-DWS dirs untouched, and the
// ~/.dws/skills/multi cache refreshed.
func TestCrossPlatformCoverageUpgradeSkillLocationsRealBundleMigratesMono(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
bundle := filepath.Join("..", "..", "skills", "multi")
entries, err := os.ReadDir(bundle)
if err != nil {
t.Fatalf("real multi bundle missing: %v", err)
}
var bundleNames []string
for _, e := range entries {
if e.IsDir() {
bundleNames = append(bundleNames, e.Name())
}
}
if len(bundleNames) == 0 {
t.Fatal("real multi bundle is empty")
}
agentsBase := filepath.Join(home, ".agents", "skills")
// Mono install plus pre-rename shared and a stale product skill.
for _, name := range []string{"dws", "dws-shared", "dingtalk-stale", "other-skill"} {
dir := filepath.Join(agentsBase, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
t.Fatal(err)
}
}
if err := skillstate.Write(home, skillstate.State{ManagedSkills: []skillprovenance.Record{{Name: "dingtalk-stale"}}}); err != nil {
t.Fatal(err)
}
custom := filepath.Join(agentsBase, "dingtalk-custom")
if err := os.MkdirAll(custom, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(custom, "SKILL.md"), []byte("market skill"), 0o644); err != nil {
t.Fatal(err)
}
result, err := upgrade.UpgradeSkillLocations(bundle)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("expected 0 failures, got %v", failed)
}
for _, stale := range []string{"dws", "dws-shared", "dingtalk-stale"} {
if _, err := os.Stat(filepath.Join(agentsBase, stale)); !os.IsNotExist(err) {
t.Errorf("leftover %q must be removed by the upgrade, stat err=%v", stale, err)
}
}
for _, name := range bundleNames {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Errorf("bundle skill %q missing after upgrade: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
if _, err := os.Stat(filepath.Join(custom, "SKILL.md")); err != nil {
t.Errorf("unregistered market/user dingtalk-* dir should be preserved: %v", err)
}
// Shared skill must come from the renamed bundle dir, never the legacy name.
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-shared", "SKILL.md")); err != nil {
t.Errorf("dingtalk-shared missing: %v", err)
}
// Cache refresh so `dws skill setup` fallbacks stay on the upgraded version.
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "SKILL.md")); err != nil {
// multi cache mirrors the bundle root, which has no top-level SKILL.md;
// check a bundle skill inside the cache instead.
if _, err2 := os.Stat(filepath.Join(home, ".dws", "skills", "multi", bundleNames[0], "SKILL.md")); err2 != nil {
t.Errorf("multi cache not refreshed: %v / %v", err, err2)
}
}
}
+21
View File
@@ -18,6 +18,27 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageUpgradeHelpDoesNotMakeFullSkillRefreshForceOnly(t *testing.T) {
cmd := newUpgradeCommand()
force := cmd.Flags().Lookup("force")
if force == nil {
t.Fatal("upgrade --force flag is missing")
}
for _, text := range []string{cmd.Example, force.Usage} {
if strings.Contains(text, "恢复全部官方 Skill") {
t.Fatalf("upgrade help still implies that only --force performs the full Skill refresh: %q", text)
}
}
if !strings.Contains(force.Usage, "已是最新版本") {
t.Fatalf("upgrade --force help must explain its remaining purpose, got %q", force.Usage)
}
for _, want := range []string{"每次升级", "全量覆盖预制 Skill", "--force 仅额外允许重装当前版本"} {
if !strings.Contains(cmd.Long, want) {
t.Fatalf("upgrade help missing full-refresh contract %q: %s", want, cmd.Long)
}
}
}
// --- ensureV ---
func TestEnsureV(t *testing.T) {
+2
View File
@@ -1356,6 +1356,8 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
{"chat message edit", "conversation-id", "openConversationId", true, ""},
{"chat message edit", "msg-id", "openMessageId", true, ""},
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message send-card", "at-all", "atAll", false, ""},
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
+21 -3
View File
@@ -5721,27 +5721,41 @@ chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。
Use: "send-card",
Short: "创建并推送流式卡片",
Long: `向群聊或单聊创建并推送流式卡片。群聊传 --group,单聊传 --receiver,二者互斥。
群聊创建卡片时可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人。
创建时无需传入卡片内容,后续通过 update-card 更新内容。
注意:send-card 必须和 update-card 搭配使用。发送卡片后,使用返回的 bizId 调用 update-card 更新内容,
最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。`,
Example: ` dws chat message send-card --group <openConversationId>
dws chat message send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId>
dws chat message send-card --group <openConversationId> --at-all
dws chat message send-card --receiver <openDingTalkId>
# 查询群 ID: dws chat search --query "群名"
# 查询人员: dws contact user search --keyword "姓名" --format json`,
RunE: func(cmd *cobra.Command, args []string) error {
groupID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
receiver, _ := cmd.Flags().GetString("receiver")
atOpenDingTalkIDs := uniqueNonEmptyStrings(parseCSVValues(mustGetFlag(cmd, "at-open-dingtalk-ids")))
atAll, _ := cmd.Flags().GetBool("at-all")
if groupID == "" && receiver == "" {
return fmt.Errorf("--group or --receiver is required")
}
if groupID != "" && receiver != "" {
return fmt.Errorf("--group and --receiver are mutually exclusive")
}
if groupID == "" && (len(atOpenDingTalkIDs) > 0 || atAll) {
return fmt.Errorf("--at-open-dingtalk-ids and --at-all are only supported with --group")
}
toolArgs := map[string]any{}
if groupID != "" {
toolArgs["openConversationId"] = groupID
if len(atOpenDingTalkIDs) > 0 {
toolArgs["atOpenDingTalkIds"] = atOpenDingTalkIDs
}
if atAll {
toolArgs["atAll"] = true
}
}
if receiver != "" {
resolved, err := resolveOpenDingTalkID(cmd.Context(), receiver)
@@ -5766,19 +5780,21 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
CLIPath: "chat message send-card",
PrimaryCLIPath: "chat message send-card",
},
Description: "创建并向群聊或单聊发送互动卡片",
Description: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_and_send_card"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建并向群聊或单聊发送互动卡片",
UseWhen: []string{"需要卡片式交互且已准备接收会话或用户时"},
AgentSummary: "创建并向群聊或单聊发送互动卡片;群聊创建时可 @成员或 @所有人",
UseWhen: []string{"需要创建卡片且已准备接收会话或用户时;群聊创建可同时指定 @成员或 @所有人"},
AvoidWhen: []string{"只发送普通文本时使用 send 或 send-by-bot"},
Examples: []string{"dws chat message send-card --group <openConversationId>"},
},
Parameters: []contract.ParamDecl{
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
{Name: "group", Property: "openConversationId"},
{Name: "receiver", Property: "receiverOpenDingTalkId"},
},
@@ -5786,6 +5802,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
})
chatMessageSendCardCmd.Flags().String("group", "", "群聊 openConversationId(群聊时必填,与 --receiver 互斥)")
chatMessageSendCardCmd.Flags().String("receiver", "", "单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)")
chatMessageSendCardCmd.Flags().String("at-open-dingtalk-ids", "", "群聊创建卡片时 @ 的 openDingTalkId 列表,逗号分隔(仅与 --group 一起使用)")
chatMessageSendCardCmd.Flags().Bool("at-all", false, "群聊创建卡片时 @ 所有人(仅与 --group 一起使用)")
chatMessageUpdateCardCmd := &cobra.Command{
Use: "update-card",
+6 -4
View File
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
}
})
t.Run("generic success is unverified", func(t *testing.T) {
t.Run("success acknowledgement is verified", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}}
err := runNativeCardUpdate(t, caller,
"message", "update-card",
@@ -85,9 +85,11 @@ func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T)
"--content", "完成",
"--flow-status", "3",
)
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
if err != nil {
t.Fatal(err)
}
if caller.calls != 1 || caller.tool != "update_streaming_card" {
t.Fatalf("call = count:%d tool:%q", caller.calls, caller.tool)
}
})
@@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -272,6 +273,52 @@ func TestCrossPlatformCoverageChatCreateAndMessageSendEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
t.Run("group forwards mention arguments", func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller,
"message", "send-card",
"--group=cid",
"--at-open-dingtalk-ids=D1,D2,D1",
"--at-all",
)
if err != nil {
t.Fatal(err)
}
want := map[string]any{
"openConversationId": "cid",
"atOpenDingTalkIds": []string{"D1", "D2"},
"atAll": true,
}
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, want) {
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
}
})
for _, tc := range []struct {
name string
args []string
}{
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
t.Fatalf("error = %v, want group-only mention validation", err)
}
if caller.calls != 0 {
t.Fatalf("invalid direct-message mentions made %d tool calls", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
os.Args = []string{"dws", "chat"}
+82 -5
View File
@@ -1,10 +1,13 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
@@ -1322,10 +1325,13 @@ ORG 类型授权不会出现在查询结果中。`,
通过 --workspace 指定知识库,支持传入知识库 ID 或知识库 URL。
支持分页,通过 --cursor 传入上次返回的 nextToken 获取下一页。
权限要求:调用者需具备知识库的成员权限,非成员会被拒绝访问。`,
使用 --exclude-file 可排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞等)。
当用户要求"只看文档操作""排除文件上传"等意图时,必须使用此 flag,
禁止在客户端自行过滤。`,
Example: ` dws wiki feed list --workspace <workspaceId>
dws wiki feed list --workspace <workspaceId> --limit 10
dws wiki feed list --workspace <workspaceId> --exclude-file
dws wiki feed list --workspace <workspaceId> --limit 10 --exclude-file
dws wiki feed list --workspace <workspaceId> --cursor <nextToken>`,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
@@ -1345,7 +1351,19 @@ ORG 类型授权不会出现在查询结果中。`,
v, _ := cmd.Flags().GetBool("exclude-file")
toolArgs["excludeFile"] = v
}
return callMCPTool("list_workspace_feeds", toolArgs)
// 调用后对 feeds[].time 毫秒时间戳做本地化格式化,
// 使 Agent 直接看到可读时间,无需自行转换
text, err := callMCPToolReturnText(context.Background(), "list_workspace_feeds", toolArgs)
if err != nil {
return err
}
// 保持与共享 dispatcher (callMCPToolInternalOpts) 一致的输出契约:
// 仅 --format json 做时间/标签增强,raw、table 及其他格式输出原始 MCP 文本
if deps.Caller.Format() != "json" {
deps.Out.PrintRaw(text)
return nil
}
return deps.Out.PrintJSON(formatFeedTime(text))
},
}
DeclareLeafMetadata(feedListCmd, LeafSpec{
@@ -1391,9 +1409,9 @@ ORG 类型授权不会出现在查询结果中。`,
},
})
feedListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 20,最大 50)")
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10")
feedListCmd.Flags().String("cursor", "", "分页游标 (首页留空)")
feedListCmd.Flags().Bool("exclude-file", false, "是否排除文件相关的动态 (默认 false)")
feedListCmd.Flags().Bool("exclude-file", false, "排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞)。用户要求排除文件/只看文档操作时必须使用此 flag,禁止客户端过滤")
feedListCmd.Flags().String("workspace-id", "", "")
_ = feedListCmd.Flags().MarkHidden("workspace-id")
RegisterCrossProductAliases(feedListCmd)
@@ -1430,3 +1448,62 @@ ORG 类型授权不会出现在查询结果中。`,
return root
}
// beijingLoc 用于将时间戳格式化为北京时间(UTC+8)
var beijingLoc = time.FixedZone("CST", 8*3600)
// formatFeedTime 将知识库动态 feeds[].time 毫秒时间戳转为可读时间字符串,
// 写入 timeFormatted 字段,同时保留原始 time 毫秒时间戳不变(避免破坏已有脚本)。
// 解析失败时原样返回,不阻断输出。
func formatFeedTime(raw string) any {
var result map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
return raw
}
feeds, ok := result["feeds"].([]any)
if !ok {
return result
}
for _, f := range feeds {
item, ok := f.(map[string]any)
if !ok {
continue
}
ms, ok := item["time"].(float64)
if !ok || ms <= 0 {
continue
}
// 保留原始 time 毫秒时间戳,新增 timeFormatted 可读字段
item["timeFormatted"] = time.UnixMilli(int64(ms)).In(beijingLoc).Format("2006-01-02 15:04")
enrichFeedFields(item)
}
return result
}
// enrichFeedFields 为单条 feed 补充可读字段(typeLabel),
func enrichFeedFields(item map[string]any) {
// 将 type 数字映射为可读标签,使 Agent 无需查表即可直接展示
if typeNum, ok := item["type"].(float64); ok {
if label, known := feedTypeLabels[int(typeNum)]; known {
item["typeLabel"] = label
}
}
}
// feedTypeLabels 将知识库动态 type 数字映射为可读中文标签,
// 与 lippi-combo OpenFeedItemDTO.type 枚举对齐。
var feedTypeLabels = map[int]string{
0: "创建文档",
1: "更新文档",
2: "评论文档",
3: "点赞文档",
4: "加入团队空间",
5: "表格选区数据变更",
6: "更新 office 文件",
7: "上传普通文件",
8: "上传媒体文件",
9: "上传文件夹",
10: "上传文件夹 V2",
11: "加入团队",
12: "创建知识库",
}
+582
View File
@@ -0,0 +1,582 @@
package helpers
import (
"bytes"
"encoding/json"
"io"
"os"
"strings"
"testing"
)
// ---------- formatFeedTime unit tests ----------
func TestCrossPlatformCoverageFormatFeedTimeInvalidJSON(t *testing.T) {
// JSON 解析失败 → 原样返回原始字符串
raw := "not-json"
got := formatFeedTime(raw)
s, ok := got.(string)
if !ok || s != raw {
t.Fatalf("formatFeedTime(invalid) = %v (%T), want raw string %q", got, got, raw)
}
}
func TestCrossPlatformCoverageFormatFeedTimeEmptyInput(t *testing.T) {
// 空字符串 → JSON 解析失败 → 原样返回
got := formatFeedTime("")
s, ok := got.(string)
if !ok || s != "" {
t.Fatalf("formatFeedTime(\"\") = %v (%T), want \"\"", got, got)
}
}
func TestCrossPlatformCoverageFormatFeedTimeNoFeedsKey(t *testing.T) {
// 合法 JSON 但没有 feeds 字段 → 返回解析后的 map
raw := `{"status":"ok","count":0}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("formatFeedTime(no feeds) = %T, want map[string]any", got)
}
if m["status"] != "ok" {
t.Fatalf("status = %v, want ok", m["status"])
}
}
func TestCrossPlatformCoverageFormatFeedTimeFeedsNotArray(t *testing.T) {
// feeds 不是数组 → 返回解析后的 map
raw := `{"feeds":"unexpected-string"}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("formatFeedTime(feeds string) = %T, want map[string]any", got)
}
if m["feeds"] != "unexpected-string" {
t.Fatalf("feeds = %v, want unexpected-string", m["feeds"])
}
}
func TestCrossPlatformCoverageFormatFeedTimeFeedsNull(t *testing.T) {
// feeds 为 null → 类型断言失败 → 返回解析后的 map
raw := `{"feeds":null}`
got := formatFeedTime(raw)
_, ok := got.(map[string]any)
if !ok {
t.Fatalf("formatFeedTime(feeds null) = %T, want map[string]any", got)
}
}
func TestCrossPlatformCoverageFormatFeedTimeNonMapItem(t *testing.T) {
// feeds 数组元素不是 object → continue 跳过
raw := `{"feeds":["string-item", 42, null]}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", got)
}
feeds := m["feeds"].([]any)
if len(feeds) != 3 {
t.Fatalf("feeds length = %d, want 3", len(feeds))
}
}
func TestCrossPlatformCoverageFormatFeedTimeTimeMissingOrInvalid(t *testing.T) {
// time 字段缺失或不是 float64 或 <= 0 → 跳过格式化
raw := `{"feeds":[
{"id":"a"},
{"id":"b","time":"not-a-number"},
{"id":"c","time":0},
{"id":"d","time":-1000}
]}`
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", got)
}
feeds := m["feeds"].([]any)
for i, f := range feeds {
item := f.(map[string]any)
if _, hasTimeMs := item["timeMs"]; hasTimeMs {
t.Fatalf("feed[%d] should not have timeMs, got %v", i, item)
}
}
}
func TestCrossPlatformCoverageFormatFeedTimeNormalPath(t *testing.T) {
// 正常路径:time 保留原始毫秒时间戳,timeFormatted 被格式化为北京时间
// 1750067400000 ms = 2025-06-16 17:50 Beijing time (UTC+8)
tsMs := float64(1750067400000)
raw := buildFeedJSON(t, []map[string]any{
{"time": tsMs, "type": float64(1), "id": "f1"},
})
got := formatFeedTime(raw)
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", got)
}
feeds := m["feeds"].([]any)
if len(feeds) != 1 {
t.Fatalf("feeds length = %d, want 1", len(feeds))
}
item := feeds[0].(map[string]any)
// time 保留原始毫秒时间戳不变
if ms, ok := item["time"].(float64); !ok || ms != tsMs {
t.Fatalf("time = %v (%T), want original ms %v", item["time"], item["time"], tsMs)
}
// timeFormatted 被格式化为北京时间字符串(硬编码期望值)
timeStr, ok := item["timeFormatted"].(string)
if !ok {
t.Fatalf("timeFormatted = %T, want string", item["timeFormatted"])
}
if timeStr != "2025-06-16 17:50" {
t.Fatalf("timeFormatted = %q, want %q", timeStr, "2025-06-16 17:50")
}
// typeLabel 被填充
if item["typeLabel"] != "更新文档" {
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
}
}
func TestCrossPlatformCoverageFormatFeedTimeMultipleFeeds(t *testing.T) {
// 多个 feed,混合有效/无效时间戳
ts1 := float64(1750067400000)
ts2 := float64(1700000000000)
raw := buildFeedJSON(t, []map[string]any{
{"time": ts1, "type": float64(0), "id": "f1"},
{"time": ts2, "type": float64(12), "id": "f2"},
{"id": "f3"}, // 没有 time → 跳过
})
got := formatFeedTime(raw)
m := got.(map[string]any)
feeds := m["feeds"].([]any)
// 前两个有时间戳
for i := 0; i < 2; i++ {
item := feeds[i].(map[string]any)
if _, ok := item["time"].(float64); !ok {
t.Fatalf("feed[%d] time should remain float64, got %T", i, item["time"])
}
if _, ok := item["timeFormatted"].(string); !ok {
t.Fatalf("feed[%d] timeFormatted not string", i)
}
}
// 第三个没有时间戳
item3 := feeds[2].(map[string]any)
if _, hasTimeFormatted := item3["timeFormatted"]; hasTimeFormatted {
t.Fatalf("feed[2] should not have timeFormatted")
}
}
// ---------- enrichFeedFields unit tests ----------
func TestCrossPlatformCoverageEnrichFeedFieldsPreservesAllFields(t *testing.T) {
// enrichFeedFields 不删除任何字段,仅新增 typeLabel
item := map[string]any{
"parentDoc": map[string]any{"name": "parent"},
"userInfo": map[string]any{"nick": "user1"},
"id": "f1",
"type": float64(1),
}
enrichFeedFields(item)
if _, ok := item["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if _, ok := item["userInfo"]; !ok {
t.Fatal("userInfo should be preserved")
}
if item["id"] != "f1" {
t.Fatal("id should be preserved")
}
if item["typeLabel"] != "更新文档" {
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelKnown(t *testing.T) {
// 已知 type → 添加 typeLabel
for typeNum, expectedLabel := range feedTypeLabels {
item := map[string]any{"type": float64(typeNum)}
enrichFeedFields(item)
if item["typeLabel"] != expectedLabel {
t.Fatalf("type %d: typeLabel = %v, want %q", typeNum, item["typeLabel"], expectedLabel)
}
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelUnknown(t *testing.T) {
// 未知 type → 不添加 typeLabel
item := map[string]any{"type": float64(999)}
enrichFeedFields(item)
if _, ok := item["typeLabel"]; ok {
t.Fatal("typeLabel should not be set for unknown type 999")
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsTypeNotFloat(t *testing.T) {
// type 不是 float64 → 不添加 typeLabel
item := map[string]any{"type": "string-type"}
enrichFeedFields(item)
if _, ok := item["typeLabel"]; ok {
t.Fatal("typeLabel should not be set for non-float type")
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsUsersPreserved(t *testing.T) {
// users 数组中的 user 对象所有字段保留不变
item := map[string]any{
"users": []any{
map[string]any{"nick": "Alice", "avatarUrl": "http://img", "userId": "u1"},
map[string]any{"nick": "Bob", "email": "bob@test"},
},
}
enrichFeedFields(item)
users := item["users"].([]any)
u0 := users[0].(map[string]any)
if u0["avatarUrl"] != "http://img" {
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
}
if u0["userId"] != "u1" {
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsUsersNonMapItem(t *testing.T) {
// users 数组中有非 map 元素 → 不 panic
item := map[string]any{
"users": []any{"not-a-map", 42, nil},
}
enrichFeedFields(item) // 不应 panic
users := item["users"].([]any)
if len(users) != 3 {
t.Fatalf("users length changed: %d", len(users))
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsContentPreserved(t *testing.T) {
// content 和 content.doc 所有字段保留不变
item := map[string]any{
"content": map[string]any{
"doc": map[string]any{
"name": "测试文档",
"extension": "adoc",
"docKey": "dk123",
"url": "http://example",
},
"dentryKey": "entry1",
"workspaceKey": "ws1",
},
}
enrichFeedFields(item)
content := item["content"].(map[string]any)
if content["dentryKey"] != "entry1" {
t.Fatalf("content.dentryKey = %v, want entry1", content["dentryKey"])
}
if content["workspaceKey"] != "ws1" {
t.Fatalf("content.workspaceKey = %v, want ws1", content["workspaceKey"])
}
doc := content["doc"].(map[string]any)
if doc["name"] != "测试文档" {
t.Fatalf("doc.name = %v, want 测试文档", doc["name"])
}
if doc["docKey"] != "dk123" {
t.Fatalf("doc.docKey = %v, want dk123", doc["docKey"])
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsNoContentNoUsers(t *testing.T) {
// 没有 content / users / type → 所有字段保留
item := map[string]any{
"id": "f1",
"parentDoc": map[string]any{"x": 1},
}
enrichFeedFields(item)
if _, ok := item["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if item["id"] != "f1" {
t.Fatal("id should be preserved")
}
}
func TestCrossPlatformCoverageEnrichFeedFieldsEmptyItem(t *testing.T) {
// 空 map → 不 panic
item := map[string]any{}
enrichFeedFields(item)
if len(item) != 0 {
t.Fatalf("empty item changed: %v", item)
}
}
// ---------- RunE integration: feed list with formatFeedTime ----------
func TestCrossPlatformCoverageWikiFeedListFormatIntegration(t *testing.T) {
// 通过 scriptedToolCaller 返回真实结构的 feed JSON,验证 RunE 中
// callMCPToolReturnText → formatFeedTime → PrintJSON 完整路径(仅 --format json)
caller := &scriptedToolCaller{format: "json"}
ts := float64(1750067400000)
feedPayload := map[string]any{
"feeds": []any{
map[string]any{
"time": ts,
"type": float64(0),
"id": "feed1",
"parentDoc": map[string]any{"name": "parent"},
"userInfo": map[string]any{"nick": "user1", "avatarUrl": "http://img"},
"users": []any{
map[string]any{"nick": "Alice", "userId": "u1", "avatarUrl": "http://avatar"},
},
"content": map[string]any{
"doc": map[string]any{
"name": "测试文档",
"extension": "adoc",
"docKey": "dk123",
},
"dentryKey": "entry1",
"workspaceKey": "ws1",
},
},
map[string]any{
"time": float64(1700000000000),
"type": float64(7),
"id": "feed2",
},
},
}
payloadBytes, err := json.Marshal(feedPayload)
if err != nil {
t.Fatal(err)
}
caller.steps = []scriptedToolStep{{text: string(payloadBytes)}}
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1", "--exclude-file", "--format", "json"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// 验证输出是合法 JSON 且包含格式化后的 feed
var parsed map[string]any
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, out.String())
}
feeds, ok := parsed["feeds"].([]any)
if !ok || len(feeds) != 2 {
t.Fatalf("feeds length unexpected: %v", parsed["feeds"])
}
// 第一个 feed 验证 time 保留、timeFormatted 格式化、所有原始字段保留
f0 := feeds[0].(map[string]any)
// time 保留原始毫秒时间戳
if f0["time"] != ts {
t.Fatalf("time = %v, want %v", f0["time"], ts)
}
// timeFormatted 被填充
if _, ok := f0["timeFormatted"].(string); !ok {
t.Fatalf("timeFormatted should be string, got %T", f0["timeFormatted"])
}
// 所有原始字段保留(不删除任何字段)
if _, ok := f0["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if _, ok := f0["userInfo"]; !ok {
t.Fatal("userInfo should be preserved")
}
if f0["typeLabel"] != "创建文档" {
t.Fatalf("typeLabel = %v, want 创建文档", f0["typeLabel"])
}
// users 保留验证:avatarUrl / userId 均保留
if users, ok := f0["users"].([]any); ok {
u0 := users[0].(map[string]any)
if u0["avatarUrl"] != "http://avatar" {
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
}
if u0["userId"] != "u1" {
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
}
}
// content 结构保留验证
if content, ok := f0["content"].(map[string]any); ok {
if content["dentryKey"] != "entry1" {
t.Fatalf("content.dentryKey should be preserved, got %v", content["dentryKey"])
}
if content["workspaceKey"] != "ws1" {
t.Fatalf("content.workspaceKey should be preserved, got %v", content["workspaceKey"])
}
doc := content["doc"].(map[string]any)
if doc["docKey"] != "dk123" {
t.Fatalf("content.doc.docKey should be preserved, got %v", doc["docKey"])
}
}
}
func TestCrossPlatformCoverageWikiFeedListRawFormat(t *testing.T) {
// --format raw: 输出原始 MCP 文本,不经过 formatFeedTime 后处理
rawPayload := `{"feeds":[{"time":1750067400000,"type":1}]}`
caller := &scriptedToolCaller{
format: "raw",
steps: []scriptedToolStep{{text: rawPayload}},
}
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1", "--format", "raw"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// raw 输出应包含原始文本,不经过 formatFeedTime 格式化
got := out.String()
if !bytes.Contains([]byte(got), []byte(`"feeds"`)) {
t.Fatalf("raw output should contain original payload, got: %s", got)
}
}
func TestCrossPlatformCoverageWikiFeedListTableFormatMatchesDispatcher(t *testing.T) {
// --format table 回归:与共享 dispatcher (callMCPToolInternalOpts) 契约一致,
// 输出原始紧凑 MCP 文本,不做 pretty 化,也不注入 timeFormatted / typeLabel
rawPayload := `{"feeds":[{"time":1750067400000,"type":1,"id":"f1"}]}`
caller := &scriptedToolCaller{
format: "table",
steps: []scriptedToolStep{{text: rawPayload}},
}
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1", "--format", "table"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// 与共享 dispatcher 一致:输出 = 原始 MCP 文本原文
if got := strings.TrimSpace(out.String()); got != rawPayload {
t.Fatalf("table output should equal raw MCP payload,\ngot: %s\nwant: %s", got, rawPayload)
}
// 不注入任何增强字段
for _, injected := range []string{"timeFormatted", "typeLabel"} {
if strings.Contains(out.String(), injected) {
t.Fatalf("table output should not inject %s, got: %s", injected, out.String())
}
}
}
func TestCrossPlatformCoverageWikiFeedListEmptyResponse(t *testing.T) {
// MCP 返回空结果 → formatFeedTime 处理空字符串 → 不报错
caller := &scriptedToolCaller{} // 无 steps → 空结果
var out bytes.Buffer
oldDeps := deps
oldArgs := os.Args
InitDeps(caller)
deps.Out.w = &out
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = oldDeps
os.Args = oldArgs
})
root := newWikiCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(os.Stdin)
args := []string{"feed", "list", "--workspace", "ws1"}
root.SetArgs(args)
os.Args = append([]string{"dws", "wiki"}, args...)
if err := root.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
}
// ---------- helpers ----------
func buildFeedJSON(t *testing.T, feeds []map[string]any) string {
t.Helper()
payload := map[string]any{"feeds": feeds}
b, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const defaultTextInputLimit = int64(8 << 20)
var (
readTextInputAll = io.ReadAll
statTextInputPath = os.Stat
openTextInputFile = os.Open
readTextInputRel = filepath.Rel
)
// ReadTextInput resolves a literal, "-" stdin, or @workspace-relative file.
// File symlinks must resolve inside the current working directory and all input
// forms are bounded to prevent accidental unbounded memory use.
func ReadTextInput(spec string, stdin io.Reader, maxBytes int64) (string, error) {
if maxBytes <= 0 {
maxBytes = defaultTextInputLimit
}
if spec == "-" {
if stdin == nil {
return "", fmt.Errorf("LOCAL_INPUT_INVALID: stdin 不可用")
}
data, err := readTextInputAll(io.LimitReader(stdin, maxBytes+1))
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
if int64(len(data)) > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: stdin 超过 %d 字节", maxBytes)
}
return string(data), nil
}
if !strings.HasPrefix(spec, "@") {
if int64(len(spec)) > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文本输入超过 %d 字节", maxBytes)
}
return spec, nil
}
path := strings.TrimSpace(strings.TrimPrefix(spec, "@"))
if path == "" {
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 路径不能为空")
}
if filepath.IsAbs(path) {
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 只接受工作目录内的相对路径")
}
clean := filepath.Clean(path)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 不能逃逸工作目录")
}
cwd, err := localGetwd()
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
realBase, err := localEvalSymlinks(cwd)
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
realPath, err := localEvalSymlinks(filepath.Join(realBase, clean))
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
rel, err := readTextInputRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 解析后逃逸工作目录")
}
pathInfo, err := statTextInputPath(realPath)
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
if !pathInfo.Mode().IsRegular() {
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 必须是普通文件")
}
if pathInfo.Size() > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", pathInfo.Size(), maxBytes)
}
file, err := openTextInputFile(realPath)
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 必须是普通文件")
}
if info.Size() > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", info.Size(), maxBytes)
}
data, err := readTextInputAll(io.LimitReader(file, maxBytes+1))
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
if int64(len(data)) > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件超过 %d 字节", maxBytes)
}
return string(data), nil
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"io"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAndBoundedReadE2E(t *testing.T) {
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
t.Run("file grows after stat", func(t *testing.T) {
path := filepath.Join(dir, "grow.txt")
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &readTextInputAll, func(reader io.Reader) ([]byte, error) {
file, openErr := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
if openErr != nil {
return nil, openErr
}
if _, writeErr := file.WriteString("-too-large"); writeErr != nil {
_ = file.Close()
return nil, writeErr
}
if closeErr := file.Close(); closeErr != nil {
return nil, closeErr
}
return io.ReadAll(reader)
})
if _, err := ReadTextInput("@grow.txt", nil, 4); err == nil {
t.Fatal("file growth bypassed size limit")
}
})
t.Run("path becomes directory before descriptor check", func(t *testing.T) {
path := filepath.Join(dir, "becomes-directory.txt")
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
if removeErr := os.Remove(candidate); removeErr != nil {
return nil, removeErr
}
if mkdirErr := os.Mkdir(candidate, 0o700); mkdirErr != nil {
return nil, mkdirErr
}
return os.Open(candidate)
})
if _, err := ReadTextInput("@becomes-directory.txt", nil, 10); err == nil {
t.Fatal("path type replacement bypassed descriptor validation")
}
})
t.Run("file grows before descriptor check", func(t *testing.T) {
path := filepath.Join(dir, "grows-before-stat.txt")
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
file, openErr := os.Open(candidate)
if openErr != nil {
return nil, openErr
}
writer, writeOpenErr := os.OpenFile(candidate, os.O_APPEND|os.O_WRONLY, 0)
if writeOpenErr != nil {
_ = file.Close()
return nil, writeOpenErr
}
if _, writeErr := writer.WriteString("-too-large"); writeErr != nil {
_ = writer.Close()
_ = file.Close()
return nil, writeErr
}
if closeErr := writer.Close(); closeErr != nil {
_ = file.Close()
return nil, closeErr
}
return file, nil
})
if _, err := ReadTextInput("@grows-before-stat.txt", nil, 4); err == nil {
t.Fatal("pre-open growth bypassed descriptor size validation")
}
})
}
+84
View File
@@ -0,0 +1,84 @@
//go:build unix
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAfterPathReplacementE2E(t *testing.T) {
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
path := filepath.Join(dir, "input.txt")
replacement := filepath.Join(dir, "replacement.txt")
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(replacement, []byte("replacement-exceeds-limit"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
file, openErr := os.Open(candidate)
if openErr != nil {
return nil, openErr
}
if renameErr := os.Rename(replacement, candidate); renameErr != nil {
_ = file.Close()
return nil, renameErr
}
return file, nil
})
got, err := ReadTextInput("@input.txt", nil, 10)
if err != nil || got != "safe" {
t.Fatalf("replacement read = %q, %v", got, err)
}
}
func TestCrossPlatformCoverageReadTextInputRejectsUnconnectedFIFOWithoutBlockingE2E(t *testing.T) {
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
fifo := filepath.Join(dir, "input.fifo")
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
_, readErr := ReadTextInput("@input.fifo", nil, 1024)
result <- readErr
}()
select {
case err := <-result:
if err == nil || !strings.Contains(err.Error(), "LOCAL_INPUT_INVALID") {
t.Fatalf("FIFO rejection error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("FIFO without a writer blocked text input validation")
}
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageReadTextInputLiteralStdinAndFileE2E(t *testing.T) {
if got, err := ReadTextInput("literal", nil, 10); err != nil || got != "literal" {
t.Fatalf("literal = %q, %v", got, err)
}
if got, err := ReadTextInput("-", strings.NewReader("stdin"), 10); err != nil || got != "stdin" {
t.Fatalf("stdin = %q, %v", got, err)
}
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
if err := os.WriteFile(filepath.Join(dir, "input.json"), []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
if got, err := ReadTextInput("@input.json", nil, 10); err != nil || got != "file" {
t.Fatalf("file = %q, %v", got, err)
}
}
func TestCrossPlatformCoverageReadTextInputRejectsEscapeAndOversizeE2E(t *testing.T) {
for _, spec := range []string{"@../secret", "@/tmp/secret", "@"} {
if _, err := ReadTextInput(spec, nil, 10); err == nil {
t.Fatalf("unsafe input accepted: %q", spec)
}
}
if _, err := ReadTextInput("too-large", nil, 2); err == nil {
t.Fatal("oversize literal accepted")
}
if _, err := ReadTextInput("-", strings.NewReader("too-large"), 2); err == nil {
t.Fatal("oversize stdin accepted")
}
}
func TestCrossPlatformCoverageReadTextInputFailureBranchesE2E(t *testing.T) {
if got, err := ReadTextInput("literal", nil, 0); err != nil || got != "literal" {
t.Fatalf("default limit literal = %q, %v", got, err)
}
if _, err := ReadTextInput("-", nil, 1); err == nil {
t.Fatal("nil stdin accepted")
}
t.Run("stdin read", func(t *testing.T) {
testseam.Swap(t, &readTextInputAll, func(io.Reader) ([]byte, error) { return nil, errors.New("read") })
if _, err := ReadTextInput("-", strings.NewReader("x"), 1); err == nil {
t.Fatal("stdin read failure ignored")
}
})
dir := t.TempDir()
file := filepath.Join(dir, "input.txt")
if err := os.WriteFile(file, []byte("long"), 0o600); err != nil {
t.Fatal(err)
}
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
if _, err := ReadTextInput("@input.txt", nil, 1); err == nil {
t.Fatal("oversize file accepted")
}
if _, err := ReadTextInput("@.", nil, 10); err == nil {
t.Fatal("directory accepted")
}
for name, run := range map[string]func(*testing.T){
"getwd": func(t *testing.T) {
testseam.Swap(t, &localGetwd, func() (string, error) { return "", errors.New("getwd") })
},
"eval base": func(t *testing.T) {
testseam.Swap(t, &localEvalSymlinks, func(string) (string, error) { return "", errors.New("eval base") })
},
"eval path": func(t *testing.T) {
calls := 0
testseam.Swap(t, &localEvalSymlinks, func(path string) (string, error) {
calls++
if calls == 2 {
return "", errors.New("eval path")
}
return path, nil
})
},
"relative": func(t *testing.T) {
testseam.Swap(t, &readTextInputRel, func(string, string) (string, error) { return "", errors.New("rel") })
},
"path stat": func(t *testing.T) {
testseam.Swap(t, &statTextInputPath, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
},
"open": func(t *testing.T) {
testseam.Swap(t, &openTextInputFile, func(string) (*os.File, error) { return nil, errors.New("open") })
},
"opened file stat": func(t *testing.T) {
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
file, openErr := os.Open(candidate)
if openErr == nil {
_ = file.Close()
}
return file, openErr
})
},
"read file": func(t *testing.T) {
testseam.Swap(t, &readTextInputAll, func(io.Reader) ([]byte, error) { return nil, errors.New("read file") })
},
} {
t.Run(name, func(t *testing.T) {
run(t)
if _, err := ReadTextInput("@input.txt", nil, 10); err == nil {
t.Fatalf("%s failure ignored", name)
}
})
}
t.Run("resolved escape", func(t *testing.T) {
calls := 0
testseam.Swap(t, &localEvalSymlinks, func(path string) (string, error) {
calls++
if calls == 2 {
return filepath.Join(filepath.Dir(path), "..", "outside"), nil
}
return path, nil
})
if _, err := ReadTextInput("@input.txt", nil, 10); err == nil {
t.Fatal("resolved escape accepted")
}
})
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"fmt"
"path/filepath"
)
const defaultPublishBytesLimit = 64 << 20
var verifyPublishTarget = func(target *downloadTarget) error { return target.verifyParent() }
// PublishBytesOptions controls safe no-clobber publication beneath BaseDir.
type PublishBytesOptions struct {
BaseDir string
Output string
PreferredName string
MaxBytes int64
}
// PublishBytes writes an in-memory artifact through the same symlink-safe,
// fsync and atomic no-clobber path used by remote downloads.
func PublishBytes(payload []byte, opts PublishBytesOptions) (DownloadResult, error) {
limit := opts.MaxBytes
if limit <= 0 {
limit = defaultPublishBytesLimit
}
if int64(len(payload)) > limit {
return DownloadResult{}, fmt.Errorf("LOCAL_OUTPUT_TOO_LARGE: 内容大小 %d 超过上限 %d 字节", len(payload), limit)
}
target, err := openDownloadTarget(opts.BaseDir, opts.Output, "", opts.PreferredName)
if err != nil {
return DownloadResult{}, err
}
defer target.close()
if err := verifyPublishTarget(target); err != nil {
return DownloadResult{}, err
}
tmp, tmpName, err := createDownloadTemp(target.parentRoot)
if err != nil {
return DownloadResult{}, fmt.Errorf("创建输出临时文件失败: %w", err)
}
cleanup := func() {
_ = tmp.Close()
_ = target.parentRoot.Remove(tmpName)
}
if _, err := tmp.Write(payload); err != nil {
cleanup()
return DownloadResult{}, fmt.Errorf("写入输出临时文件失败: %w", err)
}
if err := tmp.Sync(); err != nil {
cleanup()
return DownloadResult{}, fmt.Errorf("同步输出临时文件失败: %w", err)
}
if err := tmp.Close(); err != nil {
cleanup()
return DownloadResult{}, fmt.Errorf("关闭输出临时文件失败: %w", err)
}
if err := verifyPublishTarget(target); err != nil {
cleanup()
return DownloadResult{}, err
}
if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil {
cleanup()
return DownloadResult{}, err
}
return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: int64(len(payload))}, nil
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoveragePublishBytesAtomicNoClobberE2E(t *testing.T) {
base := t.TempDir()
result, err := PublishBytes([]byte("verified artifact"), PublishBytesOptions{BaseDir: base, Output: "out/", PreferredName: "transcript.json"})
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(base, result.RelativePath))
if err != nil || string(raw) != "verified artifact" || result.SizeBytes != int64(len(raw)) {
t.Fatalf("publish result=%#v raw=%q err=%v", result, raw, err)
}
if _, err := PublishBytes([]byte("overwrite"), PublishBytesOptions{BaseDir: base, Output: "out/transcript.json"}); err == nil {
t.Fatal("existing output was overwritten")
}
if _, err := PublishBytes([]byte("escape"), PublishBytesOptions{BaseDir: base, Output: "../escape"}); err == nil {
t.Fatal("path escape accepted")
}
}
func TestCrossPlatformCoveragePublishBytesFailureBranchesE2E(t *testing.T) {
base := t.TempDir()
if _, err := PublishBytes([]byte("xx"), PublishBytesOptions{BaseDir: base, Output: "large", MaxBytes: 1}); err == nil {
t.Fatal("oversize payload accepted")
}
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "default-limit", MaxBytes: 0}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
make func(*os.Root) (downloadTempFile, string, error)
}{
{name: "create", make: func(*os.Root) (downloadTempFile, string, error) { return nil, "", errors.New("create") }},
{name: "write", make: func(root *os.Root) (downloadTempFile, string, error) {
file, name, err := createDownloadTempInRoot(root)
return &coverageTempFile{file: file.(*os.File), writeErr: errors.New("write")}, name, err
}},
{name: "sync", make: func(root *os.Root) (downloadTempFile, string, error) {
file, name, err := createDownloadTempInRoot(root)
return &coverageTempFile{file: file.(*os.File), syncErr: errors.New("sync")}, name, err
}},
{name: "close", make: func(root *os.Root) (downloadTempFile, string, error) {
file, name, err := createDownloadTempInRoot(root)
return &coverageTempFile{file: file.(*os.File), closeErr: errors.New("close")}, name, err
}},
} {
t.Run(tc.name, func(t *testing.T) {
testseam.Swap(t, &createDownloadTemp, tc.make)
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: tc.name}); err == nil {
t.Fatalf("%s failure ignored", tc.name)
}
})
}
t.Run("initial parent verification", func(t *testing.T) {
testseam.Swap(t, &verifyPublishTarget, func(*downloadTarget) error { return errors.New("verify") })
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "verify-initial"}); err == nil {
t.Fatal("initial verification failure ignored")
}
})
t.Run("second parent verification", func(t *testing.T) {
calls := 0
testseam.Swap(t, &verifyPublishTarget, func(*downloadTarget) error {
calls++
if calls > 1 {
return errors.New("changed")
}
return nil
})
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "verify-second"}); err == nil {
t.Fatal("second verification failure ignored")
}
})
t.Run("publish", func(t *testing.T) {
testseam.Swap(t, &downloadRootLink, func(*os.Root, string, string) error { return errors.New("link") })
if _, err := PublishBytes([]byte("x"), PublishBytesOptions{BaseDir: base, Output: "publish"}); err == nil {
t.Fatal("publish failure ignored")
}
})
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"context"
"fmt"
"io"
"net/http"
"os"
"time"
)
const defaultUploadLimit = int64(5 << 30)
var (
newUploadHTTPClient = secureHTTPClient
validateUploadURL = func(raw string) error {
_, err := ValidateDownloadURL(raw)
return err
}
openUploadFile = os.Open
newUploadRequest = http.NewRequestWithContext
)
// UploadResult records only non-sensitive transfer facts. The signed URL is
// deliberately never returned.
type UploadResult struct {
SizeBytes int64
Attempts int
}
// PutFile uploads a regular local file to an exact trusted pre-signed HTTPS
// endpoint. Redirects are rejected so file bytes and credentials cannot move to
// a second origin. Transient failures reuse the same verified open file so a
// path replacement cannot change the bytes sent by a later attempt.
func PutFile(ctx context.Context, rawURL, path string, maxBytes int64) (UploadResult, error) {
client := newUploadHTTPClient()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return putFileWithClient(ctx, rawURL, path, maxBytes, client, validateUploadURL)
}
func putFileWithClient(ctx context.Context, rawURL, path string, maxBytes int64, client *http.Client, validate func(string) error) (UploadResult, error) {
if validate == nil {
return UploadResult{}, fmt.Errorf("upload URL validator is nil")
}
if err := validate(rawURL); err != nil {
return UploadResult{}, err
}
if maxBytes <= 0 {
maxBytes = defaultUploadLimit
}
file, err := openUploadFile(path)
if err != nil {
return UploadResult{}, fmt.Errorf("打开上传文件失败: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return UploadResult{}, fmt.Errorf("读取上传文件失败: %w", err)
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxBytes {
return UploadResult{}, fmt.Errorf("上传文件必须是 1..%d 字节的普通文件", maxBytes)
}
result := UploadResult{SizeBytes: info.Size()}
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
result.Attempts = attempt
reader := io.NewSectionReader(file, 0, info.Size())
request, requestErr := newUploadRequest(ctx, http.MethodPut, rawURL, reader)
if requestErr != nil {
return UploadResult{}, fmt.Errorf("创建上传请求失败: %w", requestErr)
}
request.ContentLength = info.Size()
response, callErr := client.Do(request)
if callErr == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
bodyCloseErr := response.Body.Close()
if response.StatusCode >= 200 && response.StatusCode < 300 && bodyCloseErr == nil {
return result, nil
}
if response.StatusCode < 500 && response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests {
return result, fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
}
lastErr = fmt.Errorf("上传返回 HTTP %d", response.StatusCode)
} else {
lastErr = callErr
}
if attempt < 3 {
timer := time.NewTimer(time.Duration(attempt) * 200 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return result, ctx.Err()
case <-timer.C:
}
}
}
return result, fmt.Errorf("上传在 %d 次尝试后失败: %w", result.Attempts, lastErr)
}
+217
View File
@@ -0,0 +1,217 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoveragePutFileRetriesAndUploadsExactBytesE2E(t *testing.T) {
payload := []byte("minutes-e2e")
replacement := []byte("replacement-data")
path := filepath.Join(t.TempDir(), "audio.wav")
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatal(err)
}
replacementPath := filepath.Join(t.TempDir(), "replacement.wav")
if err := os.WriteFile(replacementPath, replacement, 0o600); err != nil {
t.Fatal(err)
}
openCalls := 0
testseam.Swap(t, &openUploadFile, func(candidate string) (*os.File, error) {
openCalls++
if openCalls > 1 {
return os.Open(replacementPath)
}
return os.Open(candidate)
})
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Method != http.MethodPut {
t.Errorf("method = %s", r.Method)
}
body, _ := io.ReadAll(r.Body)
if string(body) != string(payload) {
t.Errorf("body = %q", body)
}
if calls == 1 {
http.Error(w, "retry", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
result, err := putFileWithClient(context.Background(), server.URL, path, 100, server.Client(), func(string) error { return nil })
if err != nil || calls != 2 || openCalls != 1 || result.Attempts != 2 || result.SizeBytes != int64(len(payload)) {
t.Fatalf("upload = %#v calls=%d opens=%d err=%v", result, calls, openCalls, err)
}
}
func TestCrossPlatformCoveragePutFileValidatesOpenedDescriptorE2E(t *testing.T) {
base := t.TempDir()
requestedPath := filepath.Join(base, "requested.wav")
if err := os.WriteFile(requestedPath, []byte("ok"), 0o600); err != nil {
t.Fatal(err)
}
replacementPath := filepath.Join(base, "replacement.wav")
if err := os.WriteFile(replacementPath, []byte("replacement-exceeds-limit"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openUploadFile, func(string) (*os.File, error) {
return os.Open(replacementPath)
})
called := false
client := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
called = true
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", requestedPath, 2, client, func(string) error { return nil }); err == nil {
t.Fatal("replacement descriptor bypassed size validation")
}
if called {
t.Fatal("upload started before the opened descriptor was validated")
}
}
type uploadRoundTripFunc func(*http.Request) (*http.Response, error)
func (f uploadRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}
type uploadCloseErrorBody struct{ io.Reader }
func (uploadCloseErrorBody) Close() error { return errors.New("close") }
func TestCrossPlatformCoveragePutFileFailureBranchesE2E(t *testing.T) {
base := t.TempDir()
path := filepath.Join(base, "audio.wav")
if err := os.WriteFile(path, []byte("payload"), 0o600); err != nil {
t.Fatal(err)
}
okClient := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})}
if _, err := PutFile(context.Background(), "not-a-valid-upload-url", path, 10); err == nil {
t.Fatal("default upload URL validation accepted invalid URL")
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, nil); err == nil {
t.Fatal("nil validator accepted")
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return errors.New("invalid") }); err == nil {
t.Fatal("validator error ignored")
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", filepath.Join(base, "missing"), 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("stat error ignored")
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", base, 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("directory accepted")
}
empty := filepath.Join(base, "empty")
if err := os.WriteFile(empty, nil, 0o600); err != nil {
t.Fatal(err)
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", empty, 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("empty file accepted")
}
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 1, okClient, func(string) error { return nil }); err == nil {
t.Fatal("oversize file accepted")
}
t.Run("wrapper", func(t *testing.T) {
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
http.Redirect(w, request, "http://example.invalid/redirected", http.StatusFound)
}))
defer redirect.Close()
testseam.Swap(t, &newUploadHTTPClient, redirect.Client)
testseam.Swap(t, &validateUploadURL, func(string) error { return nil })
if _, err := PutFile(context.Background(), redirect.URL, path, 0); err == nil {
t.Fatal("PutFile followed redirect")
}
})
t.Run("open", func(t *testing.T) {
testseam.Swap(t, &openUploadFile, func(string) (*os.File, error) { return nil, errors.New("open") })
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("open error ignored")
}
})
t.Run("opened file stat", func(t *testing.T) {
testseam.Swap(t, &openUploadFile, func(candidate string) (*os.File, error) {
file, err := os.Open(candidate)
if err == nil {
_ = file.Close()
}
return file, err
})
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("opened file stat error ignored")
}
})
t.Run("request", func(t *testing.T) {
testseam.Swap(t, &newUploadRequest, func(context.Context, string, string, io.Reader) (*http.Request, error) {
return nil, errors.New("request")
})
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, okClient, func(string) error { return nil }); err == nil {
t.Fatal("request error ignored")
}
})
for _, tc := range []struct {
name string
client *http.Client
}{
{name: "client", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("call") })}},
{name: "server", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("retry"))}, nil
})}},
{name: "close", client: &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: uploadCloseErrorBody{Reader: strings.NewReader("ok")}}, nil
})}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := putFileWithClient(context.Background(), "http://example.invalid", path, 10, tc.client, func(string) error { return nil }); err == nil {
t.Fatalf("%s retries unexpectedly succeeded", tc.name)
}
})
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
retryClient := &http.Client{Transport: uploadRoundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("retry"))}, nil
})}
if _, err := putFileWithClient(cancelled, "http://example.invalid", path, 10, retryClient, func(string) error { return nil }); err == nil {
t.Fatal("cancelled retry succeeded")
}
}
func TestCrossPlatformCoveragePutFileRejectsRedirectAndInvalidFileE2E(t *testing.T) {
path := filepath.Join(t.TempDir(), "audio.wav")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusFound) }))
defer redirect.Close()
client := redirect.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
if _, err := putFileWithClient(context.Background(), redirect.URL, path, 100, client, func(string) error { return nil }); err == nil {
t.Fatal("redirect accepted")
}
if _, err := putFileWithClient(context.Background(), redirect.URL, filepath.Join(t.TempDir(), "missing"), 100, client, func(string) error { return nil }); err == nil {
t.Fatal("missing file accepted")
}
}
@@ -0,0 +1,48 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageMinutesSemanticCatalogMatchesRegistryE2E(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_minutes.json")
if err != nil {
t.Fatal(err)
}
var catalog struct {
Shortcuts map[string]struct {
Public bool `json:"public"`
Reviewed bool `json:"reviewed"`
Delta string `json:"semantic_delta"`
} `json:"shortcuts"`
}
if err := json.Unmarshal(raw, &catalog); err != nil {
t.Fatal(err)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "minutes" {
registered[item.Command] = item
}
}
if len(registered) != len(catalog.Shortcuts) {
t.Fatalf("minutes registry/catalog count = %d/%d", len(registered), len(catalog.Shortcuts))
}
for command, record := range catalog.Shortcuts {
item, ok := registered[command]
if !ok {
t.Errorf("catalog command %s is not registered", command)
continue
}
if !record.Reviewed || record.Delta == "" || item.Hidden == record.Public || !item.SemanticReviewed || item.Contract.Empty() {
t.Errorf("minutes %s review/public/contract mismatch: record=%#v item=%#v", command, record, item)
}
}
}
+25 -14
View File
@@ -1049,14 +1049,6 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
},
wantError: "biz-preserved",
},
{
name: "unverified update preserves id",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"bizId":"biz-unverified"}`,
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}},
wantError: "biz-unverified",
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
@@ -1074,6 +1066,27 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
})
}
t.Run("success acknowledgement completes composite update", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"bizId":"biz-acknowledged"}`,
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "完成",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("calls = %#v", fake.calls)
}
})
for _, args := range [][]string{
{"--group", "cid", "--content", "x", "--flow-status", "6"},
{"--group", "cid", "--flow-status", "2"},
@@ -1091,7 +1104,7 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
}
}
func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T) {
func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"result":{"bizId":"biz-confirm","updated":true}}`,
@@ -1115,7 +1128,7 @@ func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T
}
})
t.Run("generic success is unverified", func(t *testing.T) {
t.Run("success acknowledgement is verified", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
}}
@@ -1128,10 +1141,8 @@ func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T
"--flow-status", "3",
"--yes",
})
err := root.Execute()
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" {
t.Fatalf("calls = %#v", fake.calls)
+27 -3
View File
@@ -59,9 +59,8 @@ func isCardBizIDPlaceholder(value string) bool {
}
// VerifyStreamingCardUpdate requires affirmative evidence that the requested
// write took effect. A transport-level {success:true,errorCode:null} only says
// that the RPC returned normally and is deliberately not accepted as proof of
// a card update.
// write took effect. update_streaming_card may acknowledge an applied write
// with success=true without returning an updated flag or affected count.
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) {
requestedBizID = strings.TrimSpace(requestedBizID)
observation := cardUpdateObservation{bizIDs: map[string]struct{}{}}
@@ -132,6 +131,23 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati
}
}
}
errorCode, hasErrorCode := value["errorCode"]
errorCodeEmpty := hasErrorCode && cardUpdateErrorCodeEmpty(errorCode)
if hasErrorCode && !errorCodeEmpty {
setNegativeCardUpdateEvidence(observation, "errorCode=non-empty")
}
if success, ok := value["success"].(bool); ok {
if success {
// Record success=true only when the same response envelope explicitly
// includes its business-error field. A non-empty code is already
// negative evidence above, so the two signals reject the conflict.
if hasErrorCode {
setPositiveCardUpdateEvidence(observation, "success=true")
}
} else {
setNegativeCardUpdateEvidence(observation, "success=false")
}
}
// Only documented response envelopes are traversed. This prevents an
// unrelated extension field containing "updated":true from proving the
@@ -143,6 +159,14 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati
}
}
func cardUpdateErrorCodeEmpty(value any) bool {
if value == nil {
return true
}
code, ok := value.(string)
return ok && strings.TrimSpace(code) == ""
}
func setPositiveCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
if observation.positiveEvidence == "" {
observation.positiveEvidence = evidence
@@ -62,7 +62,13 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"},
{name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified},
{name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied},
{name: "false success has no write proof", response: map[string]any{"success": true, "errorCode": nil}, wantErrIs: ErrCardUpdateUnverified},
{name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, wantProof: "success=true"},
{name: "success acknowledgement with empty error code", response: map[string]any{"success": true, "errorCode": " "}, wantProof: "success=true"},
{name: "success without explicit error code", response: map[string]any{"success": true}, wantErrIs: ErrCardUpdateUnverified},
{name: "success conflicts with error code", response: map[string]any{"success": true, "errorCode": "InternalError"}, wantErrIs: ErrCardUpdateUnverified},
{name: "success conflicts with numeric error code", response: map[string]any{"success": true, "errorCode": float64(500)}, wantErrIs: ErrCardUpdateUnverified},
{name: "error code without success", response: map[string]any{"errorCode": "InternalError"}, wantErrIs: ErrCardUpdateNotApplied},
{name: "failed acknowledgement", response: map[string]any{"success": false}, wantErrIs: ErrCardUpdateNotApplied},
{name: "explicitly not updated", response: map[string]any{"result": map[string]any{"updated": false}}, wantErrIs: ErrCardUpdateNotApplied},
{name: "mismatched id", response: map[string]any{"result": map[string]any{"bizId": "biz-2", "updated": true}}, wantErrIs: ErrCardUpdateBizIDDrift},
{name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified},
+758
View File
@@ -0,0 +1,758 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
var (
minutesPutFile = localio.PutFile
minutesDownload = localio.Download
)
var Search = shortcut.Shortcut{
Service: "minutes", Command: "+search", Product: "minutes",
Description: "按范围、标题关键词和时间搜索听记,支持安全全量翻页",
Intent: "需要按标题关键词或时间范围搜索自己创建、他人共享或全部可访问听记时使用;对后端返回再做确定性标题匹配,返回稳定 taskUuid 投影与完整性信息。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: minutesContract("+search", "按范围、标题关键词和时间搜索听记,支持安全全量翻页",
"需要按标题关键词或时间范围搜索自己创建、他人共享或全部可访问听记,并拿到可继续操作的 taskUuid 时使用",
[]string{"只想无条件浏览一页时可用 +list-mine/+list-shared/+list-all", "DWS 后端不支持 owner/participant 精确过滤,不要伪造该条件"},
[]string{`dws minutes +search --query "周会" --scope all`, `dws minutes +search --start "2026-08-01T00:00:00+08:00" --scope mine --page-all`}),
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "标题关键词;shortcut 会对后端结果再次精确包含过滤"},
{Name: "scope", Type: shortcut.FlagString, Default: "all", Desc: "搜索范围", Enum: []string{"mine", "shared", "all"}},
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 RFC3339"},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 RFC3339"},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "起始 nextToken"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部页"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "自动翻页安全上限"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "start", "end"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit", "page-limit"}, Description: "--limit/--page-limit 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "时间必须是 RFC3339 且开始时间不能晚于结束时间"},
},
Tips: []string{`dws minutes +search --query "周会" --scope all`, `dws minutes +search --start "2026-08-01T00:00:00+08:00" --scope mine --page-all`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("limit") <= 0 || rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--limit 和 --page-limit 必须大于 0")
}
_, _, err := minutesTimeRange(rt.Str("start"), rt.Str("end"))
return err
},
Execute: executeMinutesSearch,
}
var Download = shortcut.Shortcut{
Service: "minutes", Command: "+download", Product: "minutes",
Description: "批量取得听记音视频地址并安全下载到本地",
Intent: "已知一个或多个 taskUuid,需要取得音视频 OSS 地址或以 no-clobber、原子发布方式保存到工作目录时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: minutesContract("+download", "批量取得听记音视频地址并安全下载到本地",
"已知一个或多个听记 taskUuid,需要下载其音频/视频或显式获取临时 URL 时使用",
[]string{"只要查看摘要或逐字稿时不要下载媒体", "媒体未就绪、已过期或无痕模式时会明确失败,不返回空成功"},
[]string{`dws minutes +download --id <taskUuid> --output ./meeting-media`, `dws minutes +download --ids <uuid1,uuid2> --output-dir ./minutes-downloads`}),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "单个 taskUuid"},
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个 taskUuid,最多 50 个"},
{Name: "url-only", Type: shortcut.FlagBool, Desc: "只返回临时签名 URL,不下载"},
{Name: "output", Type: shortcut.FlagString, Desc: "单个目标的工作目录内相对输出路径"},
{Name: "output-dir", Type: shortcut.FlagString, Default: "minutes-downloads", Desc: "批量下载目录"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "去重后 taskUuid 数量必须为 1..50"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"ids", "output"}, Description: "批量下载不能使用 --output"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"output", "output-dir"}, Description: "输出必须是安全的工作目录相对路径"},
},
Tips: []string{`dws minutes +download --id <taskUuid> --output ./meeting-media`, `dws minutes +download --ids <uuid1,uuid2> --output-dir ./minutes-downloads`},
Validate: func(rt *shortcut.RuntimeContext) error {
ids := minutesIDs(rt)
if len(ids) == 0 || len(ids) > 50 {
return apperrors.NewValidation("taskUuid 数量必须为 1..50")
}
if len(ids) > 1 && rt.Changed("output") {
return apperrors.NewValidation("批量下载不能使用 --output,请使用 --output-dir")
}
if rt.Changed("output") {
return localio.ValidateOutput(rt.Str("output"))
}
return localio.ValidateOutput(rt.Str("output-dir"))
},
Execute: executeMinutesDownload,
}
var Upload = shortcut.Shortcut{
Service: "minutes", Command: "+upload", Product: "minutes",
Description: "把本地音视频完整上传并创建听记,失败时取消会话",
Intent: "需要从本地音视频直接创建听记时使用;完成 create、预签名 PUT、complete 和最终详情读回,不需要中转 Drive。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+upload", "把本地音视频完整上传并创建听记,失败时取消会话",
"用户有本地音视频,希望直接上传生成听记并取得可读回的 taskUuid 时使用",
[]string{"只需要管理已有 upload session 时使用原子 upload create/complete/cancel", "文件为空、过大或不希望创建远端听记时不要执行"},
[]string{`dws minutes +upload --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload --file ./meeting.mp4 --input-language zh`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "本地音视频文件", Required: true},
{Name: "title", Type: shortcut.FlagString, Desc: "听记标题"},
{Name: "template-id", Type: shortcut.FlagString, Desc: "纪要模板 ID"},
{Name: "input-language", Type: shortcut.FlagString, Desc: "ASR 输入语言"},
{Name: "enable-message-card", Type: shortcut.FlagBool, Desc: "生成后推送闪记卡片"},
{Name: "complete-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待服务端确认上传完成的秒数"},
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "2", Desc: "complete 重试间隔秒数"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"complete-timeout", "poll-interval"}, Description: "--complete-timeout 和 --poll-interval 必须大于 0"}},
Tips: []string{`dws minutes +upload --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload --file ./meeting.mp4 --input-language zh`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("complete-timeout") <= 0 || rt.Int("poll-interval") <= 0 {
return apperrors.NewValidation("--complete-timeout 和 --poll-interval 必须大于 0")
}
return nil
},
Execute: executeMinutesUpload,
}
var Update = shortcut.Shortcut{
Service: "minutes", Command: "+update", Product: "minutes",
Description: "读取现状、预览差异、更新听记标题并读回验证",
Intent: "已知 taskUuid,需要安全重命名听记标题并确认远端最终值时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withMinutesDryRun(minutesContract("+update", "读取现状、更新听记标题并读回验证;dry-run 输出不访问远端的执行计划",
"需要修改一条听记的标题,并通过最终读回证明修改生效时使用",
[]string{"要修改纪要正文时使用 +summary"},
[]string{`dws minutes +update --id <taskUuid> --title "Q3 项目复盘"`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "title", Type: shortcut.FlagString, Desc: "新标题", Required: true},
},
Tips: []string{`dws minutes +update --id <taskUuid> --title "Q3 项目复盘"`},
Execute: executeMinutesUpdate,
}
var ApplyPermission = shortcut.Shortcut{
Service: "minutes", Command: "+apply-permission", Product: "minutes",
Description: "为当前用户申请语义化的听记访问权限",
Intent: "打开听记无权限时,用 view/download/edit 语义申请对应权限,不需要记 policy 数字。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+apply-permission", "为当前用户申请语义化的听记访问权限",
"当前用户访问某条听记被拒绝,需要向所有者申请查看、下载或编辑权限时使用",
[]string{"所有者给其他成员授权时使用 +share", "要移除权限时使用 +unshare"},
[]string{`dws minutes +apply-permission --id <taskUuid> --permission view`, `dws minutes +apply-permission --id <taskUuid> --permission edit`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "permission", Type: shortcut.FlagString, Desc: "申请权限", Required: true, Enum: []string{"view", "download", "edit"}},
},
Tips: []string{`dws minutes +apply-permission --id <taskUuid> --permission view`, `dws minutes +apply-permission --id <taskUuid> --permission edit`},
Execute: executeMinutesApplyPermission,
}
var Summary = shortcut.Shortcut{
Service: "minutes", Command: "+summary", Product: "minutes",
Description: "读取当前纪要、校验图片引用、全量覆盖并读回验证",
Intent: "需要安全编辑听记纪要正文时使用;支持字面量、@file 和 stdin,保护现有 Markdown 图片并验证最终全文。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withMinutesDryRun(minutesContract("+summary", "校验本地纪要输入、全量覆盖并读回验证;dry-run 不访问远端",
"需要用确认后的完整 Markdown 覆盖一条听记纪要,并保护已有图片引用时使用",
[]string{"只改标题时使用 +update", "没有完整目标内容时不要执行全量覆盖"},
[]string{`dws minutes +summary --id <taskUuid> --content "项目周会纪要"`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "完整纪要字面量、@相对文件或 - 表示 stdin", Required: true},
},
Tips: []string{`dws minutes +summary --id <taskUuid> --content @summary.md`, `dws minutes +summary --id <taskUuid> --content -`},
Execute: executeMinutesSummary,
}
var SpeakerReplace = shortcut.Shortcut{
Service: "minutes", Command: "+speaker-replace", Product: "minutes",
Description: "预检逐字稿中的发言人昵称,替换后重新读回验证",
Intent: "需要把听记中的一个发言人昵称替换为目标昵称/UID 时使用;这是昵称替换,不是 Lark speaker_id 身份重绑。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withMinutesDryRun(minutesContract("+speaker-replace", "预检逐字稿中的发言人昵称,替换后重新读回验证",
"逐字稿已经生成且用户明确要把一个源发言人昵称批量替换为目标昵称时使用",
[]string{"DWS 不是 speaker_id 到用户 open_id 的身份重绑;需要该语义时当前无法对齐", "源昵称不存在或目标身份不确定时不要写"},
[]string{`dws minutes +speaker-replace --id <taskUuid> --from "发言人1" --to "张三"`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "from", Type: shortcut.FlagString, Desc: "源发言人昵称", Required: true},
{Name: "to", Type: shortcut.FlagString, Desc: "目标发言人昵称", Required: true},
{Name: "target-uid", Type: shortcut.FlagString, Desc: "目标钉钉 UID"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿预检翻页上限"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"from", "to"}, Description: "--from/--to 去空白后不能相同"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
},
Tips: []string{`dws minutes +speaker-replace --id <taskUuid> --from "发言人1" --to "张三"`},
Validate: func(rt *shortcut.RuntimeContext) error {
if strings.TrimSpace(rt.Str("from")) == strings.TrimSpace(rt.Str("to")) {
return apperrors.NewValidation("--from 与 --to 不能相同")
}
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
return nil
},
Execute: executeMinutesSpeakerReplace,
}
func executeMinutesSearch(rt *shortcut.RuntimeContext) error {
start, end, err := minutesTimeRange(rt.Str("start"), rt.Str("end"))
if err != nil {
return err
}
belonging := map[string]string{"mine": "createdByMe", "shared": "sharedToMe", "all": "noLimit"}[rt.Str("scope")]
token := rt.Str("cursor")
seenTokens := map[string]bool{}
seenIDs := map[string]bool{}
rows := make([]map[string]any, 0)
pages := 0
complete := false
nextToken := ""
for {
if seenTokens[token] {
return fmt.Errorf("minutes search cursor stalled or cycled")
}
seenTokens[token] = true
params := map[string]any{"belongingConditionId": belonging, "maxResults": rt.Int("limit")}
if query := strings.TrimSpace(rt.Str("query")); query != "" {
params["keyword"] = query
}
if start != 0 {
params["createTimeStart"] = float64(start)
}
if end != 0 {
params["createTimeEnd"] = float64(end)
}
if token != "" {
params["nextToken"] = token
}
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", params)
if err != nil {
return err
}
page, err := minutesdata.ParseListPage(data)
if err != nil {
return err
}
projected, err := minutesdata.ProjectList(page)
if err != nil {
return err
}
for _, row := range projected {
id := row["taskUuid"].(string)
if seenIDs[id] {
continue
}
seenIDs[id] = true
rows = append(rows, row)
}
pages++
nextToken = page.NextToken
if !page.HasMore {
complete = true
nextToken = ""
break
}
if !rt.Bool("page-all") {
break
}
if pages >= rt.Int("page-limit") {
return fmt.Errorf("minutes search exceeded page safety limit %d", rt.Int("page-limit"))
}
token = page.NextToken
}
scanned := len(rows)
if query := strings.TrimSpace(rt.Str("query")); query != "" {
needle := strings.ToLower(query)
filtered := make([]map[string]any, 0, len(rows))
for _, row := range rows {
title, _ := row["title"].(string)
if strings.Contains(strings.ToLower(title), needle) {
filtered = append(filtered, row)
}
}
rows = filtered
}
payload := map[string]any{"count": len(rows), "scannedCount": scanned, "minutes": rows, "pages": pages, "complete": complete}
if nextToken != "" {
payload["nextToken"] = nextToken
}
return rt.Output(payload)
}
func executeMinutesDownload(rt *shortcut.RuntimeContext) error {
ids := minutesIDs(rt)
results := make([]map[string]any, 0, len(ids))
failures := make([]map[string]any, 0)
for _, id := range ids {
data, err := rt.CallMCPData("minutes", "query_minutes_audio_url", map[string]any{"taskUuid": id})
if err != nil {
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
continue
}
mediaURL, err := minutesdata.MediaURL(data)
if err != nil {
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
continue
}
if rt.Bool("url-only") {
results = append(results, map[string]any{"taskUuid": id, "url": mediaURL})
continue
}
output := rt.Str("output-dir") + "/"
if len(ids) == 1 && rt.Changed("output") {
output = rt.Str("output")
}
preferred := id + mediaExtension(mediaURL)
download, err := minutesDownload(rt.Command().Context(), mediaURL, localio.DownloadOptions{Output: output, PreferredName: preferred})
if err != nil {
failures = append(failures, map[string]any{"taskUuid": id, "error": err.Error()})
continue
}
results = append(results, map[string]any{"taskUuid": id, "path": download.RelativePath, "sizeBytes": download.SizeBytes})
}
payload := map[string]any{"ok": len(failures) == 0, "requested": len(ids), "succeeded": len(results), "failed": len(failures), "results": results}
if len(failures) > 0 {
payload["failures"] = failures
}
if err := rt.Output(payload); err != nil {
return err
}
if len(failures) > 0 {
return minutesCompositeError("minutes_download_incomplete", "download", payload)
}
return nil
}
func executeMinutesUpload(rt *shortcut.RuntimeContext) error {
payload, err := performMinutesUpload(rt)
if err != nil {
return err
}
return rt.Output(payload)
}
func performMinutesUpload(rt *shortcut.RuntimeContext) (map[string]any, error) {
info, err := os.Stat(rt.Str("file"))
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 {
return nil, apperrors.NewValidation("--file 必须是非空普通文件")
}
plan := map[string]any{"operation": "minutes.upload", "fileName": info.Name(), "sizeBytes": info.Size(), "title": rt.Str("title")}
if rt.DryRun() {
return minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.upload", plan), nil
}
params := map[string]any{"fileName": info.Name(), "fileSize": info.Size()}
if value := strings.TrimSpace(rt.Str("title")); value != "" {
params["title"] = value
}
option := map[string]any{}
if value := strings.TrimSpace(rt.Str("template-id")); value != "" {
option["templateId"] = value
}
if value := strings.TrimSpace(rt.Str("input-language")); value != "" {
option["inputLanguage"] = value
}
if rt.Changed("enable-message-card") {
option["enableMessageCard"] = rt.Bool("enable-message-card")
}
if len(option) > 0 {
params["minutesOption"] = option
}
created, err := rt.CallMCPWriteDataStrict("minutes", "create_upload_session", params)
if err != nil {
return nil, err
}
sessionID, presignedURL, err := minutesdata.UploadSession(created)
if err != nil {
return nil, err
}
transfer, err := minutesPutFile(rt.Command().Context(), presignedURL, rt.Str("file"), 0)
if err != nil {
cancelled, cancelErr := cancelMinutesUpload(rt, sessionID)
return nil, minutesUploadFailure("put", sessionID, cancelled, cancelErr, err)
}
completed, completeAttempts, err := completeMinutesUpload(rt, sessionID, time.Duration(rt.Int("complete-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
if err != nil {
return nil, minutesUploadCompletionUnknown(sessionID, completeAttempts, err)
}
taskUUID, err := minutesdata.CompletedTaskUUID(completed)
if err != nil {
return nil, minutesUploadFailure("complete_response", sessionID, false, nil, err)
}
readBack, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": taskUUID})
if err != nil {
return nil, minutesUploadFailure("verify", sessionID, false, nil, err)
}
if _, err := minutesdata.Basic(taskUUID, readBack); err != nil {
return nil, minutesUploadFailure("verify", sessionID, false, nil, err)
}
return map[string]any{"operation": "minutes.upload", "complete": true, "sessionId": sessionID, "taskUuid": taskUUID, "sizeBytes": transfer.SizeBytes, "uploadAttempts": transfer.Attempts, "completeAttempts": completeAttempts, "verified": true}, nil
}
func executeMinutesUpdate(rt *shortcut.RuntimeContext) error {
id, title := rt.Str("id"), strings.TrimSpace(rt.Str("title"))
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.update", map[string]any{
"taskUuid": id, "after": title, "remotePreconditions": []string{"read current title", "write title", "read back exact title"},
}))
}
beforeData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
if err != nil {
return err
}
before, err := minutesdata.Basic(id, beforeData)
if err != nil {
return err
}
oldTitle, _ := before["title"].(string)
if oldTitle == title {
return rt.Output(map[string]any{"taskUuid": id, "changed": false, "verified": true, "title": title})
}
if _, err := rt.CallMCPWriteDataStrict("minutes", "update_minutes_title", map[string]any{"taskUuid": id, "title": title}); err != nil {
return err
}
afterData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
if err != nil {
return err
}
after, err := minutesdata.Basic(id, afterData)
if err != nil || after["title"] != title {
return minutesCompositeError("minutes_title_verification_failed", "verify", map[string]any{"taskUuid": id, "expectedTitle": title})
}
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "before": oldTitle, "after": title})
}
func executeMinutesApplyPermission(rt *shortcut.RuntimeContext) error {
policy := map[string]float64{"edit": 2, "download": 3, "view": 4}[rt.Str("permission")]
plan := map[string]any{"taskUuid": rt.Str("id"), "permission": rt.Str("permission"), "policyId": policy}
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.apply_permission", plan))
}
data, err := rt.CallMCPWriteDataStrict("minutes", "apply_minutes_permission", map[string]any{"taskUuid": rt.Str("id"), "policyId": policy})
if err != nil {
return err
}
if err := minutesdata.RequireWriteAcknowledgement("apply permission", data); err != nil {
return err
}
plan["requested"], plan["result"] = true, data["result"]
return rt.Output(plan)
}
func executeMinutesSummary(rt *shortcut.RuntimeContext) error {
id := rt.Str("id")
after, err := localio.ReadTextInput(rt.Str("content"), rt.Command().InOrStdin(), 8<<20)
if err != nil {
return apperrors.NewValidation(err.Error())
}
if strings.TrimSpace(after) == "" {
return apperrors.NewValidation("纪要内容不能为空")
}
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.summary", map[string]any{
"taskUuid": id, "afterBytes": len(after), "remotePreconditions": []string{"read current summary", "preserve existing Markdown images", "write summary", "read back exact summary"},
}))
}
beforeData, err := rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
if err != nil {
return err
}
before, err := minutesdata.SummaryText(beforeData)
if err != nil {
return err
}
if missing := missingMarkdownImages(before, after); len(missing) > 0 {
return apperrors.NewValidation(fmt.Sprintf("新纪要丢失 %d 个原有 Markdown 图片引用", len(missing)))
}
if before == after {
return rt.Output(map[string]any{"taskUuid": id, "changed": false, "verified": true})
}
if _, err := rt.CallMCPWriteDataStrict("minutes", "update_minutes_summary", map[string]any{"taskUuid": id, "summaryText": after}); err != nil {
return err
}
readBackData, err := rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
if err != nil {
return err
}
readBack, err := minutesdata.SummaryText(readBackData)
if err != nil || readBack != after {
return minutesCompositeError("minutes_summary_verification_failed", "verify", map[string]any{"taskUuid": id, "expectedBytes": len(after)})
}
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "beforeBytes": len(before), "afterBytes": len(after), "preservedImages": true})
}
func executeMinutesSpeakerReplace(rt *shortcut.RuntimeContext) error {
id, from, to := rt.Str("id"), rt.Str("from"), rt.Str("to")
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.speaker_replace", map[string]any{
"taskUuid": id, "from": from, "to": to, "targetUid": strings.TrimSpace(rt.Str("target-uid")),
"remotePreconditions": []string{"read full transcript", "verify source speaker exists", "replace speaker", "read back full transcript"},
}))
}
before, err := collectTranscriptForMinutes(rt, id, rt.Int("page-limit"))
if err != nil {
return err
}
beforeSpeakers := speakerCounts(before.Paragraphs)
if beforeSpeakers[from] == 0 {
return apperrors.NewValidation(fmt.Sprintf("逐字稿中不存在发言人 %q", from))
}
params := map[string]any{"taskUuid": id, "speakerNick": from, "targetNickName": to}
if uid := strings.TrimSpace(rt.Str("target-uid")); uid != "" {
params["targetUid"] = uid
}
if _, err := rt.CallMCPWriteDataStrict("minutes", "replace_speaker", params); err != nil {
return err
}
after, err := collectTranscriptForMinutes(rt, id, rt.Int("page-limit"))
if err != nil {
return err
}
afterSpeakers := speakerCounts(after.Paragraphs)
if afterSpeakers[from] != 0 || afterSpeakers[to] < beforeSpeakers[from] {
return minutesCompositeError("minutes_speaker_verification_failed", "verify", map[string]any{"taskUuid": id, "fromRemaining": afterSpeakers[from], "targetCount": afterSpeakers[to]})
}
return rt.Output(map[string]any{"taskUuid": id, "changed": true, "verified": true, "from": from, "to": to, "affectedParagraphs": beforeSpeakers[from]})
}
func minutesTimeRange(startRaw, endRaw string) (int64, int64, error) {
parse := func(name, raw string) (int64, error) {
if strings.TrimSpace(raw) == "" {
return 0, nil
}
value, err := time.Parse(time.RFC3339, raw)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间: %v", name, err))
}
return value.UnixMilli(), nil
}
start, err := parse("start", startRaw)
if err != nil {
return 0, 0, err
}
end, err := parse("end", endRaw)
if err != nil {
return 0, 0, err
}
if start != 0 && end != 0 && start > end {
return 0, 0, apperrors.NewValidation("--start 不能晚于 --end")
}
return start, end, nil
}
func minutesIDs(rt *shortcut.RuntimeContext) []string {
values := []string{}
if id := strings.TrimSpace(rt.Str("id")); id != "" {
values = append(values, id)
}
values = append(values, rt.StrSlice("ids")...)
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, value := range values {
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item != "" && !seen[item] {
seen[item] = true
out = append(out, item)
}
}
}
return out
}
func mediaExtension(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
ext := strings.ToLower(filepath.Ext(parsed.Path))
if len(ext) > 10 {
return ""
}
return ext
}
func cancelMinutesUpload(rt *shortcut.RuntimeContext, sessionID string) (bool, error) {
data, err := rt.CallMCPWriteDataStrict("minutes", "cancel_upload_session", map[string]any{"sessionId": sessionID})
if err != nil {
return false, err
}
if err := minutesdata.RequireWriteAcknowledgement("cancel upload", data); err != nil {
return false, err
}
return true, nil
}
func completeMinutesUpload(rt *shortcut.RuntimeContext, sessionID string, timeout, interval time.Duration) (map[string]any, int, error) {
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
data, err := rt.CallMCPWriteDataStrict("minutes", "complete_upload_session", map[string]any{"sessionId": sessionID})
if err == nil {
return data, attempts, nil
}
message := strings.ToLower(err.Error())
retryable := strings.Contains(message, "still uploading") || strings.Contains(message, "wait and retry") || strings.Contains(message, "正在上传")
if !retryable || time.Now().Add(interval).After(deadline) {
return nil, attempts, err
}
timer := time.NewTimer(interval)
select {
case <-rt.Command().Context().Done():
timer.Stop()
return nil, attempts, rt.Command().Context().Err()
case <-timer.C:
}
}
}
func minutesUploadFailure(stage, sessionID string, cancelled bool, cancelErr, cause error) error {
details := map[string]any{"stage": stage, "sessionId": sessionID, "cancelled": cancelled}
if cause != nil {
details["cause"] = cause.Error()
}
if cancelErr != nil {
details["cancelError"] = cancelErr.Error()
}
return apperrors.NewAPI(
fmt.Sprintf("听记上传在 %s 阶段失败", stage),
apperrors.WithOperation("minutes/+upload"),
apperrors.WithOrigin("shortcut"),
apperrors.WithFailureStage(stage),
apperrors.WithReason("minutes_upload_failed"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithDetails(details),
)
}
func minutesUploadCompletionUnknown(sessionID string, attempts int, cause error) error {
details := map[string]any{
"stage": "complete",
"sessionId": sessionID,
"completeAttempts": attempts,
"cancelled": false,
"remoteEffect": "unknown",
"recovery": map[string]any{
"action": "verify_before_cancel",
"nextAction": "保留 sessionId;先重试 complete_upload_session 或查询最近听记确认服务端状态,只有明确证明未完成时才取消会话",
},
}
if cause != nil {
details["cause"] = cause.Error()
}
return apperrors.NewAPI(
"听记上传 complete 请求的远端结果未知;为避免破坏可能已完成的上传,已保留会话且未自动取消",
apperrors.WithOperation("minutes/+upload"),
apperrors.WithOrigin("shortcut"),
apperrors.WithFailureStage("complete"),
apperrors.WithReason("minutes_upload_completion_unknown"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithHint("不要重新上传或直接取消;先使用错误详情中的 sessionId 核验服务端状态"),
apperrors.WithActions("retry complete_upload_session with the preserved sessionId", "query recent Minutes before cancelling the upload session"),
apperrors.WithDetails(details),
)
}
func minutesCompositeError(reason, stage string, details map[string]any) error {
return apperrors.NewAPI(
"Minutes shortcut 未完成,详见结构化错误",
apperrors.WithOperation("minutes/shortcut"),
apperrors.WithOrigin("shortcut"),
apperrors.WithFailureStage(stage),
apperrors.WithReason(reason),
apperrors.WithRetryable(false),
apperrors.WithDetails(details),
)
}
var markdownImageRE = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
func missingMarkdownImages(before, after string) []string {
remaining := map[string]bool{}
for _, match := range markdownImageRE.FindAllStringSubmatch(after, -1) {
remaining[match[1]] = true
}
missing := []string{}
for _, match := range markdownImageRE.FindAllStringSubmatch(before, -1) {
if !remaining[match[1]] {
missing = append(missing, match[1])
}
}
sort.Strings(missing)
return missing
}
func collectTranscriptForMinutes(rt *shortcut.RuntimeContext, taskUUID string, pageLimit int) (minutesdata.TranscriptResult, error) {
return minutesdata.CollectTranscript(func(nextToken string) (map[string]any, error) {
params := map[string]any{"taskUuid": taskUUID, "direction": "0"}
if nextToken != "" {
params["nextToken"] = nextToken
}
return rt.CallMCPData("minutes", "get_minutes_transcription", params)
}, "", false, pageLimit)
}
func speakerCounts(paragraphs []map[string]any) map[string]int {
counts := map[string]int{}
var visit func(any, map[string]bool)
visit = func(value any, seen map[string]bool) {
switch typed := value.(type) {
case map[string]any:
local := map[string]bool{}
for _, key := range []string{"speakerNick", "nickName", "nickname", "subSpeakerNickname"} {
if name, ok := typed[key].(string); ok && strings.TrimSpace(name) != "" {
local[strings.TrimSpace(name)] = true
}
}
for name := range local {
seen[name] = true
}
for _, child := range typed {
visit(child, seen)
}
case []any:
for _, child := range typed {
visit(child, seen)
}
}
}
for _, paragraph := range paragraphs {
seen := map[string]bool{}
visit(paragraph, seen)
for name := range seen {
counts[name]++
}
}
return counts
}
func init() {
shortcut.Register(finalizeMinutesShortcuts(Search, Download, Upload, Update, ApplyPermission, Summary, SpeakerReplace)...)
}
@@ -0,0 +1,517 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
type minutesFailWriter struct{}
func (minutesFailWriter) Write([]byte) (int, error) { return 0, errors.New("fixture output failure") }
func runMinutesAlignmentCLIWithWriter(t *testing.T, caller *minutesE2ECaller, writer io.Writer, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
root.SetOut(writer)
root.SetErr(&bytes.Buffer{})
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMinutesAlignmentValidationAndHelpers(t *testing.T) {
caller := &minutesE2ECaller{}
manyIDs := make([]string, 51)
for index := range manyIDs {
manyIDs[index] = fmt.Sprintf("u%d", index)
}
invalid := [][]string{
{"minutes", "+search", "--query", "x", "--limit", "0"},
{"minutes", "+search", "--query", "x", "--start", "bad"},
{"minutes", "+search", "--query", "x", "--end", "bad"},
{"minutes", "+search", "--query", "x", "--start", "2026-08-02T00:00:00Z", "--end", "2026-08-01T00:00:00Z"},
{"minutes", "+download", "--id", "u1", "--ids", "u2"},
{"minutes", "+download", "--ids", strings.Join(manyIDs, ",")},
{"minutes", "+download", "--ids", "u1,u2", "--output", "one"},
{"minutes", "+download", "--id", "u1", "--output", "../escape"},
{"minutes", "+upload", "--file", "missing", "--complete-timeout", "0"},
{"minutes", "+speaker-replace", "--id", "u1", "--from", "same", "--to", " same "},
{"minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--page-limit", "0"},
}
for _, args := range invalid {
if payload, output, err := runMinutesAlignmentCLI(t, caller, args...); err == nil || payload != nil || output != "" {
t.Fatalf("invalid argv accepted: %v payload=%#v output=%q err=%v", args, payload, output, err)
}
}
if start, end, err := minutesTimeRange("2026-08-01T00:00:00Z", "2026-08-02T00:00:00Z"); err != nil || start == 0 || end <= start {
t.Fatalf("valid range = %d %d %v", start, end, err)
}
if got := mediaExtension("https://example.invalid/a.MP3?token=x"); got != ".mp3" {
t.Fatalf("extension = %q", got)
}
if got := mediaExtension("://bad"); got != "" {
t.Fatalf("bad extension = %q", got)
}
if got := mediaExtension("https://example.invalid/a.12345678901"); got != "" {
t.Fatalf("long extension = %q", got)
}
counts := speakerCounts([]map[string]any{{
"speakerNick": " A ", "nested": []any{map[string]any{"nickName": "A", "subSpeakerNickname": "B"}},
}})
if counts["A"] != 1 || counts["B"] != 1 {
t.Fatalf("speaker counts = %#v", counts)
}
decl := corecmd.ContractDecl{Parameters: []contract.ParamDecl{
{Name: "a", Description: "parameter"},
{Name: "a", Description: "already evidence"},
{Name: "other", Description: "other"},
}}
value := shortcut.Shortcut{Description: "desc", Intent: "intent", Contract: decl,
Flags: []shortcut.Flag{{Name: "a", Desc: "flag"}},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"a"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}},
},
}
finalized := finalizeMinutesShortcuts(value)[0]
if finalized.Contract.Selection.AgentSummary != "desc" || strings.Contains(finalized.Flags[0].Desc, "约束") {
t.Fatalf("finalized = %#v", finalized)
}
withEvidence := value
withEvidence.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}, Description: "evidence"}}
withEvidence.Flags[0].Desc = "flag evidence"
withEvidence.Contract.Parameters[1].Description = "already evidence"
finalized = finalizeMinutesShortcuts(withEvidence)[0]
if !strings.Contains(finalized.Contract.Parameters[0].Description, "约束:evidence") || finalized.Contract.Parameters[1].Description != "already evidence" || finalized.Contract.Parameters[2].Description != "other" {
t.Fatalf("parameter evidence = %#v", finalized.Contract.Parameters)
}
payload := minutesDryRunPayload("plan", "op", nil)
if payload["operation"] != "op" || payload["executed"] != false {
t.Fatalf("dry-run payload = %#v", payload)
}
}
func TestCrossPlatformCoverageMinutesSearchFailureAndCursorBranchesE2E(t *testing.T) {
cases := []struct {
name string
caller *minutesE2ECaller
args []string
wantError bool
wantNext string
}{
{name: "call", caller: &minutesE2ECaller{failAt: map[string]int{"minutes/list_by_keyword_and_time_range": 1}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
{name: "parse", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
{name: "project", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"title":"x"}],"hasNext":false}}`}}}, args: []string{"minutes", "+search", "--query", "x"}, wantError: true},
{name: "single page", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"x"}],"hasNext":true,"nextToken":"n2"}}`}}}, args: []string{"minutes", "+search", "--start", "2026-08-01T00:00:00Z", "--end", "2026-08-02T00:00:00Z", "--cursor", "n1"}, wantNext: "n2"},
{name: "limit", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":true,"nextToken":"n2"}}`}}}, args: []string{"minutes", "+search", "--query", "x", "--page-all", "--page-limit", "1"}, wantError: true},
{name: "cycle", caller: &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":true,"nextToken":"n1"}}`}}}, args: []string{"minutes", "+search", "--query", "x", "--page-all", "--cursor", "n1"}, wantError: true},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
payload, _, err := runMinutesAlignmentCLI(t, test.caller, test.args...)
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if test.wantNext != "" && payload["nextToken"] != test.wantNext {
t.Fatalf("payload=%#v", payload)
}
})
}
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{responses: map[string][]string{
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":false}}`},
}}, minutesFailWriter{}, "minutes", "+search", "--query", "x"); err == nil {
t.Fatal("output failure accepted")
}
// Execute is defensive even when invoked without the Shortcut validator.
cmd := &cobra.Command{Use: "search"}
cmd.Flags().String("start", "bad", "")
cmd.Flags().String("end", "", "")
cmd.Flags().String("scope", "all", "")
cmd.Flags().String("cursor", "", "")
cmd.Flags().String("query", "x", "")
cmd.Flags().Int("limit", 20, "")
cmd.Flags().Int("page-limit", 100, "")
cmd.Flags().Bool("page-all", false, "")
if err := executeMinutesSearch(shortcut.RuntimeContextForTest(cmd, Search)); err == nil {
t.Fatal("direct invalid time accepted")
}
}
func TestCrossPlatformCoverageMinutesDownloadBranchesE2E(t *testing.T) {
testseam.Swap(t, &minutesDownload, func(_ context.Context, rawURL string, options localio.DownloadOptions) (localio.DownloadResult, error) {
if strings.Contains(rawURL, "fail") {
return localio.DownloadResult{}, errors.New("fixture download failure")
}
return localio.DownloadResult{RelativePath: filepath.ToSlash(filepath.Join(options.Output, options.PreferredName)), SizeBytes: 12}, nil
})
caller := &minutesE2ECaller{
responses: map[string][]string{"minutes/query_minutes_audio_url": {
`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`,
`{"success":true,"result":{"downloadUrl":"https://example.invalid/fail.mp4"}}`,
}},
failAt: map[string]int{"minutes/query_minutes_audio_url": 3},
}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+download", "--ids", "u1,u2,u3", "--output-dir", "media")
if err == nil || output == "" || payload["succeeded"] != float64(1) || payload["failed"] != float64(2) {
t.Fatalf("download payload=%#v err=%v", payload, err)
}
urlOnly := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`}}}
if payload, _, err = runMinutesAlignmentCLI(t, urlOnly, "minutes", "+download", "--id", "u1", "--url-only"); err != nil || payload["succeeded"] != float64(1) {
t.Fatalf("url-only payload=%#v err=%v", payload, err)
}
if err := runMinutesAlignmentCLIWithWriter(t, urlOnly, minutesFailWriter{}, "minutes", "+download", "--id", "u1", "--url-only"); err == nil {
t.Fatal("download output failure accepted")
}
single := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/u1.mp3"}}`}}}
if payload, _, err = runMinutesAlignmentCLI(t, single, "minutes", "+download", "--id", "u1", "--output", "single.mp3"); err != nil || payload["succeeded"] != float64(1) {
t.Fatalf("single payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesUploadFailureAndSuccessBranchesE2E(t *testing.T) {
file := filepath.Join(t.TempDir(), "source.wav")
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
t.Fatal(err)
}
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload", "--file", t.TempDir(), "--yes"); err == nil || payload != nil || output != "" {
t.Fatalf("directory upload accepted: %#v %q %v", payload, output, err)
}
createFail := &minutesE2ECaller{failAt: map[string]int{"minutes/create_upload_session": 1}}
if _, _, err := runMinutesAlignmentCLI(t, createFail, "minutes", "+upload", "--file", file, "--yes"); err == nil {
t.Fatal("create failure accepted")
}
badSession := &minutesE2ECaller{responses: map[string][]string{"minutes/create_upload_session": {`{"success":true,"result":{}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, badSession, "minutes", "+upload", "--file", file, "--yes"); err == nil {
t.Fatal("bad session accepted")
}
for _, test := range []struct {
name string
cancelRaw string
cancelFailAt int
}{
{name: "cancel success", cancelRaw: `{"success":true,"result":{}}`},
{name: "cancel call failure", cancelFailAt: 1},
{name: "cancel acknowledgement failure", cancelRaw: `{"result":{}}`},
} {
t.Run(test.name, func(t *testing.T) {
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
return localio.UploadResult{}, errors.New("put failed")
})
caller := &minutesE2ECaller{
responses: map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
"minutes/cancel_upload_session": {test.cancelRaw},
},
failAt: map[string]int{"minutes/cancel_upload_session": test.cancelFailAt},
}
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes"); err == nil {
t.Fatal("put failure accepted")
}
})
}
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
return localio.UploadResult{SizeBytes: 5, Attempts: 2}, nil
})
base := map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u1"}}`},
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`},
}
success := &minutesE2ECaller{responses: base}
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+upload", "--file", file, "--title", "T", "--template-id", "tpl", "--input-language", "zh", "--enable-message-card", "--yes")
if err != nil || payload["complete"] != true || payload["verified"] != true {
t.Fatalf("upload payload=%#v err=%v", payload, err)
}
cases := []struct {
name string
responses map[string][]string
failAt map[string]int
}{
{name: "invalid completion", responses: map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
"minutes/complete_upload_session": {`{"success":true,"result":{}}`},
}},
{name: "verify call", responses: base, failAt: map[string]int{"minutes/get_minutes_basic_info": 1}},
{name: "verify parse", responses: map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u1"}}`},
"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`},
}},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
caller := &minutesE2ECaller{responses: test.responses, failAt: test.failAt}
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes"); err == nil {
t.Fatal("upload failure accepted")
}
})
}
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+upload", "--file", file, "--dry-run"); err == nil {
t.Fatal("upload output failure accepted")
}
}
func TestCrossPlatformCoverageMinutesUploadErrorConstructors(t *testing.T) {
if err := minutesUploadFailure("put", "s", false, nil, nil); err == nil {
t.Fatal("nil-cause failure missing")
}
if err := minutesUploadCompletionUnknown("s", 1, nil); err == nil {
t.Fatal("nil-cause unknown completion missing")
}
}
func TestCrossPlatformCoverageMinutesUpdatePermissionSummarySpeakerFailuresE2E(t *testing.T) {
// Dry-run branches must not access MCP and must still propagate output errors.
dryCases := [][]string{
{"minutes", "+update", "--id", "u1", "--title", "new", "--dry-run"},
{"minutes", "+summary", "--id", "u1", "--content", "new", "--dry-run"},
{"minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--dry-run"},
}
for _, args := range dryCases {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, args...); err == nil {
t.Fatalf("output failure accepted: %v", args)
}
}
updateCases := []struct {
name string
responses map[string][]string
failAt map[string]int
wantError bool
}{
{name: "before call", failAt: map[string]int{"minutes/get_minutes_basic_info": 1}, wantError: true},
{name: "before parse", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}, wantError: true},
{name: "unchanged", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"new"}}`}}},
{name: "write", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`}}, failAt: map[string]int{"minutes/update_minutes_title": 1}, wantError: true},
{name: "after call", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`}}, failAt: map[string]int{"minutes/get_minutes_basic_info": 2}, wantError: true},
{name: "after parse", responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"old"}}`, `{"success":true,"result":{}}`}, "minutes/update_minutes_title": {`{"success":true,"result":{}}`}}, wantError: true},
}
for _, test := range updateCases {
t.Run("update "+test.name, func(t *testing.T) {
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+update", "--id", "u1", "--title", "new", "--yes")
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
permissionCases := []struct {
name string
responses map[string][]string
failAt map[string]int
writer io.Writer
}{
{name: "call", failAt: map[string]int{"minutes/apply_minutes_permission": 1}},
{name: "ack", responses: map[string][]string{"minutes/apply_minutes_permission": {`{"result":{}}`}}},
{name: "success output", responses: map[string][]string{"minutes/apply_minutes_permission": {`{"success":true,"result":{}}`}}, writer: minutesFailWriter{}},
}
for _, test := range permissionCases {
t.Run("permission "+test.name, func(t *testing.T) {
writer := test.writer
if writer == nil {
writer = &bytes.Buffer{}
}
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, writer, "minutes", "+apply-permission", "--id", "u1", "--permission", "view", "--yes"); err == nil {
t.Fatal("permission failure accepted")
}
})
}
permissionSuccess := &minutesE2ECaller{responses: map[string][]string{"minutes/apply_minutes_permission": {`{"success":true,"result":{}}`}}}
if payload, _, err := runMinutesAlignmentCLI(t, permissionSuccess, "minutes", "+apply-permission", "--id", "u1", "--permission", "view", "--yes"); err != nil || payload["requested"] != true {
t.Fatalf("permission payload=%#v err=%v", payload, err)
}
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+summary", "--id", "u1", "--content", "@missing", "--yes"); err == nil {
t.Fatal("missing input accepted")
}
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+summary", "--id", "u1", "--content", " ", "--yes"); err == nil {
t.Fatal("empty summary accepted")
}
cmd := &cobra.Command{Use: "summary"}
cmd.Flags().String("id", "u1", "")
cmd.Flags().String("content", " ", "")
if err := executeMinutesSummary(shortcut.RuntimeContextForTest(cmd, Summary)); err == nil {
t.Fatal("direct empty summary accepted")
}
summaryCases := []struct {
name string
responses map[string][]string
failAt map[string]int
wantError bool
}{
{name: "before call", failAt: map[string]int{"minutes/get_minutes_ai_summary": 1}, wantError: true},
{name: "before parse", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{}}`}}, wantError: true},
{name: "unchanged", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"new"}}`}}},
{name: "write", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`}}, failAt: map[string]int{"minutes/update_minutes_summary": 1}, wantError: true},
{name: "readback call", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`}}, failAt: map[string]int{"minutes/get_minutes_ai_summary": 2}, wantError: true},
{name: "readback parse", responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"old"}}`, `{"success":true,"result":{}}`}, "minutes/update_minutes_summary": {`{"success":true,"result":{}}`}}, wantError: true},
}
for _, test := range summaryCases {
t.Run("summary "+test.name, func(t *testing.T) {
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+summary", "--id", "u1", "--content", "new", "--yes")
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
speakerCases := []struct {
name string
responses map[string][]string
failAt map[string]int
}{
{name: "before", failAt: map[string]int{"minutes/get_minutes_transcription": 1}},
{name: "missing", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"x"}],"hasNext":false}}`}}},
{name: "write", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}}, failAt: map[string]int{"minutes/replace_speaker": 1}},
{name: "after", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}}, failAt: map[string]int{"minutes/get_minutes_transcription": 2}},
{name: "verify", responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`, `{"success":true,"result":{"paragraphList":[{"speakerNick":"a"}],"hasNext":false}}`}, "minutes/replace_speaker": {`{"success":true,"result":{}}`}}},
}
for _, test := range speakerCases {
t.Run("speaker "+test.name, func(t *testing.T) {
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, "minutes", "+speaker-replace", "--id", "u1", "--from", "a", "--to", "b", "--target-uid", "uid", "--yes"); err == nil {
t.Fatal("speaker failure accepted")
}
})
}
}
func TestCrossPlatformCoverageMinutesCompleteUploadRetryAndContext(t *testing.T) {
// Use the real runtime/caller path so retry classification is tested, while
// zero intervals keep the fault-injection cases deterministic and fast.
for _, test := range []struct {
name string
responses []string
failAt int
failError error
cancel bool
timeout time.Duration
interval time.Duration
wantCalls int
wantError bool
}{
{name: "success", responses: []string{`{"success":true,"result":{"taskUuid":"u1"}}`}, timeout: time.Second, wantCalls: 1},
{name: "retry success", responses: []string{`{"success":true,"result":{"taskUuid":"u1"}}`}, failAt: 1, failError: errors.New("still uploading"), timeout: time.Second, wantCalls: 2},
{name: "nonretryable", failAt: 1, failError: errors.New("denied"), timeout: time.Second, wantCalls: 1, wantError: true},
{name: "timeout", failAt: 1, failError: errors.New("wait and retry"), wantCalls: 1, wantError: true},
{name: "context", failAt: 1, failError: errors.New("正在上传"), cancel: true, timeout: 2 * time.Hour, interval: time.Hour, wantCalls: 1, wantError: true},
} {
t.Run(test.name, func(t *testing.T) {
key := "minutes/complete_upload_session"
caller := &minutesE2ECaller{
responses: map[string][]string{key: test.responses},
failAt: map[string]int{key: test.failAt},
failErrors: map[string]error{key: test.failError},
}
helpers.InitDepsForTest(t, caller)
ctx := context.Background()
if test.cancel {
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
cancel()
}
cmd := &cobra.Command{Use: "test"}
cmd.SetContext(ctx)
rt := shortcut.RuntimeContextForTest(cmd, Upload)
_, attempts, err := completeMinutesUpload(rt, "s1", test.timeout, test.interval)
if (err != nil) != test.wantError || attempts != test.wantCalls || caller.counts[key] != test.wantCalls {
t.Fatalf("attempts=%d calls=%d err=%v", attempts, caller.counts[key], err)
}
})
}
}
func TestCrossPlatformCoverageMinutesLegacyListsAndRecordCommandsE2E(t *testing.T) {
listSuccess := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"weekly"}],"hasNext":false}}`}}}
for _, command := range []string{"+list-mine", "+list-shared", "+list-all"} {
payload, _, err := runMinutesAlignmentCLI(t, listSuccess, "minutes", command, "--query", "weekly", "--limit", "5", "--cursor", "n1")
if err != nil || payload["count"] != float64(1) {
t.Fatalf("%s payload=%#v err=%v", command, payload, err)
}
}
if calls := listSuccess.arguments["minutes/list_by_keyword_and_time_range"]; len(calls) != 3 || calls[0]["maxResults"] != 5 || calls[0]["keyword"] != "weekly" || calls[0]["nextToken"] != "n1" {
t.Fatalf("list args=%#v", calls)
}
listCall := &minutesE2ECaller{failAt: map[string]int{"minutes/list_by_keyword_and_time_range": 1}}
if _, _, err := runMinutesAlignmentCLI(t, listCall, "minutes", "+list-all"); err == nil {
t.Fatal("list call failure accepted")
}
listParse := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, listParse, "minutes", "+list-all"); err == nil {
t.Fatal("list parse failure accepted")
}
listProject := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"title":"missing id"}]}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, listProject, "minutes", "+list-all"); err == nil {
t.Fatal("list projection failure accepted")
}
if err := runMinutesAlignmentCLIWithWriter(t, listSuccess, minutesFailWriter{}, "minutes", "+list-all"); err == nil {
t.Fatal("list output failure accepted")
}
for _, test := range []struct {
command string
cmd string
id string
}{
{command: "+record-start", cmd: "create"},
{command: "+record-pause", cmd: "pause", id: "u1"},
{command: "+record-resume", cmd: "resume", id: "u1"},
{command: "+record-stop", cmd: "end", id: "u1"},
} {
t.Run(test.command, func(t *testing.T) {
result := fmt.Sprintf(`{"success":true,"result":{"cmd":%q,"uuid":%q}}`, test.cmd, test.id)
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {result}}}
args := []string{"minutes", test.command, "--session-id", "session", "--yes"}
if test.id != "" {
args = append(args, "--id", test.id)
}
payload, _, err := runMinutesAlignmentCLI(t, caller, args...)
if err != nil || payload["accepted"] != true || payload["command"] != test.cmd {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if caller.arguments["minutes/"+listeningNoteCmdTool][0]["sessionId"] != "session" {
t.Fatalf("args=%#v", caller.arguments)
}
})
}
recordCall := &minutesE2ECaller{failAt: map[string]int{"minutes/" + listeningNoteCmdTool: 1}}
if _, _, err := runMinutesAlignmentCLI(t, recordCall, "minutes", "+record-start", "--yes"); err == nil {
t.Fatal("record call failure accepted")
}
recordParse := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"pause","uuid":"u1"}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, recordParse, "minutes", "+record-stop", "--id", "u1", "--yes"); err == nil {
t.Fatal("record result mismatch accepted")
}
recordOutput := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`}}}
if err := runMinutesAlignmentCLIWithWriter(t, recordOutput, minutesFailWriter{}, "minutes", "+record-stop", "--id", "u1", "--yes"); err == nil {
t.Fatal("record output failure accepted")
}
}
@@ -0,0 +1,394 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type minutesE2ECaller struct {
responses map[string][]string
failAt map[string]int
counts map[string]int
arguments map[string][]map[string]any
beforeFail map[string]func()
failErrors map[string]error
}
func (c *minutesE2ECaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
if c.counts == nil {
c.counts = map[string]int{}
}
if c.arguments == nil {
c.arguments = map[string][]map[string]any{}
}
key := product + "/" + tool
c.counts[key]++
c.arguments[key] = append(c.arguments[key], args)
if c.failAt[key] == c.counts[key] {
if hook := c.beforeFail[key]; hook != nil {
hook()
}
if err := c.failErrors[key]; err != nil {
return nil, err
}
return nil, errors.New("fixture failure")
}
responses := c.responses[key]
text := `{"success":true,"result":{}}`
if len(responses) > 0 {
index := c.counts[key] - 1
if index >= len(responses) {
index = len(responses) - 1
}
text = responses[index]
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (c *minutesE2ECaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.CallTool(ctx, product, tool, args)
}
func (*minutesE2ECaller) Format() string { return "json" }
func (*minutesE2ECaller) DryRun() bool { return false }
func (*minutesE2ECaller) Fields() string { return "" }
func (*minutesE2ECaller) JQ() string { return "" }
func runMinutesAlignmentCLI(t *testing.T, caller *minutesE2ECaller, args ...string) (map[string]any, string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&bytes.Buffer{})
root.SetArgs(args)
err := root.Execute()
if stdout.Len() == 0 {
return nil, "", err
}
var payload map[string]any
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
}
return payload, stdout.String(), err
}
func TestCrossPlatformCoverageMinutesSearchPaginatesFiltersAndRejectsUnknownE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/list_by_keyword_and_time_range": {
`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"周会 A","startTime":1},{"uuid":"u2","title":"其他","startTime":2}],"hasNext":true,"nextToken":"n2"}}`,
`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"周会 A","startTime":1},{"uuid":"u3","title":"周会 B","startTime":3}],"hasNext":false}}`,
},
}}
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+search", "--query", "周会", "--page-all")
if err != nil || payload["count"] != float64(2) || payload["scannedCount"] != float64(3) || payload["pages"] != float64(2) || payload["complete"] != true {
t.Fatalf("search payload=%#v err=%v", payload, err)
}
if calls := caller.arguments["minutes/list_by_keyword_and_time_range"]; len(calls) != 2 || calls[1]["nextToken"] != "n2" {
t.Fatalf("search calls=%#v", calls)
}
for _, test := range []struct {
scope string
belonging string
}{
{scope: "mine", belonging: "createdByMe"},
{scope: "shared", belonging: "sharedToMe"},
{scope: "all", belonging: "noLimit"},
} {
t.Run("scope "+test.scope, func(t *testing.T) {
scoped := &minutesE2ECaller{responses: map[string][]string{
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[],"hasNext":false}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, scoped, "minutes", "+search", "--query", "needle", "--scope", test.scope, "--limit", "1")
if err != nil || payload["count"] != float64(0) || payload["complete"] != true {
t.Fatalf("scope %s payload=%#v err=%v", test.scope, payload, err)
}
calls := scoped.arguments["minutes/list_by_keyword_and_time_range"]
want := map[string]any{"belongingConditionId": test.belonging, "keyword": "needle", "maxResults": 1}
if len(calls) != 1 || !reflect.DeepEqual(calls[0], want) {
t.Fatalf("scope %s calls=%#v, want exactly %#v", test.scope, calls, want)
}
})
}
unknown := &minutesE2ECaller{responses: map[string][]string{"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`}}}
if payload, output, err := runMinutesAlignmentCLI(t, unknown, "minutes", "+search", "--query", "周会"); err == nil || payload != nil || output != "" {
t.Fatalf("unknown list accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesDownloadEmptyMediaIsPartialFailureE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{}}`}}}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+download", "--id", "u1", "--url-only")
if err == nil || output == "" || payload["ok"] != false || payload["failed"] != float64(1) || payload["succeeded"] != float64(0) {
t.Fatalf("empty media accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesUpdateRequiresVerifiedReadbackE2E(t *testing.T) {
success := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"旧标题"}}`, `{"success":true,"result":{"taskUuid":"u1","title":"新标题"}}`},
"minutes/update_minutes_title": {`{"success":true,"result":{"updated":true}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+update", "--id", "u1", "--title", "新标题", "--yes")
if err != nil || payload["changed"] != true || payload["verified"] != true {
t.Fatalf("verified update payload=%#v err=%v", payload, err)
}
mismatch := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"旧标题"}}`, `{"success":true,"result":{"taskUuid":"u1","title":"仍是旧标题"}}`},
"minutes/update_minutes_title": {`{"success":true,"result":{"updated":true}}`},
}}
if payload, output, err := runMinutesAlignmentCLI(t, mismatch, "minutes", "+update", "--id", "u1", "--title", "新标题", "--yes"); err == nil || payload != nil || output != "" {
t.Fatalf("readback mismatch accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesSummaryProtectsImagesAndVerifiesE2E(t *testing.T) {
missingImage := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"旧内容\n![图](https://example.invalid/a.png)"}}`},
}}
if payload, output, err := runMinutesAlignmentCLI(t, missingImage, "minutes", "+summary", "--id", "u1", "--content", "新内容", "--yes"); err == nil || payload != nil || output != "" || missingImage.counts["minutes/update_minutes_summary"] != 0 {
t.Fatalf("missing image accepted: payload=%#v output=%q err=%v calls=%#v", payload, output, err, missingImage.counts)
}
content := "新内容\n![图](https://example.invalid/a.png)"
success := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"旧内容\n![图](https://example.invalid/a.png)"}}`, `{"success":true,"result":{"fullSummary":"新内容\n![图](https://example.invalid/a.png)"}}`},
"minutes/update_minutes_summary": {`{"success":true,"result":{"updated":true}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, success, "minutes", "+summary", "--id", "u1", "--content", content, "--yes")
if err != nil || payload["changed"] != true || payload["verified"] != true || payload["preservedImages"] != true {
t.Fatalf("summary payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesSpeakerReplacePaginatesAndVerifiesE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","speakerNick":"甲"}],"hasNext":true,"nextToken":"n2"}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","speakerNick":"甲"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","speakerNick":"乙"}],"hasNext":true,"nextToken":"n2"}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","speakerNick":"乙"}],"hasNext":false}}`,
},
"minutes/replace_speaker": {`{"success":true,"result":{"updated":true}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+speaker-replace", "--id", "u1", "--from", "甲", "--to", "乙", "--yes")
if err != nil || payload["verified"] != true || payload["affectedParagraphs"] != float64(2) || caller.counts["minutes/get_minutes_transcription"] != 4 {
t.Fatalf("speaker payload=%#v err=%v calls=%#v", payload, err, caller.counts)
}
}
func TestCrossPlatformCoverageMinutesUploadAndPermissionDryRunDoNotWriteE2E(t *testing.T) {
work := t.TempDir()
file := filepath.Join(work, "source.wav")
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
t.Fatal(err)
}
caller := &minutesE2ECaller{}
upload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--title", "E2E", "--dry-run")
if err != nil || upload["dryRun"] != true || upload["executed"] != false || len(caller.counts) != 0 {
t.Fatalf("upload dry-run=%#v err=%v calls=%#v", upload, err, caller.counts)
}
permission, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+apply-permission", "--id", "u1", "--permission", "edit", "--dry-run")
if err != nil || permission["policyId"] != float64(2) || permission["executed"] != false || len(caller.counts) != 0 {
t.Fatalf("permission dry-run=%#v err=%v calls=%#v", permission, err, caller.counts)
}
}
func TestCrossPlatformCoverageMinutesUploadUnknownCompletionPreservesSessionE2E(t *testing.T) {
work := t.TempDir()
file := filepath.Join(work, "response-loss.wav")
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
return localio.UploadResult{SizeBytes: 23, Attempts: 1}, nil
})
serverCompleted := false
caller := &minutesE2ECaller{
responses: map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"session-redacted","presignedUrl":"https://upload.example.invalid/object"}}`},
},
failAt: map[string]int{"minutes/complete_upload_session": 1},
beforeFail: map[string]func(){
"minutes/complete_upload_session": func() { serverCompleted = true },
},
}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload", "--file", file, "--yes")
if err == nil || payload != nil || output != "" || !serverCompleted {
t.Fatalf("response-loss outcome = payload:%#v output:%q err:%v completed:%v", payload, output, err, serverCompleted)
}
if caller.counts["minutes/cancel_upload_session"] != 0 {
t.Fatalf("unknown remote completion was cancelled: calls=%#v", caller.counts)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "minutes_upload_completion_unknown" || typed.Retryable || typed.FailureStage != "complete" {
t.Fatalf("unknown completion error = %#v", err)
}
if typed.Details["sessionId"] != "session-redacted" || typed.Details["cancelled"] != false || typed.Details["remoteEffect"] != "unknown" {
t.Fatalf("unknown completion recovery details = %#v", typed.Details)
}
}
func TestCrossPlatformCoverageMinutesMindmapExplicitStatusControlsExitE2E(t *testing.T) {
for _, test := range []struct {
name string
status string
wantErr bool
wantResult bool
}{
{name: "success", status: `{"success":true,"result":{"taskStatus":1,"mindGraph":"ready"}}`, wantResult: true},
{name: "platform failed", status: `{"success":true,"result":{"taskStatus":2}}`, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
"minutes/query_mind_graph_status": {test.status},
}}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+mindmap", "--id", "u1", "--timeout", "2", "--interval", "1", "--yes")
if output == "" || (err != nil) != test.wantErr || payload["complete"] != test.wantResult {
t.Fatalf("mindmap payload=%#v output=%q err=%v", payload, output, err)
}
if caller.counts["minutes/create_mind_graph"] != 1 {
t.Fatalf("mindmap create repeated: %#v", caller.counts)
}
})
}
}
func TestCrossPlatformCoverageMinutesSpeakerInsightsRequiresTaskAndResultE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job-1","status":"processing"}}`},
"minutes/get_speaker_summary": {
`{"success":false,"errorMsg":"downstream query empty"}`,
`{"success":true,"result":{"summaries":[{"speaker":"甲","summary":"结论"}]}}`,
},
}}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+speaker-insights", "--id", "u1", "--timeout", "3", "--interval", "1", "--yes")
if err != nil || output == "" || payload["complete"] != true || payload["taskId"] != "job-1" || payload["attempts"] != float64(2) {
t.Fatalf("speaker insights payload=%#v output=%q err=%v", payload, output, err)
}
missingTask := &minutesE2ECaller{responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"status":"processing"}}`}}}
if payload, output, err := runMinutesAlignmentCLI(t, missingTask, "minutes", "+speaker-insights", "--id", "u1", "--timeout", "2", "--interval", "1", "--yes"); err == nil || output == "" || payload["complete"] != false {
t.Fatalf("missing task accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesPrepareASRDiffWritesAndReadbackE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/list_my_hotwords": {
`{"success":true,"result":{"hotWordList":["已有"],"currentCount":1}}`,
`{"success":true,"result":{"hotWordList":["已有","DWS"],"currentCount":2}}`,
},
"minutes/add_personal_hot_word": {`{"success":true,"result":{}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+prepare-asr", "--words", "已有,DWS", "--yes")
if err != nil || payload["complete"] != true || payload["verified"] != true || caller.counts["minutes/add_personal_hot_word"] != 1 || caller.counts["minutes/delete_personal_hotword"] != 0 {
t.Fatalf("prepare asr payload=%#v err=%v calls=%#v", payload, err, caller.counts)
}
unknown := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{}}`}}}
if payload, output, err := runMinutesAlignmentCLI(t, unknown, "minutes", "+prepare-asr", "--words", "DWS", "--yes"); err == nil || payload != nil || output != "" {
t.Fatalf("unknown hotword list accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesRecordWrapUpStopSuccessArtifactFailureIsPartialE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`},
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{}}`},
}}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+record-wrap-up", "--id", "u1", "--artifacts", "summary", "--wait-timeout", "1", "--poll-interval", "1", "--yes")
if err == nil || output == "" || payload["complete"] != false || payload["taskUuid"] != "u1" || payload["recovery"] == nil || caller.counts["minutes/"+listeningNoteCmdTool] != 1 {
t.Fatalf("wrap-up partial payload=%#v output=%q err=%v calls=%#v", payload, output, err, caller.counts)
}
}
func TestCrossPlatformCoverageMinutesExportPackPublishesOnlyCompleteArtifactsE2E(t *testing.T) {
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
work := t.TempDir()
if err := os.Chdir(work); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"E2E"}}`},
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"完整纪要"}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic,summary")
if err != nil || payload["complete"] != true || payload["published"] != true {
t.Fatalf("export payload=%#v err=%v", payload, err)
}
manifestRaw, err := os.ReadFile(filepath.Join(work, "pack", "manifest.json"))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(manifestRaw, []byte("https://")) || !bytes.Contains(manifestRaw, []byte(`"complete": true`)) {
t.Fatalf("manifest contains secret URL or is incomplete: %s", manifestRaw)
}
}
func TestCrossPlatformCoverageMinutesSharePartialWriteIsNonZeroE2E(t *testing.T) {
caller := &minutesE2ECaller{
responses: map[string][]string{"minutes/add_member_permission": {`{"success":true,"result":{}}`, `{"success":false,"errorMsg":"denied"}`}},
}
payload, output, err := runMinutesAlignmentCLI(t, caller, "minutes", "+share", "--id", "u1", "--member-uids", "m1,m2,m3", "--permission", "view", "--yes")
if err == nil || output == "" || payload["complete"] != false || payload["succeeded"] != float64(1) || payload["failed"] != float64(1) || len(payload["unattempted"].([]any)) != 1 {
t.Fatalf("share partial payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesUploadAndAnalyzeDryRunDoesNotWriteE2E(t *testing.T) {
file := filepath.Join(t.TempDir(), "source.wav")
if err := os.WriteFile(file, []byte("non-empty-audio-fixture"), 0o600); err != nil {
t.Fatal(err)
}
caller := &minutesE2ECaller{}
payload, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+upload-and-analyze", "--file", file, "--artifacts", "summary,transcript", "--mindmap", "--dry-run")
if err != nil || payload["dryRun"] != true || payload["executed"] != false || len(caller.counts) != 0 {
t.Fatalf("upload-and-analyze dry-run=%#v err=%v calls=%#v", payload, err, caller.counts)
}
}
func TestCrossPlatformCoverageMinutesArtifactWaitDoesNotTreatEmptyAnalysisAsReadyE2E(t *testing.T) {
caller := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[],"hasNext":false}}`},
}}
// Exercise the same readiness collector used after upload/record stop. An
// explicit [] is a valid transport shape, but cannot prove asynchronous ASR
// has finished when the workflow promised transcript analysis.
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "+export-pack"}, ExportPack)
bundle, failures := collectMinutesArtifactsOnce(rt, "u1", []string{"transcript"}, 10)
if len(failures) != 1 || bundle["transcript"] != nil {
t.Fatalf("empty transcript accepted: bundle=%#v failures=%#v", bundle, failures)
}
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func minutesContract(command, description, useWhen string, avoidWhen []string, examples []string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
return corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: name,
CanonicalPath: "minutes." + name,
CLIPath: "minutes " + command,
PrimaryCLIPath: "minutes " + command,
},
Description: description,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: "The executable Shortcut owns validation, orchestration, completeness and verification across one or more Minutes RPCs; no single RPC represents the final command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{useWhen},
AvoidWhen: avoidWhen,
Examples: examples,
},
}
}
func withMinutesDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl {
decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads}
return decl
}
func minutesDryRunPayload(kind, operation string, payload map[string]any) map[string]any {
if payload == nil {
payload = map[string]any{}
}
payload["operation"] = operation
payload["dry_run"] = true
payload["dryRun"] = true
payload["preview_kind"] = kind
payload["executed"] = false
return payload
}
// finalizeMinutesShortcuts keeps the human Shortcut declaration and the final
// Agent contract on one source of truth. Custom validation is prose-only in the
// Schema wire, so publish its exact evidence on every affected flag as well.
func finalizeMinutesShortcuts(values ...shortcut.Shortcut) []shortcut.Shortcut {
finalized := make([]shortcut.Shortcut, len(values))
for index, value := range values {
value.Contract.Selection.AgentSummary = value.Description
value.Contract.Selection.UseWhen = []string{value.Intent}
for _, constraint := range value.Constraints {
if constraint.Kind != shortcut.ConstraintCustom {
continue
}
evidence := strings.TrimSpace(constraint.Description)
if evidence == "" {
continue
}
for flagIndex := range value.Flags {
flag := &value.Flags[flagIndex]
if !containsString(constraint.Flags, flag.Name) || strings.Contains(flag.Desc, evidence) {
continue
}
flag.Desc = strings.TrimRight(flag.Desc, ";。 ") + ";约束:" + evidence
}
for parameterIndex := range value.Contract.Parameters {
parameter := &value.Contract.Parameters[parameterIndex]
if !containsString(constraint.Flags, parameter.Name) || strings.Contains(parameter.Description, evidence) {
continue
}
parameter.Description = strings.TrimRight(parameter.Description, ";。 ") + ";约束:" + evidence
}
}
finalized[index] = value
}
return finalized
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
@@ -32,7 +32,10 @@ func TestCallListProjectItemListShape(t *testing.T) {
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := callListProject(data)
got, err := callListProject(data)
if err != nil {
t.Fatalf("project itemList: %v", err)
}
if len(got) != 2 {
t.Fatalf("lower/upper mismatch: itemList has 2 entries, projection returned %d", len(got))
}
@@ -40,3 +43,20 @@ func TestCallListProjectItemListShape(t *testing.T) {
t.Fatalf("taskUuid not projected from taskUuid/task_uuid keys: %v", got)
}
}
func TestCrossPlatformCoverageMinutesUnknownListIsNotEmptySuccessE2E(t *testing.T) {
for _, raw := range []string{
`{}`,
`{"success":true}`,
`{"success":true,"result":{"itemList":null}}`,
`{"success":false,"errorMsg":"denied"}`,
} {
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got, err := callListProject(data); err == nil || got != nil {
t.Fatalf("unknown list response accepted: got=%#v err=%v raw=%s", got, err, raw)
}
}
}
+65 -85
View File
@@ -19,6 +19,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// listeningNoteCmdTool is the gateway-registered name of the 听记指令 tool.
@@ -173,7 +174,10 @@ func callList(rt *shortcut.RuntimeContext, belonging string) error {
if err != nil {
return err
}
minutes := callListProject(data)
minutes, err := callListProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(minutes), "minutes": minutes})
}
@@ -183,83 +187,12 @@ func callList(rt *shortcut.RuntimeContext, belonging string) error {
// The list container and field names are probed defensively across candidate
// keys so the projection tolerates response-shape drift; unknown keys are never
// invented.
func callListProject(data map[string]any) []map[string]any {
raw := callListResolveList(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
// Only real taskUuid spellings map to taskUuid: the downstream
// +record-pause/resume/stop commands feed this value straight into the
// recording-control tool as the task uuid, so a minutesId (the minutes
// document id, a different identifier) must not be substituted here or
// record control would fail with a wrong id. The backend list already
// returns taskUuid.
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid"); ok {
row["taskUuid"] = v
}
if v, ok := callListFirst(m, "title", "name"); ok {
row["title"] = v
}
if v, ok := callListFirst(m, "creator", "creatorName", "createUserName", "creatorNick"); ok {
row["creator"] = v
}
if v, ok := callListFirst(m, "startTime", "gmtStart", "beginTime"); ok {
row["startTime"] = v
}
if v, ok := callListFirst(m, "endTime", "gmtEnd", "deadline"); ok {
row["endTime"] = v
}
if v, ok := callListFirst(m, "url", "shareUrl", "link"); ok {
row["url"] = v
}
if v, ok := callListFirst(m, "status", "taskStatus", "state"); ok {
row["status"] = v
}
if len(row) > 0 {
out = append(out, row)
}
func callListProject(data map[string]any) ([]map[string]any, error) {
page, err := minutesdata.ParseListPage(data)
if err != nil {
return nil, err
}
return out
}
// callListResolveList locates the list payload inside the response, tolerating a
// bare top-level array or nesting under result/data/list/items/records containers.
func callListResolveList(data map[string]any) []any {
// list_by_keyword_and_time_range nests the minutes under result.itemList;
// "itemList" MUST be probed or +list-all/+list-mine/+list-shared silently
// return empty despite the backend returning minutes.
for _, key := range []string{"result", "data", "list", "items", "itemList", "records", "dataList"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
// container may itself wrap the list one level deeper
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "itemList", "records", "dataList", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
}
// callListFirst returns the first present candidate key's value.
func callListFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
return minutesdata.ProjectList(page)
}
// ── get ─────────────────────────────────────────────────────────────────────
@@ -273,7 +206,7 @@ var RecordStart = shortcut.Shortcut{
Command: "+record-start",
Product: "minutes",
Description: "发起听记(开始录音)",
Intent: "当你要开始一场实时会议/通话的 AI 听记、立刻启动录音并生成一条新听记任务时使用;可选传入 AI 助理会话 ID,会真实发起录音,返回新建听记的 taskUuid 供后续暂停/恢复/结束。",
Intent: "当你要开始一场实时会议/通话的 AI 听记、立刻启动录音时使用;可选传入 AI 助理会话 ID。当前网关 create 回执不返回 taskUuid,只能证明录音指令已被接受;随后需用 +latest/+search 定位新听记。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -295,7 +228,7 @@ var RecordStart = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "发起听记(开始录音)",
UseWhen: []string{"当你要开始一场实时会议/通话的 AI 听记、立刻启动录音并生成一条新听记任务时使用;可选传入 AI 助理会话 ID,会真实发起录音,返回新建听记的 taskUuid 供后续暂停/恢复/结束。"},
UseWhen: []string{"当你要开始一场实时会议/通话的 AI 听记、立刻启动录音时使用;网关 create 只返回已接受回执,不返回 taskUuid,随后需用 +latest/+search 定位新听记。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws minutes +record-start --session-id <sessionId>"},
},
@@ -309,7 +242,7 @@ var RecordStart = shortcut.Shortcut{
if rt.Changed("session-id") {
params["sessionId"] = rt.Str("session-id")
}
return rt.CallMCP(listeningNoteCmdTool, params)
return executeRecordCommand(rt, "create", "", params)
},
}
@@ -320,6 +253,16 @@ var RecordPause = shortcut.Shortcut{
Description: "暂停听记录音",
Intent: "录音进行中想临时中断(如中场休息、切换话题)又不想结束整条听记时使用;传入正在录音的听记 taskUuid,会真实暂停该次录音,之后可用 +record-resume 继续。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
},
Contract: minutesContract(
"+record-pause",
"暂停听记录音",
"实时听记仍在录制,但要临时中断且保留后续恢复能力时使用;必须传当前录制任务的 taskUuid。",
[]string{"会议已经结束时使用 +record-stop;只是查看听记状态或内容时使用只读命令"},
[]string{"dws minutes +record-pause --id <taskUuid>"},
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
@@ -330,7 +273,7 @@ var RecordPause = shortcut.Shortcut{
if rt.Changed("session-id") {
params["sessionId"] = rt.Str("session-id")
}
return rt.CallMCP(listeningNoteCmdTool, params)
return executeRecordCommand(rt, "pause", rt.Str("id"), params)
},
}
@@ -341,6 +284,16 @@ var RecordResume = shortcut.Shortcut{
Description: "恢复听记录音",
Intent: "之前用 +record-pause 暂停过的听记,现在想接着录时使用;传入该听记 taskUuid,会真实恢复录音,继续追加到同一条听记中。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
},
Contract: minutesContract(
"+record-resume",
"恢复听记录音",
"实时听记此前已暂停、现在要继续向同一 taskUuid 追加录音时使用。",
[]string{"已经结束的听记不能恢复;要新建录制时使用 +record-start"},
[]string{"dws minutes +record-resume --id <taskUuid>"},
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
@@ -351,7 +304,7 @@ var RecordResume = shortcut.Shortcut{
if rt.Changed("session-id") {
params["sessionId"] = rt.Str("session-id")
}
return rt.CallMCP(listeningNoteCmdTool, params)
return executeRecordCommand(rt, "resume", rt.Str("id"), params)
},
}
@@ -362,6 +315,16 @@ var RecordStop = shortcut.Shortcut{
Description: "结束听记录音",
Intent: "会议开完、想彻底停止录音并让系统开始生成转写与 AI 纪要时使用;传入正在录音的听记 taskUuid,会真实结束该次录音,结束后无法再恢复到这条听记继续录。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
},
Contract: minutesContract(
"+record-stop",
"结束听记录音",
"会议结束且要永久停止指定 taskUuid 的实时录制,让服务进入转写和纪要处理阶段时使用。",
[]string{"仍计划继续录音时使用 +record-pause;结束后不能通过 +record-resume 继续同一条录制"},
[]string{"dws minutes +record-stop --id <taskUuid>"},
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "session-id", Type: shortcut.FlagString, Desc: "AI 助理会话 ID (可选)"},
@@ -372,10 +335,27 @@ var RecordStop = shortcut.Shortcut{
if rt.Changed("session-id") {
params["sessionId"] = rt.Str("session-id")
}
return rt.CallMCP(listeningNoteCmdTool, params)
return executeRecordCommand(rt, "end", rt.Str("id"), params)
},
}
func executeRecordCommand(rt *shortcut.RuntimeContext, expectedCmd, taskUUID string, params map[string]any) error {
data, err := rt.CallMCPWriteDataStrict("minutes", listeningNoteCmdTool, params)
if err != nil {
return err
}
result, err := minutesdata.RecordResult(expectedCmd, taskUUID, data)
if err != nil {
return err
}
return rt.Output(map[string]any{
"accepted": true,
"command": expectedCmd,
"taskUuid": taskUUID,
"result": result,
})
}
// ── mind-graph ──────────────────────────────────────────────────────────────
// ── speaker ─────────────────────────────────────────────────────────────────
@@ -391,7 +371,7 @@ var RecordStop = shortcut.Shortcut{
// ── tag ─────────────────────────────────────────────────────────────────────
func init() {
shortcut.Register(
shortcut.Register(finalizeMinutesShortcuts(
ListMine,
ListShared,
ListAll,
@@ -399,5 +379,5 @@ func init() {
RecordPause,
RecordResume,
RecordStop,
)
)...)
}
+836
View File
@@ -0,0 +1,836 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
var minutesWorkflowArtifacts = []string{"basic", "summary", "keywords", "transcript", "todos"}
var (
minutesMkdirTemp = os.MkdirTemp
minutesRemoveAll = os.RemoveAll
minutesWriteFile = os.WriteFile
minutesStat = os.Stat
minutesRename = os.Rename
minutesGetwd = os.Getwd
minutesEvalSymlinks = filepath.EvalSymlinks
minutesRel = filepath.Rel
minutesLstat = os.Lstat
minutesMkdir = os.Mkdir
minutesMarshalIndent = json.MarshalIndent
)
var RecordWrapUp = shortcut.Shortcut{
Service: "minutes", Command: "+record-wrap-up", Product: "minutes",
Description: "停止实时录音并有界等待听记产物,失败时保留恢复句柄",
Intent: "会议结束时一次完成 stop 与 basic/summary/keywords/transcript/todos 读取;停止已生效但产物未就绪时返回非零 partial 结果和 taskUuid,不会诱导重复 stop。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+record-wrap-up", "停止实时录音并有界等待听记产物,失败时保留恢复句柄",
"已知正在录制的 taskUuid,会议结束后要停止录制并立即收口转写、纪要、关键词和行动项时使用",
[]string{"仍要继续录音时使用 +record-pause;只停止、不等待产物时使用 +record-stop"},
[]string{`dws minutes +record-wrap-up --id <taskUuid>`, `dws minutes +record-wrap-up --id <taskUuid> --artifacts transcript,summary --wait-timeout 120`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "正在录制的听记 taskUuid", Required: true},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "停止后等待的产物", Enum: minutesWorkflowArtifacts},
{Name: "wait-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待产物秒数"},
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"wait-timeout", "poll-interval", "page-limit"}, Description: "超时、轮询间隔和逐字稿页数上限必须大于 0"}},
Tips: []string{`dws minutes +record-wrap-up --id <taskUuid>`, `dws minutes +record-wrap-up --id <taskUuid> --artifacts transcript,summary --wait-timeout 120`},
Validate: validateMinutesWorkflowWait,
Execute: executeMinutesRecordWrapUp,
}
var UploadAndAnalyze = shortcut.Shortcut{
Service: "minutes", Command: "+upload-and-analyze", Product: "minutes",
Description: "本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察",
Intent: "从本地媒体一次完成 upload create/PUT/complete/read-back,再等待选定听记产物;后续分析失败仍返回已创建 taskUuid 和恢复动作。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+upload-and-analyze", "本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察",
"有本地音视频且希望一次创建听记、等待基础分析,并可继续生成思维导图或发言人洞察时使用",
[]string{"只需上传时使用 +upload;已有 taskUuid 时分别使用 +detail/+mindmap/+speaker-insights"},
[]string{`dws minutes +upload-and-analyze --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload-and-analyze --file ./meeting.mp4 --artifacts transcript,summary --mindmap`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "本地音视频文件;与 --resume-id 二选一"},
{Name: "resume-id", Type: shortcut.FlagString, Desc: "先前已成功上传的 taskUuid;只恢复分析、不重复上传"},
{Name: "title", Type: shortcut.FlagString, Desc: "听记标题"},
{Name: "template-id", Type: shortcut.FlagString, Desc: "纪要模板 ID"},
{Name: "input-language", Type: shortcut.FlagString, Desc: "ASR 输入语言"},
{Name: "enable-message-card", Type: shortcut.FlagBool, Desc: "生成后推送闪记卡片"},
{Name: "complete-timeout", Type: shortcut.FlagInt, Default: "90", Desc: "上传 complete 超时秒数"},
{Name: "poll-interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "wait-timeout", Type: shortcut.FlagInt, Default: "180", Desc: "等待分析产物秒数"},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "等待的分析产物", Enum: minutesWorkflowArtifacts},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
{Name: "mindmap", Type: shortcut.FlagBool, Desc: "继续创建并等待思维导图"},
{Name: "speaker-insights", Type: shortcut.FlagBool, Desc: "继续创建并等待发言人总结"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"file", "resume-id"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"complete-timeout", "wait-timeout", "poll-interval", "page-limit"}, Description: "上传超时、分析超时、轮询间隔和逐字稿页数上限必须大于 0"},
},
Tips: []string{`dws minutes +upload-and-analyze --file ./meeting.mp3 --title "项目周会"`, `dws minutes +upload-and-analyze --file ./meeting.mp4 --artifacts transcript,summary --mindmap`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("complete-timeout") <= 0 {
return apperrors.NewValidation("--complete-timeout 必须大于 0")
}
return validateMinutesWorkflowWait(rt)
},
Execute: executeMinutesUploadAndAnalyze,
}
var Mindmap = shortcut.Shortcut{
Service: "minutes", Command: "+mindmap", Product: "minutes",
Description: "创建听记思维导图并轮询到明确成功、失败或超时",
Intent: "已有 taskUuid 且需要思维导图时使用;创建只执行一次,随后只轮询 taskStatus,失败/超时均返回非零和可继续查询的 taskUuid。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+mindmap", "创建听记思维导图并轮询到明确成功、失败或超时",
"听记内容已就绪,需要触发并等待平台思维导图产物时使用",
[]string{"只查询已经触发的任务状态时使用原子 mind-graph status;短音频或无有效内容可能明确失败"},
[]string{`dws minutes +mindmap --id <taskUuid>`, `dws minutes +mindmap --id <taskUuid> --timeout 120 --interval 3`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "timeout", Type: shortcut.FlagInt, Default: "90", Desc: "等待秒数"},
{Name: "interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "resume", Type: shortcut.FlagBool, Desc: "只继续轮询,不重复创建任务"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"timeout", "interval"}, Description: "--timeout 和 --interval 必须大于 0"}},
Tips: []string{`dws minutes +mindmap --id <taskUuid>`, `dws minutes +mindmap --id <taskUuid> --timeout 120 --interval 3`},
Validate: validateMinutesPolling,
Execute: executeMinutesMindmap,
}
var SpeakerInsights = shortcut.Shortcut{
Service: "minutes", Command: "+speaker-insights", Product: "minutes",
Description: "创建发言人段落总结并轮询结果,保留异步任务恢复句柄",
Intent: "需要按发言人汇总听记内容时使用;严格要求 create 返回 taskId,读取未就绪时有界重试,失败或超时返回 taskId/taskUuid。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+speaker-insights", "创建发言人段落总结并轮询结果,保留异步任务恢复句柄",
"逐字稿已有多位发言人,需要触发并读取平台发言人段落总结时使用",
[]string{"只改发言人昵称时使用 +speaker-replace;无有效发言内容时平台可能不生成结果"},
[]string{`dws minutes +speaker-insights --id <taskUuid>`, `dws minutes +speaker-insights --id <taskUuid> --timeout 180`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "timeout", Type: shortcut.FlagInt, Default: "180", Desc: "等待秒数"},
{Name: "interval", Type: shortcut.FlagInt, Default: "3", Desc: "轮询间隔秒数"},
{Name: "resume", Type: shortcut.FlagBool, Desc: "只继续轮询,不重复创建任务"},
{Name: "task-id", Type: shortcut.FlagString, Desc: "先前 create 返回的异步 taskId,恢复时用于输出追踪"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"timeout", "interval"}, Description: "--timeout 和 --interval 必须大于 0"}},
Tips: []string{`dws minutes +speaker-insights --id <taskUuid>`, `dws minutes +speaker-insights --id <taskUuid> --timeout 180`},
Validate: validateMinutesPolling,
Execute: executeMinutesSpeakerInsights,
}
var PrepareASR = shortcut.Shortcut{
Service: "minutes", Command: "+prepare-asr", Product: "minutes",
Description: "读取个人热词、计算差异、按需新增并可显式同步删除后读回验证",
Intent: "录音/上传前准备 ASR 专有词表;默认只增加缺失热词,只有 --sync 才删除未出现在目标集合中的现有热词。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withMinutesDryRun(minutesContract("+prepare-asr", "按需新增个人热词并可显式同步删除后读回验证;dry-run 不访问远端",
"录音或上传前要补充人名、项目名等 ASR 热词,并希望看到差异和最终读回验证时使用",
[]string{"只查看现有热词时使用原子 hot-word list;不明确接受删除时不要使用 --sync"},
[]string{`dws minutes +prepare-asr --words "DWS,听记"`, `dws minutes +prepare-asr --words "DWS,听记" --sync`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "words", Type: shortcut.FlagStringSlice, Desc: "目标热词,逗号分隔", Required: true},
{Name: "sync", Type: shortcut.FlagBool, Desc: "删除目标集合之外的现有热词"},
},
Tips: []string{`dws minutes +prepare-asr --words "DWS,听记"`, `dws minutes +prepare-asr --words "DWS,听记" --sync`},
Execute: executeMinutesPrepareASR,
}
var ExportPack = shortcut.Shortcut{
Service: "minutes", Command: "+export-pack", Product: "minutes",
Description: "把完整听记产物写入受控目录并生成不含签名 URL 的 manifest",
Intent: "需要离线归档 basic/summary/keywords/transcript/todos,可选媒体文件时使用;全部必需产物验证通过后才原子发布目录。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: minutesContract("+export-pack", "把完整听记产物写入受控目录并生成不含签名 URL 的 manifest",
"已知 taskUuid,需要把多个已验证产物和完整性 manifest 安全归档到工作目录时使用",
[]string{"只下载媒体时使用 +download;目标目录已存在时本命令拒绝覆盖"},
[]string{`dws minutes +export-pack --id <taskUuid> --output ./minutes-export`, `dws minutes +export-pack --id <taskUuid> --output ./minutes-export --include-media`}),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid", Required: true},
{Name: "output", Type: shortcut.FlagString, Desc: "工作目录内的新归档目录", Required: true},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要导出的产物", Enum: minutesWorkflowArtifacts},
{Name: "include-media", Type: shortcut.FlagBool, Desc: "同时下载音视频"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿翻页上限"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "输出必须是安全的工作目录相对路径"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
},
Tips: []string{`dws minutes +export-pack --id <taskUuid> --output ./minutes-export`, `dws minutes +export-pack --id <taskUuid> --output ./minutes-export --include-media`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
return localio.ValidateOutput(rt.Str("output"))
},
Execute: executeMinutesExportPack,
}
var Share = shortcut.Shortcut{
Service: "minutes", Command: "+share", Product: "minutes",
Description: "按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger",
Intent: "所有者已确认成员钉钉 UID 和权限,需批量授权时使用;逐成员调用以区分成功/失败,默认首错停止。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+share", "按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger",
"听记所有者已确认稳定 member UID,需要授予 view/download/edit 权限并审计每个成员结果时使用",
[]string{"当前用户自己申请权限时使用 +apply-permission;只有姓名而无稳定 UID 时先用 contact 命令消歧"},
[]string{`dws minutes +share --ids <uuid1,uuid2> --member-uids <uid1,uid2> --permission view`, `dws minutes +share --id <uuid> --member-uids <uid> --permission edit --cover`}), contract.DryRunPreviewPlan, false),
Flags: minutesShareFlags(true),
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "听记去重后必须为 1..50 个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-uids"}, Description: "成员 UID 去重后必须为 1..50 个"},
},
Tips: []string{`dws minutes +share --ids <uuid1,uuid2> --member-uids <uid1,uid2> --permission view`, `dws minutes +share --id <uuid> --member-uids <uid> --permission edit --cover`},
Validate: validateMinutesShare,
Execute: executeMinutesShare,
}
var Unshare = shortcut.Shortcut{
Service: "minutes", Command: "+unshare", Product: "minutes",
Description: "按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger",
Intent: "所有者明确要撤销稳定成员 UID 的听记访问时使用;默认首错停止,任何部分失败都返回非零。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withMinutesDryRun(minutesContract("+unshare", "按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger",
"听记所有者已确认稳定 member UID,需要撤销其对一个或多个听记的访问权限时使用",
[]string{"要授权时使用 +share;成员或听记 ID 未确认时不要撤销"},
[]string{`dws minutes +unshare --ids <uuid1,uuid2> --member-uids <uid1,uid2>`, `dws minutes +unshare --id <uuid> --member-uids <uid> --failure-policy continue`}), contract.DryRunPreviewPlan, false),
Flags: minutesShareFlags(false),
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "ids"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "听记去重后必须为 1..50 个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"member-uids"}, Description: "成员 UID 去重后必须为 1..50 个"},
},
Tips: []string{`dws minutes +unshare --ids <uuid1,uuid2> --member-uids <uid1,uid2>`, `dws minutes +unshare --id <uuid> --member-uids <uid> --failure-policy continue`},
Validate: validateMinutesShare,
Execute: executeMinutesUnshare,
}
func validateMinutesWorkflowWait(rt *shortcut.RuntimeContext) error {
if rt.Int("wait-timeout") <= 0 || rt.Int("poll-interval") <= 0 || rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("等待超时、轮询间隔和逐字稿页数上限必须大于 0")
}
return nil
}
func validateMinutesPolling(rt *shortcut.RuntimeContext) error {
if rt.Int("timeout") <= 0 || rt.Int("interval") <= 0 {
return apperrors.NewValidation("--timeout 和 --interval 必须大于 0")
}
return nil
}
func selectedWorkflowArtifacts(rt *shortcut.RuntimeContext) []string {
selected := rt.StrSlice("artifacts")
if len(selected) == 0 {
return append([]string(nil), minutesWorkflowArtifacts...)
}
return uniqueStrings(selected)
}
func executeMinutesRecordWrapUp(rt *shortcut.RuntimeContext) error {
id := rt.Str("id")
artifacts := selectedWorkflowArtifacts(rt)
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.record_wrap_up", map[string]any{"taskUuid": id, "stages": []string{"record.stop", "artifacts.wait"}, "artifacts": artifacts}))
}
stopData, err := rt.CallMCPWriteDataStrict("minutes", listeningNoteCmdTool, map[string]any{"cmd": "end", "uuid": id})
if err != nil {
return err
}
if _, err := minutesdata.RecordResult("end", id, stopData); err != nil {
return err
}
bundle, failures, attempts := waitMinutesArtifacts(rt, id, artifacts, rt.Int("page-limit"), time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
payload := map[string]any{
"operation": "minutes.record_wrap_up", "complete": len(failures) == 0, "taskUuid": id,
"stages": []map[string]any{{"name": "record.stop", "complete": true}, {"name": "artifacts.wait", "complete": len(failures) == 0, "attempts": attempts}},
"artifacts": bundle, "failures": failures,
}
if len(failures) > 0 {
payload["recovery"] = map[string]any{"taskUuid": id, "nextAction": "dws minutes +detail --id <taskUuid>"}
}
return outputWorkflowResult(rt, payload, len(failures) > 0, "minutes_record_wrap_up_incomplete", "artifacts")
}
func executeMinutesUploadAndAnalyze(rt *shortcut.RuntimeContext) error {
artifacts := selectedWorkflowArtifacts(rt)
if rt.DryRun() {
upload := map[string]any{"operation": "minutes.upload", "resume": rt.Str("resume-id") != "", "taskUuid": rt.Str("resume-id"), "executed": false}
if rt.Str("resume-id") == "" {
var err error
upload, err = performMinutesUpload(rt)
if err != nil {
return err
}
}
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.upload_and_analyze", map[string]any{"upload": upload, "artifacts": artifacts, "mindmap": rt.Bool("mindmap"), "speakerInsights": rt.Bool("speaker-insights")}))
}
id := strings.TrimSpace(rt.Str("resume-id"))
upload := map[string]any{"operation": "minutes.upload", "complete": true, "resumed": true, "taskUuid": id, "verified": true}
if id == "" {
var err error
upload, err = performMinutesUpload(rt)
if err != nil {
return err
}
id, _ = upload["taskUuid"].(string)
}
bundle, failures, attempts := waitMinutesArtifacts(rt, id, artifacts, rt.Int("page-limit"), time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second)
stages := []map[string]any{{"name": "upload", "complete": true, "result": upload}, {"name": "artifacts.wait", "complete": len(failures) == 0, "attempts": attempts}}
if rt.Bool("mindmap") {
result, runErr := runMinutesMindmap(rt, id, time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second, true)
stage := map[string]any{"name": "mindmap", "complete": runErr == nil, "result": result}
if runErr != nil {
stage["error"] = runErr.Error()
failures = append(failures, map[string]any{"artifact": "mindmap", "error": runErr.Error()})
}
stages = append(stages, stage)
}
if rt.Bool("speaker-insights") {
result, runErr := runMinutesSpeakerInsights(rt, id, time.Duration(rt.Int("wait-timeout"))*time.Second, time.Duration(rt.Int("poll-interval"))*time.Second, true, "")
stage := map[string]any{"name": "speaker-insights", "complete": runErr == nil, "result": result}
if runErr != nil {
stage["error"] = runErr.Error()
failures = append(failures, map[string]any{"artifact": "speaker-insights", "error": runErr.Error()})
}
stages = append(stages, stage)
}
payload := map[string]any{"operation": "minutes.upload_and_analyze", "complete": len(failures) == 0, "taskUuid": id, "stages": stages, "artifacts": bundle, "failures": failures}
if len(failures) > 0 {
payload["recovery"] = map[string]any{"taskUuid": id, "nextAction": "resume analysis with +detail/+mindmap/+speaker-insights; do not upload again"}
}
return outputWorkflowResult(rt, payload, len(failures) > 0, "minutes_upload_analysis_incomplete", "analysis")
}
func executeMinutesMindmap(rt *shortcut.RuntimeContext) error {
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.mindmap", map[string]any{"taskUuid": rt.Str("id"), "stages": []string{"create", "poll"}}))
}
payload, err := runMinutesMindmap(rt, rt.Str("id"), time.Duration(rt.Int("timeout"))*time.Second, time.Duration(rt.Int("interval"))*time.Second, !rt.Bool("resume"))
if err := rt.Output(payload); err != nil {
return err
}
return err
}
func runMinutesMindmap(rt *shortcut.RuntimeContext, id string, timeout, interval time.Duration, create bool) (map[string]any, error) {
createAcknowledged := false
if create {
// create_mind_graph is observed to return either an explicit success
// envelope or an empty acknowledgement. Empty alone is never success;
// continue only so the status read-back can prove the final effect.
created, err := rt.CallMCPWriteData("minutes", "create_mind_graph", map[string]any{"taskUuid": id})
if err != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "create"}, err
}
createAcknowledged = minutesdata.RequireWriteAcknowledgement("mind graph create", created) == nil
}
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
data, callErr := rt.CallMCPData("minutes", "query_mind_graph_status", map[string]any{"taskUuid": id})
if callErr != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "poll", "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}, callErr
}
status, result, parseErr := minutesdata.MindGraphStatus(data)
if parseErr != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "stage": "poll", "attempts": attempts}, parseErr
}
if status == 1 {
return map[string]any{"operation": "minutes.mindmap", "complete": true, "taskUuid": id, "taskStatus": status, "attempts": attempts, "createAttempted": create, "createAcknowledged": createAcknowledged, "verified": true, "result": result}, nil
}
if status == 2 {
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "result": result, "recovery": map[string]any{"taskUuid": id, "nextAction": "inspect source transcript; do not assume an empty mind map"}}
return payload, minutesCompositeError("minutes_mindmap_failed", "poll", payload)
}
if time.Now().Add(interval).After(deadline) {
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}
return payload, minutesCompositeError("minutes_mindmap_timeout", "poll", payload)
}
if err := waitMinutesInterval(rt, interval); err != nil {
return map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "attempts": attempts}, err
}
}
}
func executeMinutesSpeakerInsights(rt *shortcut.RuntimeContext) error {
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.speaker_insights", map[string]any{"taskUuid": rt.Str("id"), "stages": []string{"create", "poll"}}))
}
payload, err := runMinutesSpeakerInsights(rt, rt.Str("id"), time.Duration(rt.Int("timeout"))*time.Second, time.Duration(rt.Int("interval"))*time.Second, !rt.Bool("resume"), rt.Str("task-id"))
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
return err
}
func runMinutesSpeakerInsights(rt *shortcut.RuntimeContext, id string, timeout, interval time.Duration, create bool, taskID string) (map[string]any, error) {
status := "resume"
if create {
created, err := rt.CallMCPWriteDataStrict("minutes", "create_speaker_summary", map[string]any{"uuids": []string{id}})
if err != nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "stage": "create"}, err
}
var parseErr error
taskID, status, parseErr = minutesdata.SpeakerSummaryTask(created)
if parseErr != nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "stage": "create"}, parseErr
}
}
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
data, callErr := rt.CallMCPData("minutes", "get_speaker_summary", map[string]any{"uuids": []string{id}})
if callErr == nil {
result, resultErr := minutesdata.SpeakerSummaryResult(data)
if resultErr == nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": true, "taskUuid": id, "taskId": taskID, "createStatus": status, "attempts": attempts, "result": result}, nil
}
callErr = resultErr
}
if !speakerSummaryPending(callErr) {
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
return payload, callErr
}
if time.Now().Add(interval).After(deadline) {
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
return payload, minutesCompositeError("minutes_speaker_insights_timeout", "poll", payload)
}
if err := waitMinutesInterval(rt, interval); err != nil {
return map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts}, err
}
}
}
func speakerSummaryPending(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "query empty") || strings.Contains(message, "processing") || strings.Contains(message, "not ready") || strings.Contains(message, "result is empty") || strings.Contains(message, "business error: code 000") || strings.Contains(message, "暂无")
}
func executeMinutesPrepareASR(rt *shortcut.RuntimeContext) error {
desired := uniqueStrings(rt.StrSlice("words"))
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes.prepare_asr", map[string]any{
"desired": desired, "sync": rt.Bool("sync"), "remotePreconditions": []string{"read current hot words", "compute additions and deletions", "apply writes", "read back exact set"},
}))
}
currentData, err := rt.CallMCPData("minutes", "list_my_hotwords", map[string]any{})
if err != nil {
return err
}
current, err := minutesdata.HotWords(currentData)
if err != nil {
return err
}
toAdd := stringDifference(desired, current)
toDelete := []string{}
if rt.Bool("sync") {
toDelete = stringDifference(current, desired)
}
plan := map[string]any{"operation": "minutes.prepare_asr", "before": current, "desired": desired, "add": toAdd, "delete": toDelete, "sync": rt.Bool("sync")}
stages := []map[string]any{}
if len(toAdd) > 0 {
data, writeErr := rt.CallMCPWriteDataStrict("minutes", "add_personal_hot_word", map[string]any{"hotWordList": toAdd})
if writeErr == nil {
writeErr = minutesdata.RequireWriteAcknowledgement("hot-word add", data)
}
stages = append(stages, map[string]any{"name": "add", "complete": writeErr == nil, "words": toAdd})
if writeErr != nil {
plan["complete"], plan["stages"], plan["recovery"] = false, stages, map[string]any{"nextAction": "review current hot words before retrying"}
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_add_failed", "add")
}
}
if len(toDelete) > 0 {
data, writeErr := rt.CallMCPWriteDataStrict("minutes", "delete_personal_hotword", map[string]any{"hotWordList": toDelete})
if writeErr == nil {
writeErr = minutesdata.RequireWriteAcknowledgement("hot-word delete", data)
}
stages = append(stages, map[string]any{"name": "delete", "complete": writeErr == nil, "words": toDelete})
if writeErr != nil {
plan["complete"], plan["stages"], plan["recovery"] = false, stages, map[string]any{"nextAction": "list hot words; additions may already be applied"}
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_delete_failed", "delete")
}
}
verifiedData, err := rt.CallMCPData("minutes", "list_my_hotwords", map[string]any{})
if err != nil {
plan["complete"], plan["stages"] = false, stages
if outputErr := rt.Output(plan); outputErr != nil {
return outputErr
}
return err
}
verified, err := minutesdata.HotWords(verifiedData)
if err != nil {
return err
}
missing := stringDifference(desired, verified)
unexpected := []string{}
if rt.Bool("sync") {
unexpected = stringDifference(verified, desired)
}
plan["complete"], plan["verified"], plan["after"], plan["stages"] = len(missing) == 0 && len(unexpected) == 0, len(missing) == 0 && len(unexpected) == 0, verified, stages
if len(missing) > 0 || len(unexpected) > 0 {
plan["missing"], plan["unexpected"] = missing, unexpected
return outputWorkflowResult(rt, plan, true, "minutes_prepare_asr_verification_failed", "verify")
}
return rt.Output(plan)
}
func executeMinutesExportPack(rt *shortcut.RuntimeContext) error {
id := rt.Str("id")
artifacts := selectedWorkflowArtifacts(rt)
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, rt.Int("page-limit"))
if len(failures) > 0 {
payload := map[string]any{"operation": "minutes.export_pack", "complete": false, "taskUuid": id, "failures": failures, "published": false}
return outputWorkflowResult(rt, payload, true, "minutes_export_pack_incomplete", "collect")
}
target, relative, parent, err := prepareExportTarget(rt.Str("output"))
if err != nil {
return err
}
tempDir, err := minutesMkdirTemp(parent, ".dws-minutes-export-*")
if err != nil {
return err
}
cleanup := true
defer func() {
if cleanup {
_ = minutesRemoveAll(tempDir)
}
}()
files := map[string]map[string]any{}
for _, name := range artifacts {
filename := name + ".json"
value := bundle[name]
if name == "summary" {
filename = "summary.md"
if err := minutesWriteFile(filepath.Join(tempDir, filename), []byte(value.(string)), 0o600); err != nil {
return err
}
} else if err := writeJSONFile(filepath.Join(tempDir, filename), value); err != nil {
return err
}
info, err := minutesStat(filepath.Join(tempDir, filename))
if err != nil {
return err
}
files[name] = map[string]any{"file": filename, "sizeBytes": info.Size(), "complete": true}
}
if rt.Bool("include-media") {
mediaData, callErr := rt.CallMCPData("minutes", "query_minutes_audio_url", map[string]any{"taskUuid": id})
if callErr != nil {
return callErr
}
mediaURL, parseErr := minutesdata.MediaURL(mediaData)
if parseErr != nil {
return parseErr
}
download, downloadErr := minutesDownload(rt.Command().Context(), mediaURL, localio.DownloadOptions{BaseDir: tempDir, Output: "media/", PreferredName: id + mediaExtension(mediaURL)})
if downloadErr != nil {
return downloadErr
}
files["media"] = map[string]any{"file": download.RelativePath, "sizeBytes": download.SizeBytes, "complete": true}
}
manifest := map[string]any{"version": 1, "operation": "minutes.export_pack", "taskUuid": id, "complete": true, "generatedAt": time.Now().UTC().Format(time.RFC3339), "files": files}
if err := writeJSONFile(filepath.Join(tempDir, "manifest.json"), manifest); err != nil {
return err
}
if err := minutesRename(tempDir, target); err != nil {
return fmt.Errorf("发布听记归档目录失败: %w", err)
}
cleanup = false
return rt.Output(map[string]any{"operation": "minutes.export_pack", "complete": true, "taskUuid": id, "published": true, "path": filepath.ToSlash(relative), "manifest": filepath.ToSlash(filepath.Join(relative, "manifest.json")), "files": files})
}
func minutesShareFlags(includePermission bool) []shortcut.Flag {
flags := []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "单个听记 taskUuid"},
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个听记 taskUuid,最多 50 个"},
{Name: "member-uids", Type: shortcut.FlagStringSlice, Desc: "稳定成员钉钉 UID,最多 50 个", Required: true},
}
if includePermission {
flags = append(flags,
shortcut.Flag{Name: "permission", Type: shortcut.FlagString, Desc: "授予权限", Required: true, Enum: []string{"view", "download", "edit"}},
shortcut.Flag{Name: "cover", Type: shortcut.FlagBool, Desc: "覆盖已有权限"},
shortcut.Flag{Name: "sub-resources", Type: shortcut.FlagStringSlice, Desc: "可选子资源", Enum: []string{"OrigContent", "Summary", "Analysis", "Note"}},
)
}
return append(flags, shortcut.Flag{Name: "failure-policy", Type: shortcut.FlagString, Default: "stop", Desc: "成员失败策略", Enum: []string{"stop", "continue"}})
}
func validateMinutesShare(rt *shortcut.RuntimeContext) error {
ids, members := minutesIDs(rt), uniqueStrings(rt.StrSlice("member-uids"))
if len(ids) == 0 || len(ids) > 50 || len(members) == 0 || len(members) > 50 {
return apperrors.NewValidation("听记和成员 UID 数量必须各为 1..50")
}
return nil
}
func executeMinutesShare(rt *shortcut.RuntimeContext) error {
policy := map[string]float64{"edit": 2, "download": 3, "view": 4}[rt.Str("permission")]
return executeMinutesPermissionLedger(rt, "share", "add_member_permission", func(member string) map[string]any {
params := map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}, "policyId": policy}
if rt.Changed("cover") {
params["coverPermission"] = fmt.Sprintf("%t", rt.Bool("cover"))
}
if values := rt.StrSlice("sub-resources"); len(values) > 0 {
params["roleSubResourceIds"] = values
}
return params
})
}
func executeMinutesUnshare(rt *shortcut.RuntimeContext) error {
return executeMinutesPermissionLedger(rt, "unshare", "remove_member_permission", func(member string) map[string]any {
return map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}}
})
}
func executeMinutesPermissionLedger(rt *shortcut.RuntimeContext, operation, tool string, params func(string) map[string]any) error {
members := uniqueStrings(rt.StrSlice("member-uids"))
plan := map[string]any{"operation": "minutes." + operation, "taskUuids": minutesIDs(rt), "memberCount": len(members), "members": members}
if rt.DryRun() {
return rt.Output(minutesDryRunPayload(contract.DryRunPreviewPlan, "minutes."+operation, plan))
}
results := []map[string]any{}
failures := []map[string]any{}
unattempted := []string{}
for index, member := range members {
data, err := rt.CallMCPWriteDataStrict("minutes", tool, params(member))
if err == nil {
err = minutesdata.RequireWriteAcknowledgement(operation, data)
}
if err != nil {
failures = append(failures, map[string]any{"memberUid": member, "error": err.Error()})
if rt.Str("failure-policy") == "stop" {
unattempted = append(unattempted, members[index+1:]...)
break
}
continue
}
results = append(results, map[string]any{"memberUid": member, "complete": true})
}
plan["complete"], plan["succeeded"], plan["failed"], plan["unattempted"], plan["results"], plan["failures"] = len(failures) == 0, len(results), len(failures), unattempted, results, failures
return outputWorkflowResult(rt, plan, len(failures) > 0, "minutes_permission_partial", operation)
}
func collectMinutesArtifactsOnce(rt *shortcut.RuntimeContext, id string, artifacts []string, pageLimit int) (map[string]any, []map[string]any) {
bundle := map[string]any{}
failures := []map[string]any{}
for _, artifact := range artifacts {
var value any
var err error
switch artifact {
case "basic":
var data map[string]any
data, err = rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
if err == nil {
value, err = minutesdata.Basic(id, data)
}
case "summary":
var data map[string]any
data, err = rt.CallMCPData("minutes", "get_minutes_ai_summary", map[string]any{"taskUuid": id})
if err == nil {
value, err = minutesdata.SummaryText(data)
if err == nil && strings.TrimSpace(value.(string)) == "" {
err = fmt.Errorf("minutes summary is explicitly empty; analysis readiness is not proven")
}
}
case "keywords":
var data map[string]any
data, err = rt.CallMCPData("minutes", "get_minutes_keywords", map[string]any{"taskUuid": id})
if err == nil {
err = minutesdata.ValidateArtifact("keywords", id, data)
value = data["result"]
}
case "transcript":
var transcript minutesdata.TranscriptResult
transcript, err = collectTranscriptForMinutes(rt, id, pageLimit)
value = minutesdata.TranscriptPayload(id, "0", transcript)
if err == nil && len(transcript.Paragraphs) == 0 {
err = fmt.Errorf("minutes transcript is explicitly empty; analysis readiness is not proven")
}
case "todos":
var data map[string]any
data, err = rt.CallMCPData("minutes", "list_minutes_todos", map[string]any{"taskUuid": id})
if err == nil {
err = minutesdata.ValidateArtifact("todos", id, data)
value = data["result"]
}
default:
err = fmt.Errorf("unsupported artifact %q", artifact)
}
if err != nil {
failures = append(failures, map[string]any{"artifact": artifact, "error": err.Error()})
continue
}
bundle[artifact] = value
}
return bundle, failures
}
func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []string, pageLimit int, timeout, interval time.Duration) (map[string]any, []map[string]any, int) {
deadline := time.Now().Add(timeout)
attempts := 0
for {
attempts++
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, pageLimit)
if len(failures) == 0 || time.Now().Add(interval).After(deadline) {
return bundle, failures, attempts
}
if err := waitMinutesInterval(rt, interval); err != nil {
return bundle, append(failures, map[string]any{"artifact": "wait", "error": err.Error()}), attempts
}
}
}
func waitMinutesInterval(rt *shortcut.RuntimeContext, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-rt.Command().Context().Done():
return rt.Command().Context().Err()
case <-timer.C:
return nil
}
}
func outputWorkflowResult(rt *shortcut.RuntimeContext, payload map[string]any, failed bool, reason, stage string) error {
if err := rt.Output(payload); err != nil {
return err
}
if failed {
return minutesCompositeError(reason, stage, payload)
}
return nil
}
func uniqueStrings(values []string) []string {
seen := map[string]bool{}
out := []string{}
for _, value := range values {
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item != "" && !seen[item] {
seen[item] = true
out = append(out, item)
}
}
}
return out
}
func stringDifference(left, right []string) []string {
other := map[string]bool{}
for _, value := range right {
other[value] = true
}
out := []string{}
for _, value := range left {
if !other[value] {
out = append(out, value)
}
}
sort.Strings(out)
return out
}
func prepareExportTarget(output string) (target, relative, parent string, err error) {
if err := localio.ValidateOutput(output); err != nil {
return "", "", "", err
}
cwd, err := minutesGetwd()
if err != nil {
return "", "", "", err
}
realBase, err := minutesEvalSymlinks(cwd)
if err != nil {
return "", "", "", err
}
relative = filepath.Clean(output)
target = filepath.Join(realBase, relative)
parent = filepath.Dir(target)
rel, err := minutesRel(realBase, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", "", "", fmt.Errorf("LOCAL_PATH_UNSAFE: 输出目录逃逸工作目录")
}
if err := ensureExportParent(realBase, filepath.Dir(rel)); err != nil {
return "", "", "", err
}
if _, err := minutesLstat(target); err == nil {
return "", "", "", fmt.Errorf("LOCAL_FILE_EXISTS: 目标目录已存在;请选择新的输出路径")
} else if !os.IsNotExist(err) {
return "", "", "", err
}
return target, relative, parent, nil
}
func ensureExportParent(base, relative string) error {
current := base
if relative == "." || relative == "" {
return nil
}
for _, component := range strings.Split(filepath.Clean(relative), string(filepath.Separator)) {
current = filepath.Join(current, component)
info, err := minutesLstat(current)
if os.IsNotExist(err) {
if err := minutesMkdir(current, 0o700); err != nil {
return err
}
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("LOCAL_PATH_UNSAFE: 输出父路径不是安全目录")
}
}
return nil
}
func writeJSONFile(path string, value any) error {
raw, err := minutesMarshalIndent(value, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
return minutesWriteFile(path, raw, 0o600)
}
func init() {
shortcut.Register(finalizeMinutesShortcuts(RecordWrapUp, UploadAndAnalyze, Mindmap, SpeakerInsights, PrepareASR, ExportPack, Share, Unshare)...)
}
@@ -0,0 +1,667 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutes
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageMinutesWorkflowValidationAndDefaults(t *testing.T) {
file := filepath.Join(t.TempDir(), "source.wav")
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
t.Fatal(err)
}
invalid := [][]string{
{"minutes", "+record-wrap-up", "--id", "u1", "--wait-timeout", "0"},
{"minutes", "+upload-and-analyze", "--file", file, "--complete-timeout", "0"},
{"minutes", "+mindmap", "--id", "u1", "--timeout", "0"},
{"minutes", "+speaker-insights", "--id", "u1", "--interval", "0"},
{"minutes", "+export-pack", "--id", "u1", "--output", "pack", "--page-limit", "0"},
{"minutes", "+share", "--id", "u1", "--member-uids", strings.Repeat("m,", 51), "--permission", "view"},
}
for _, args := range invalid {
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, args...); err == nil || payload != nil || output != "" {
t.Fatalf("invalid workflow accepted: %v payload=%#v output=%q err=%v", args, payload, output, err)
}
}
caller := &minutesE2ECaller{}
helpers.InitDepsForTest(t, caller)
cmd := &cobra.Command{Use: "workflow"}
cmd.Flags().StringSlice("artifacts", nil, "")
rt := shortcut.RuntimeContextForTest(cmd, RecordWrapUp)
selected := selectedWorkflowArtifacts(rt)
selected[0] = "mutated"
if minutesWorkflowArtifacts[0] == "mutated" || len(selected) != len(minutesWorkflowArtifacts) {
t.Fatalf("default artifacts alias global: %#v", selected)
}
}
func TestCrossPlatformCoverageMinutesRecordWrapUpBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+record-wrap-up", "--id", "u1", "--dry-run"); err == nil {
t.Fatal("record dry-run output failure accepted")
}
stopFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/" + listeningNoteCmdTool: 1}}
if _, _, err := runMinutesAlignmentCLI(t, stopFailure, "minutes", "+record-wrap-up", "--id", "u1", "--yes"); err == nil {
t.Fatal("record stop failure accepted")
}
badStop := &minutesE2ECaller{responses: map[string][]string{"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"pause","uuid":"u1"}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, badStop, "minutes", "+record-wrap-up", "--id", "u1", "--yes"); err == nil {
t.Fatal("record mismatched acknowledgement accepted")
}
success := &minutesE2ECaller{responses: map[string][]string{
"minutes/" + listeningNoteCmdTool: {`{"success":true,"result":{"cmd":"end","uuid":"u1"}}`},
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
}}
payload, output, err := runMinutesAlignmentCLI(t, success, "minutes", "+record-wrap-up", "--id", "u1", "--artifacts", "basic", "--yes")
if err != nil || output == "" || payload["complete"] != true {
t.Fatalf("record success payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesUploadAndAnalyzeBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--dry-run"); err == nil {
t.Fatal("resume dry-run output failure accepted")
}
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload-and-analyze", "--file", filepath.Join(t.TempDir(), "missing"), "--yes"); err == nil {
t.Fatal("upload-and-analyze invalid file accepted")
}
if _, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+upload-and-analyze", "--file", filepath.Join(t.TempDir(), "missing"), "--dry-run"); err == nil {
t.Fatal("upload-and-analyze invalid dry-run file accepted")
}
resume := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1,"mindGraph":"ready"}}`},
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`},
"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, resume, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--artifacts", "basic", "--mindmap", "--speaker-insights", "--yes")
if err != nil || payload["complete"] != true || payload["taskUuid"] != "u1" {
t.Fatalf("resume payload=%#v err=%v", payload, err)
}
partial := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"done"}}`},
"minutes/create_mind_graph": {`{"success":true,"result":{}}`},
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":2}}`},
"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`},
"minutes/get_speaker_summary": {`{"success":false,"errorMsg":"denied"}`},
}}
payload, output, err := runMinutesAlignmentCLI(t, partial, "minutes", "+upload-and-analyze", "--resume-id", "u1", "--artifacts", "basic", "--mindmap", "--speaker-insights", "--yes")
if err == nil || output == "" || payload["complete"] != false || payload["recovery"] == nil {
t.Fatalf("partial payload=%#v err=%v", payload, err)
}
file := filepath.Join(t.TempDir(), "source.wav")
if err := os.WriteFile(file, []byte("audio"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &minutesPutFile, func(context.Context, string, string, int64) (localio.UploadResult, error) {
return localio.UploadResult{SizeBytes: 5, Attempts: 1}, nil
})
upload := &minutesE2ECaller{responses: map[string][]string{
"minutes/create_upload_session": {`{"success":true,"result":{"sessionId":"s1","presignedUrl":"https://example.invalid/u"}}`},
"minutes/complete_upload_session": {`{"success":true,"result":{"taskUuid":"u2"}}`},
"minutes/get_minutes_basic_info": {
`{"success":true,"result":{"taskUuid":"u2","title":"uploaded"}}`,
`{"success":true,"result":{"taskUuid":"u2","title":"uploaded"}}`,
},
}}
payload, _, err = runMinutesAlignmentCLI(t, upload, "minutes", "+upload-and-analyze", "--file", file, "--artifacts", "basic", "--yes")
if err != nil || payload["taskUuid"] != "u2" || payload["complete"] != true {
t.Fatalf("fresh upload payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesMindmapBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+mindmap", "--id", "u1", "--dry-run"); err == nil {
t.Fatal("mindmap dry-run output failure accepted")
}
cases := []struct {
name string
responses map[string][]string
failAt map[string]int
args []string
}{
{name: "create call", failAt: map[string]int{"minutes/create_mind_graph": 1}},
{name: "poll call", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}}, failAt: map[string]int{"minutes/query_mind_graph_status": 1}},
{name: "poll parse", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}, "minutes/query_mind_graph_status": {`{"success":true,"result":{}}`}}},
{name: "timeout", responses: map[string][]string{"minutes/create_mind_graph": {`{}`}, "minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":0}}`}}},
{name: "resume", responses: map[string][]string{"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1}}`}}, args: []string{"--resume"}},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
args := []string{"minutes", "+mindmap", "--id", "u1", "--timeout", "1", "--interval", "1", "--yes"}
args = append(args, test.args...)
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
if test.name == "resume" {
if err != nil || payload["complete"] != true {
t.Fatalf("resume payload=%#v err=%v", payload, err)
}
} else if err == nil || output == "" {
t.Fatalf("failure accepted payload=%#v err=%v", payload, err)
}
})
}
outputFail := &minutesE2ECaller{responses: map[string][]string{
"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":1}}`},
}}
if err := runMinutesAlignmentCLIWithWriter(t, outputFail, minutesFailWriter{}, "minutes", "+mindmap", "--id", "u1", "--resume", "--yes"); err == nil {
t.Fatal("mindmap output failure accepted")
}
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/query_mind_graph_status": {`{"success":true,"result":{"taskStatus":0}}`}}}
helpers.InitDepsForTest(t, caller)
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd := &cobra.Command{Use: "mindmap"}
cmd.SetContext(ctx)
rt := shortcut.RuntimeContextForTest(cmd, Mindmap)
if _, err := runMinutesMindmap(rt, "u1", 2*time.Hour, time.Hour, false); err == nil {
t.Fatal("mindmap cancelled wait accepted")
}
}
func TestCrossPlatformCoverageMinutesSpeakerInsightsBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+speaker-insights", "--id", "u1", "--dry-run"); err == nil {
t.Fatal("speaker dry-run output failure accepted")
}
cases := []struct {
name string
responses map[string][]string
failAt map[string]int
args []string
}{
{name: "create call", failAt: map[string]int{"minutes/create_speaker_summary": 1}},
{name: "create parse", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{}}`}}},
{name: "poll nonpending", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":false,"errorMsg":"denied"}`}}},
{name: "poll parse", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":true,"result":{}}`}}},
{name: "timeout", responses: map[string][]string{"minutes/create_speaker_summary": {`{"success":true,"result":{"taskId":"job","status":"processing"}}`}, "minutes/get_speaker_summary": {`{"success":false,"errorMsg":"processing"}`}}},
{name: "resume", responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`}}, args: []string{"--resume", "--task-id", "job"}},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
args := []string{"minutes", "+speaker-insights", "--id", "u1", "--timeout", "1", "--interval", "1", "--yes"}
args = append(args, test.args...)
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
if test.name == "resume" {
if err != nil || payload["complete"] != true || payload["taskId"] != "job" {
t.Fatalf("resume payload=%#v err=%v", payload, err)
}
} else if err == nil || output == "" {
t.Fatalf("failure accepted payload=%#v err=%v", payload, err)
}
})
}
outputFail := &minutesE2ECaller{responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":true,"result":{"summaries":[{"speaker":"a","summary":"b"}]}}`}}}
if err := runMinutesAlignmentCLIWithWriter(t, outputFail, minutesFailWriter{}, "minutes", "+speaker-insights", "--id", "u1", "--resume", "--yes"); err == nil {
t.Fatal("speaker output failure accepted")
}
for _, message := range []string{"query empty", "processing", "not ready", "result is empty", "business error: code 000", "暂无"} {
if !speakerSummaryPending(errors.New(message)) {
t.Fatalf("pending message rejected: %q", message)
}
}
if speakerSummaryPending(nil) || speakerSummaryPending(errors.New("denied")) {
t.Fatal("non-pending speaker error accepted")
}
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/get_speaker_summary": {`{"success":false,"errorMsg":"processing"}`}}}
helpers.InitDepsForTest(t, caller)
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd := &cobra.Command{Use: "speaker"}
cmd.SetContext(ctx)
rt := shortcut.RuntimeContextForTest(cmd, SpeakerInsights)
if _, err := runMinutesSpeakerInsights(rt, "u1", 2*time.Hour, time.Hour, false, "job"); err == nil {
t.Fatal("speaker cancelled wait accepted")
}
}
func TestCrossPlatformCoverageMinutesPrepareASRBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+prepare-asr", "--words", "a", "--dry-run"); err == nil {
t.Fatal("ASR dry-run output failure accepted")
}
readFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/list_my_hotwords": 1}}
if _, _, err := runMinutesAlignmentCLI(t, readFailure, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
t.Fatal("ASR current read failure accepted")
}
parseFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, parseFailure, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
t.Fatal("ASR current parse failure accepted")
}
for _, test := range []struct {
name string
responses map[string][]string
failAt map[string]int
}{
{name: "add call", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":[]}}`}}, failAt: map[string]int{"minutes/add_personal_hot_word": 1}},
{name: "add ack", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":[]}}`}, "minutes/add_personal_hot_word": {`{"result":{}}`}}},
{name: "delete call", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`}}, failAt: map[string]int{"minutes/delete_personal_hotword": 1}},
{name: "delete ack", responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`}, "minutes/delete_personal_hotword": {`{"result":{}}`}}},
} {
t.Run(test.name, func(t *testing.T) {
args := []string{"minutes", "+prepare-asr", "--words", "a", "--yes"}
if strings.HasPrefix(test.name, "delete") {
args = []string{"minutes", "+prepare-asr", "--words", "a", "--sync", "--yes"}
test.responses["minutes/add_personal_hot_word"] = []string{`{"success":true,"result":{}}`}
}
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: test.responses, failAt: test.failAt}, args...)
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("ASR failure accepted payload=%#v err=%v", payload, err)
}
})
}
verifyCall := &minutesE2ECaller{
responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["a"]}}`}},
failAt: map[string]int{"minutes/list_my_hotwords": 2},
}
payload, output, err := runMinutesAlignmentCLI(t, verifyCall, "minutes", "+prepare-asr", "--words", "a", "--yes")
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("ASR verify call payload=%#v err=%v", payload, err)
}
verifyCallOutput := &minutesE2ECaller{
responses: map[string][]string{"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["a"]}}`}},
failAt: map[string]int{"minutes/list_my_hotwords": 2},
}
if err := runMinutesAlignmentCLIWithWriter(t, verifyCallOutput, minutesFailWriter{}, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
t.Fatal("ASR verify read output failure accepted")
}
verifyParse := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {
`{"success":true,"result":{"hotWordList":["a"]}}`, `{"success":true,"result":{}}`,
}}}
if _, _, err := runMinutesAlignmentCLI(t, verifyParse, "minutes", "+prepare-asr", "--words", "a", "--yes"); err == nil {
t.Fatal("ASR verify parse failure accepted")
}
verifyMismatch := &minutesE2ECaller{responses: map[string][]string{"minutes/list_my_hotwords": {
`{"success":true,"result":{"hotWordList":["a","old"]}}`, `{"success":true,"result":{"hotWordList":["old"]}}`,
}, "minutes/delete_personal_hotword": {`{"success":true,"result":{}}`}}}
payload, output, err = runMinutesAlignmentCLI(t, verifyMismatch, "minutes", "+prepare-asr", "--words", "a", "--sync", "--yes")
if err == nil || output == "" || payload["verified"] != false {
t.Fatalf("ASR mismatch payload=%#v err=%v", payload, err)
}
syncSuccess := &minutesE2ECaller{responses: map[string][]string{
"minutes/list_my_hotwords": {`{"success":true,"result":{"hotWordList":["old"]}}`, `{"success":true,"result":{"hotWordList":["a"]}}`},
"minutes/add_personal_hot_word": {`{"success":true,"result":{}}`},
"minutes/delete_personal_hotword": {`{"success":true,"result":{}}`},
}}
payload, _, err = runMinutesAlignmentCLI(t, syncSuccess, "minutes", "+prepare-asr", "--words", "a", "--sync", "--yes")
if err != nil || payload["complete"] != true {
t.Fatalf("ASR sync payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesPermissionLedgerBranchesE2E(t *testing.T) {
many := make([]string, 51)
for index := range many {
many[index] = fmt.Sprintf("m%d", index)
}
if payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{}, "minutes", "+share", "--id", "u1", "--member-uids", strings.Join(many, ","), "--permission", "view", "--yes"); err == nil || payload != nil || output != "" {
t.Fatalf("too many members accepted payload=%#v err=%v", payload, err)
}
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--dry-run"); err == nil {
t.Fatal("unshare dry-run output failure accepted")
}
unshare := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"success":true,"result":{}}`}}}
payload, _, err := runMinutesAlignmentCLI(t, unshare, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--yes")
if err != nil || payload["complete"] != true || payload["succeeded"] != float64(1) {
t.Fatalf("unshare payload=%#v err=%v", payload, err)
}
share := &minutesE2ECaller{responses: map[string][]string{"minutes/add_member_permission": {`{"success":true,"result":{}}`}}}
payload, _, err = runMinutesAlignmentCLI(t, share, "minutes", "+share", "--id", "u1", "--member-uids", "m1", "--permission", "edit", "--cover", "--sub-resources", "Summary,Note", "--yes")
if err != nil || payload["complete"] != true {
t.Fatalf("share payload=%#v err=%v", payload, err)
}
args := share.arguments["minutes/add_member_permission"][0]
if args["coverPermission"] != "true" || len(args["roleSubResourceIds"].([]string)) != 2 {
t.Fatalf("share args=%#v", args)
}
continueFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"result":{}}`, `{"success":true,"result":{}}`}}}
payload, output, err := runMinutesAlignmentCLI(t, continueFailure, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1,m2", "--failure-policy", "continue", "--yes")
if err == nil || output == "" || payload["failed"] != float64(1) || payload["succeeded"] != float64(1) {
t.Fatalf("continue ledger payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesArtifactCollectorBranches(t *testing.T) {
success := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`},
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`},
"minutes/get_minutes_keywords": {`{"success":true,"result":{"keywords":[]}}`},
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`},
"minutes/list_minutes_todos": {`{"success":true,"result":{"actions":[]}}`},
}}
helpers.InitDepsForTest(t, success)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
bundle, failures := collectMinutesArtifactsOnce(rt, "u1", append(minutesWorkflowArtifacts, "unknown"), 10)
if len(bundle) != 5 || len(failures) != 1 {
t.Fatalf("collector bundle=%#v failures=%#v", bundle, failures)
}
for _, artifact := range []string{"basic", "summary", "keywords", "transcript", "todos"} {
t.Run("call "+artifact, func(t *testing.T) {
tool := map[string]string{
"basic": "get_minutes_basic_info", "summary": "get_minutes_ai_summary", "keywords": "get_minutes_keywords",
"transcript": "get_minutes_transcription", "todos": "list_minutes_todos",
}[artifact]
caller := &minutesE2ECaller{failAt: map[string]int{"minutes/" + tool: 1}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
got, failed := collectMinutesArtifactsOnce(rt, "u1", []string{artifact}, 1)
if len(got) != 0 || len(failed) != 1 {
t.Fatalf("got=%#v failed=%#v", got, failed)
}
})
t.Run("parse "+artifact, func(t *testing.T) {
tool := map[string]string{
"basic": "get_minutes_basic_info", "summary": "get_minutes_ai_summary", "keywords": "get_minutes_keywords",
"transcript": "get_minutes_transcription", "todos": "list_minutes_todos",
}[artifact]
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/" + tool: {`{"success":true,"result":{}}`}}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
got, failed := collectMinutesArtifactsOnce(rt, "u1", []string{artifact}, 1)
if len(got) != 0 || len(failed) != 1 {
t.Fatalf("got=%#v failed=%#v", got, failed)
}
})
}
emptySummary := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":""}}`}}}
helpers.InitDepsForTest(t, emptySummary)
rt = shortcut.RuntimeContextForTest(&cobra.Command{Use: "collect"}, ExportPack)
if _, failures = collectMinutesArtifactsOnce(rt, "u1", []string{"summary"}, 1); len(failures) != 1 {
t.Fatalf("empty summary failures=%#v", failures)
}
}
func TestCrossPlatformCoverageMinutesArtifactWaitAndOutput(t *testing.T) {
success := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`}}}
helpers.InitDepsForTest(t, success)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "wait"}, ExportPack)
if _, failures, attempts := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, time.Second, 0); len(failures) != 0 || attempts != 1 {
t.Fatalf("success failures=%#v attempts=%d", failures, attempts)
}
failing := &minutesE2ECaller{failAt: map[string]int{"minutes/get_minutes_basic_info": 1}}
helpers.InitDepsForTest(t, failing)
rt = shortcut.RuntimeContextForTest(&cobra.Command{Use: "wait"}, ExportPack)
if _, failures, attempts := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, 0, 0); len(failures) != 1 || attempts != 1 {
t.Fatalf("timeout failures=%#v attempts=%d", failures, attempts)
}
cancelled := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}}
helpers.InitDepsForTest(t, cancelled)
ctx, cancel := context.WithCancel(context.Background())
cancel()
cmd := &cobra.Command{Use: "wait"}
cmd.SetContext(ctx)
rt = shortcut.RuntimeContextForTest(cmd, ExportPack)
if _, failures, _ := waitMinutesArtifacts(rt, "u1", []string{"basic"}, 1, 2*time.Hour, time.Hour); len(failures) != 2 || failures[1]["artifact"] != "wait" {
t.Fatalf("cancel failures=%#v", failures)
}
cmd = &cobra.Command{Use: "wait"}
cmd.SetContext(context.Background())
rt = shortcut.RuntimeContextForTest(cmd, ExportPack)
if err := waitMinutesInterval(rt, 0); err != nil {
t.Fatalf("timer wait: %v", err)
}
cmd.SetOut(minutesFailWriter{})
if err := outputWorkflowResult(rt, map[string]any{"complete": true}, false, "", ""); err == nil {
t.Fatal("workflow output failure accepted")
}
}
func TestCrossPlatformCoverageMinutesExportPackBranchesE2E(t *testing.T) {
withCWD := func(t *testing.T) string {
t.Helper()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
work := t.TempDir()
if err := os.Chdir(work); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
return work
}
basicCaller := func() *minutesE2ECaller {
return &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`}}}
}
t.Run("collect failure", func(t *testing.T) {
withCWD(t)
payload, output, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`}}}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic")
if err == nil || output == "" || payload["published"] != false {
t.Fatalf("collect payload=%#v err=%v", payload, err)
}
})
t.Run("target exists", func(t *testing.T) {
work := withCWD(t)
if err := os.Mkdir(filepath.Join(work, "pack"), 0o700); err != nil {
t.Fatal(err)
}
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("existing export accepted")
}
})
t.Run("mkdir temp", func(t *testing.T) {
withCWD(t)
testseam.Swap(t, &minutesMkdirTemp, func(string, string) (string, error) { return "", errors.New("mkdir temp") })
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("mkdir temp failure accepted")
}
})
t.Run("summary write", func(t *testing.T) {
withCWD(t)
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
caller := &minutesE2ECaller{responses: map[string][]string{"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`}}}
if _, _, err := runMinutesAlignmentCLI(t, caller, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "summary"); err == nil {
t.Fatal("summary write failure accepted")
}
})
t.Run("json write", func(t *testing.T) {
withCWD(t)
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("JSON write failure accepted")
}
})
t.Run("stat", func(t *testing.T) {
withCWD(t)
testseam.Swap(t, &minutesStat, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("stat failure accepted")
}
})
for _, test := range []struct {
name string
responses map[string][]string
failAt map[string]int
download func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error)
wantError bool
}{
{name: "media call", failAt: map[string]int{"minutes/query_minutes_audio_url": 1}, wantError: true},
{name: "media parse", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{}}`}}, wantError: true},
{name: "media download", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/a.mp3"}}`}}, download: func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("download")
}, wantError: true},
{name: "media success", responses: map[string][]string{"minutes/query_minutes_audio_url": {`{"success":true,"result":{"downloadUrl":"https://example.invalid/a.mp3"}}`}}, download: func(_ context.Context, _ string, options localio.DownloadOptions) (localio.DownloadResult, error) {
path := filepath.Join(options.BaseDir, "media", "u1.mp3")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return localio.DownloadResult{}, err
}
if err := os.WriteFile(path, []byte("media"), 0o600); err != nil {
return localio.DownloadResult{}, err
}
return localio.DownloadResult{RelativePath: "media/u1.mp3", SizeBytes: 5}, nil
}},
} {
t.Run(test.name, func(t *testing.T) {
withCWD(t)
if test.download != nil {
testseam.Swap(t, &minutesDownload, test.download)
}
responses := map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`}}
for key, values := range test.responses {
responses[key] = values
}
payload, _, err := runMinutesAlignmentCLI(t, &minutesE2ECaller{responses: responses, failAt: test.failAt}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic", "--include-media")
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if !test.wantError && payload["published"] != true {
t.Fatalf("payload=%#v", payload)
}
})
}
t.Run("manifest write", func(t *testing.T) {
withCWD(t)
realWrite := minutesWriteFile
testseam.Swap(t, &minutesWriteFile, func(path string, data []byte, mode os.FileMode) error {
if filepath.Base(path) == "manifest.json" {
return errors.New("manifest")
}
return realWrite(path, data, mode)
})
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("manifest failure accepted")
}
})
t.Run("rename", func(t *testing.T) {
withCWD(t)
testseam.Swap(t, &minutesRename, func(string, string) error { return errors.New("rename") })
if _, _, err := runMinutesAlignmentCLI(t, basicCaller(), "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("rename failure accepted")
}
})
t.Run("output", func(t *testing.T) {
withCWD(t)
if err := runMinutesAlignmentCLIWithWriter(t, basicCaller(), minutesFailWriter{}, "minutes", "+export-pack", "--id", "u1", "--output", "pack", "--artifacts", "basic"); err == nil {
t.Fatal("export output failure accepted")
}
})
}
func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) {
if _, _, _, err := prepareExportTarget("../escape"); err == nil {
t.Fatal("unsafe target accepted")
}
for _, test := range []struct {
name string
run func(*testing.T)
}{
{name: "getwd", run: func(t *testing.T) {
testseam.Swap(t, &minutesGetwd, func() (string, error) { return "", errors.New("getwd") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("getwd failure accepted")
}
}},
{name: "eval", run: func(t *testing.T) {
testseam.Swap(t, &minutesEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("eval failure accepted")
}
}},
{name: "rel", run: func(t *testing.T) {
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "", errors.New("rel") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("rel failure accepted")
}
}},
{name: "escape", run: func(t *testing.T) {
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "../escape", nil })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("rel escape accepted")
}
}},
{name: "target lstat", run: func(t *testing.T) {
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("lstat") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("lstat failure accepted")
}
}},
{name: "parent", run: func(t *testing.T) {
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "parent/pack", nil })
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("parent") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("unsafe parent accepted")
}
}},
} {
t.Run(test.name, test.run)
}
base := t.TempDir()
if err := ensureExportParent(base, "."); err != nil {
t.Fatal(err)
}
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &minutesMkdir, func(string, os.FileMode) error { return errors.New("mkdir") })
if err := ensureExportParent(base, "a"); err == nil {
t.Fatal("mkdir failure accepted")
}
})
t.Run("lstat", func(t *testing.T) {
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("lstat") })
if err := ensureExportParent(base, "a"); err == nil {
t.Fatal("lstat failure accepted")
}
})
t.Run("file", func(t *testing.T) {
path := filepath.Join(base, "file")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := ensureExportParent(base, "file/child"); err == nil {
t.Fatal("file parent accepted")
}
})
t.Run("create", func(t *testing.T) {
if err := ensureExportParent(base, "created/nested"); err != nil {
t.Fatal(err)
}
})
t.Run("marshal", func(t *testing.T) {
testseam.Swap(t, &minutesMarshalIndent, func(any, string, string) ([]byte, error) { return nil, errors.New("marshal") })
if err := writeJSONFile(filepath.Join(t.TempDir(), "x.json"), map[string]any{}); err == nil {
t.Fatal("marshal failure accepted")
}
})
t.Run("write", func(t *testing.T) {
testseam.Swap(t, &minutesWriteFile, func(string, []byte, os.FileMode) error { return errors.New("write") })
if err := writeJSONFile(filepath.Join(t.TempDir(), "x.json"), map[string]any{}); err == nil {
t.Fatal("write failure accepted")
}
})
path := filepath.Join(t.TempDir(), "ok.json")
if err := writeJSONFile(path, map[string]any{"ok": true}); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil || !bytes.HasSuffix(raw, []byte("\n")) || !json.Valid(raw) {
t.Fatalf("JSON output=%q err=%v", raw, err)
}
}
@@ -0,0 +1,290 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import (
"encoding/json"
"math"
"strings"
"testing"
"time"
)
func TestCrossPlatformCoverageMinutesResponseCompletion(t *testing.T) {
for _, data := range []map[string]any{
{},
{"success": false, "message": "denied"},
{"errorCode": "E", "msg": "bad"},
{"dingOpenErrcode": float64(1)},
} {
if err := ValidateEnvelope(data); err == nil {
t.Fatalf("invalid envelope accepted: %#v", data)
}
}
for _, value := range []any{nil, "", "0", float64(0), 0, int64(0), json.Number("0")} {
if !isZeroCode(value) {
t.Fatalf("zero code rejected: %#v", value)
}
}
if isZeroCode(true) {
t.Fatal("unsupported code treated as zero")
}
if got := envelopeMessage(map[string]any{}); got != "no backend error message" {
t.Fatalf("fallback message = %q", got)
}
list, err := ParseListPage(map[string]any{
"hasNext": true, "nextCursor": "outer",
"data": map[string]any{"result": map[string]any{"records": []any{map[string]any{"taskUUID": "u1"}}}},
})
if err != nil || !list.HasMoreKnown || list.NextToken != "outer" || len(list.Items) != 1 {
t.Fatalf("nested list = %#v, %v", list, err)
}
if _, err := ParseListPage(map[string]any{"result": map[string]any{"items": []any{map[string]any{}, "bad"}}}); err == nil {
t.Fatal("non-object list item accepted")
}
transcript, err := ParseTranscriptPage(map[string]any{
"hasMore": false,
"data": map[string]any{"data": map[string]any{"paragraphList": []any{map[string]any{"sentenceId": "p"}}}},
})
if err != nil || !transcript.HasMoreKnown || transcript.HasMore || len(transcript.Items) != 1 {
t.Fatalf("nested transcript = %#v, %v", transcript, err)
}
if _, err := ParseTranscriptPage(map[string]any{"result": map[string]any{"paragraphList": []any{"bad"}}}); err == nil {
t.Fatal("non-object paragraph accepted")
}
for _, tc := range []struct {
name string
data map[string]any
}{
{"no result", map[string]any{"success": true}},
{"basic missing", map[string]any{"result": map[string]any{}}},
{"basic mismatch", map[string]any{"result": map[string]any{"taskUuid": "other"}}},
{"summary missing", map[string]any{"result": map[string]any{}}},
{"keywords wrong", map[string]any{"result": map[string]any{"keywords": "bad"}}},
{"todos wrong", map[string]any{"result": map[string]any{"actions": "bad", "dingtalkTodoList": "bad"}}},
{"other empty", map[string]any{"result": map[string]any{}}},
} {
artifact := map[string]string{"no result": "basic", "basic missing": "basic", "basic mismatch": "basic", "summary missing": "summary", "keywords wrong": "keywords", "todos wrong": "todos", "other empty": "custom"}[tc.name]
if err := ValidateArtifact(artifact, "u1", tc.data); err == nil {
t.Fatalf("%s accepted", tc.name)
}
}
if err := ValidateArtifact("basic", "u1", map[string]any{}); err == nil {
t.Fatal("artifact envelope failure ignored")
}
if err := ValidateArtifact("todos", "", map[string]any{"result": map[string]any{"dingtalkTodoList": []any{}}}); err != nil {
t.Fatal(err)
}
if err := ValidateArtifact("custom", "", map[string]any{"result": map[string]any{"value": true}}); err != nil {
t.Fatal(err)
}
if _, err := Basic("u1", map[string]any{"result": map[string]any{}}); err == nil {
t.Fatal("invalid basic accepted")
}
if basic, err := Basic("u1", map[string]any{"result": map[string]any{"uuid": "u1"}}); err != nil || basic["uuid"] != "u1" {
t.Fatalf("basic = %#v, %v", basic, err)
}
if _, err := SummaryText(map[string]any{"result": map[string]any{}}); err == nil {
t.Fatal("invalid summary accepted")
}
if _, err := SummaryText(map[string]any{"result": map[string]any{"fullSummary": 1}}); err == nil {
t.Fatal("non-string summary accepted")
}
if text, err := SummaryText(map[string]any{"result": map[string]any{"fullSummary": "ok"}}); err != nil || text != "ok" {
t.Fatalf("summary = %q, %v", text, err)
}
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}} {
if _, err := MediaURL(data); err == nil {
t.Fatalf("invalid media accepted: %#v", data)
}
}
if value, err := MediaURL(map[string]any{"result": map[string]any{"audioUrl": " https://example.invalid/a "}}); err != nil || value == "" {
t.Fatalf("media = %q, %v", value, err)
}
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{"sessionId": "s"}}} {
if _, _, err := UploadSession(data); err == nil {
t.Fatalf("invalid upload session accepted: %#v", data)
}
}
if session, url, err := UploadSession(map[string]any{"result": map[string]any{"uploadId": 7, "uploadUrl": "https://example.invalid"}}); err != nil || session != "7" || url == "" {
t.Fatalf("upload session = %q/%q, %v", session, url, err)
}
if _, err := CompletedTaskUUID(map[string]any{}); err == nil {
t.Fatal("invalid completed envelope accepted")
}
if _, err := CompletedTaskUUID(map[string]any{"result": map[string]any{}}); err == nil {
t.Fatal("missing completed UUID accepted")
}
if id, err := CompletedTaskUUID(map[string]any{"data": map[string]any{"result": map[string]any{"task_uuid": "done"}}}); err != nil || id != "done" {
t.Fatalf("completed UUID = %q, %v", id, err)
}
rows, err := ProjectList(Page{Items: []map[string]any{{
"uuid": "u", "name": "n", "creatorNick": "c", "createTime": 1,
"deadline": 2, "link": "l", "state": "ready",
}}})
if err != nil || rows[0]["title"] != "n" || rows[0]["creator"] != "c" || rows[0]["url"] != "l" {
t.Fatalf("projected fallback fields = %#v, %v", rows, err)
}
if got := TaskUUID(map[string]any{"taskUUID": json.Number("9")}); got != "9" {
t.Fatalf("numeric task UUID = %q", got)
}
if id, err := LatestTaskUUID(Page{}); err != nil || id != "" {
t.Fatalf("empty latest = %q, %v", id, err)
}
if _, err := LatestTaskUUID(Page{Items: []map[string]any{{"startTime": 1}}}); err == nil {
t.Fatal("latest missing UUID accepted")
}
latest, err := LatestTaskUUID(Page{Items: []map[string]any{
{"uuid": "skip", "createTime": "bad"},
{"uuid": "int", "createTime": 1},
{"uuid": "int64", "gmtCreate": int64(2)},
{"uuid": "number", "startTime": json.Number("3")},
{"uuid": "rfc", "createTimeStart": time.Now().UTC().Format(time.RFC3339Nano)},
}})
if err != nil || latest != "rfc" {
t.Fatalf("typed latest = %q, %v", latest, err)
}
if key, err := StableItemKey(map[string]any{"text": "same"}); err != nil || !strings.HasPrefix(key, "json:") {
t.Fatalf("JSON stable key = %q, %v", key, err)
}
if _, err := StableItemKey(map[string]any{"bad": func() {}}); err == nil {
t.Fatal("unencodable stable key accepted")
}
for _, value := range []any{"value", json.Number("4"), float64(5), 6, int64(7)} {
if got := stringField(map[string]any{"x": value}, "missing", "x"); got == "" {
t.Fatalf("stringField(%#v) empty", value)
}
}
if got := stringField(map[string]any{"x": true}, "x"); got != "" {
t.Fatalf("unsupported string field = %q", got)
}
for _, value := range []any{float64(1), 2, int64(3), json.Number("4"), "5", "2026-08-11T00:00:00Z"} {
if _, ok := timestamp(map[string]any{"x": value}, "x"); !ok {
t.Fatalf("timestamp(%#v) rejected", value)
}
}
if _, ok := timestamp(map[string]any{"x": json.Number("bad")}, "x"); ok {
t.Fatal("invalid timestamp number accepted")
}
}
func TestCrossPlatformCoverageMinutesPagerCompletion(t *testing.T) {
if _, err := CollectTranscript(nil, "", false, 1); err == nil {
t.Fatal("nil transcript caller accepted")
}
defaulted, err := CollectTranscript(func(string) (map[string]any, error) {
return map[string]any{"result": map[string]any{"paragraphList": []any{}}}, nil
}, "", false, 0)
if err != nil || !defaulted.Complete {
t.Fatalf("default max pages = %#v, %v", defaulted, err)
}
invalid, err := CollectTranscript(func(string) (map[string]any, error) { return map[string]any{}, nil }, "", false, 1)
if err == nil || invalid.FailurePage != 1 || invalid.FailureMessage == "" {
t.Fatalf("invalid page = %#v, %v", invalid, err)
}
badKey, err := CollectTranscript(func(string) (map[string]any, error) {
return map[string]any{"result": map[string]any{"paragraphList": []any{map[string]any{"bad": func() {}}}}}, nil
}, "", false, 1)
if err == nil || badKey.FailurePage != 1 {
t.Fatalf("bad key = %#v, %v", badKey, err)
}
one, err := CollectTranscript(func(string) (map[string]any, error) {
return map[string]any{"result": map[string]any{"paragraphList": []any{}, "hasNext": false}}, nil
}, "", true, 1)
if err != nil || !one.Complete {
t.Fatalf("complete single page = %#v, %v", one, err)
}
limited, err := CollectTranscript(func(token string) (map[string]any, error) {
return map[string]any{"result": map[string]any{"paragraphList": []any{}, "hasNext": true, "nextToken": token + "n"}}, nil
}, "", false, 1)
if err == nil || limited.FailurePage != 2 || limited.FailureMessage == "" {
t.Fatalf("page limit = %#v, %v", limited, err)
}
payload := TranscriptPayload("u", "1", TranscriptResult{
Paragraphs: []map[string]any{{"id": "p"}}, Pages: 1, Duplicates: 2,
NextToken: "n", FailurePage: 2, FailureMessage: "failed",
})
if payload["nextToken"] != "n" || payload["failurePage"] != 2 || payload["failure"] != "failed" {
t.Fatalf("transcript payload = %#v", payload)
}
}
func TestCrossPlatformCoverageMinutesWorkflowCompletion(t *testing.T) {
for _, data := range []map[string]any{{}, {"other": true}, {"success": "yes"}, {"success": false}} {
if err := RequireWriteAcknowledgement("write", data); err == nil {
t.Fatalf("invalid acknowledgement accepted: %#v", data)
}
}
if err := RequireWriteAcknowledgement("write", map[string]any{"result": map[string]any{"updated": true}}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
cmd, id string
data map[string]any
}{
{"pause", "u", map[string]any{}},
{"pause", "u", map[string]any{"success": true}},
{"pause", "u", map[string]any{"success": true, "result": map[string]any{"cmd": "resume", "uuid": "u"}}},
{"pause", "u", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "other"}}},
} {
if _, err := RecordResult(tc.cmd, tc.id, tc.data); err == nil {
t.Fatalf("invalid record accepted: %#v", tc)
}
}
if _, err := RecordResult("create", "", map[string]any{"success": true, "result": map[string]any{"cmd": "create"}}); err != nil {
t.Fatal(err)
}
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}, {"result": map[string]any{"taskStatus": 3}}} {
if _, _, err := MindGraphStatus(data); err == nil {
t.Fatalf("invalid mind graph accepted: %#v", data)
}
}
for _, value := range []any{0, "1", json.Number("2")} {
status, _, err := MindGraphStatus(map[string]any{"result": map[string]any{"taskStatus": value}})
if err != nil || status < 0 || status > 2 {
t.Fatalf("mind status %#v = %d, %v", value, status, err)
}
}
for _, data := range []map[string]any{{}, {"success": true}, {"success": true, "result": map[string]any{}}} {
if _, _, err := SpeakerSummaryTask(data); err == nil {
t.Fatalf("invalid speaker task accepted: %#v", data)
}
}
if id, status, err := SpeakerSummaryTask(map[string]any{"result": map[string]any{"taskID": int64(7), "status": "READY"}}); err != nil || id != "7" || status != "ready" {
t.Fatalf("speaker task = %q/%q, %v", id, status, err)
}
for _, data := range []map[string]any{{}, {"result": nil}, {"result": []any(nil)}, {"result": "bad"}} {
if _, err := SpeakerSummaryResult(data); err == nil {
t.Fatalf("invalid speaker result accepted: %#v", data)
}
}
if value, err := SpeakerSummaryResult(map[string]any{"result": []any{}}); err != nil || value == nil {
t.Fatalf("empty speaker list = %#v, %v", value, err)
}
if value, err := SpeakerSummaryResult(map[string]any{"result": map[string]any{"summary": "ok"}}); err != nil || value == nil {
t.Fatalf("speaker map = %#v, %v", value, err)
}
for _, data := range []map[string]any{{}, {"result": "bad"}, {"result": map[string]any{}}, {"result": map[string]any{"hotWordList": "bad"}}, {"result": map[string]any{"hotWordList": []any{true}}}} {
if _, err := HotWords(data); err == nil {
t.Fatalf("invalid hot words accepted: %#v", data)
}
}
words, err := HotWords(map[string]any{"result": map[string]any{"hotWordList": []any{" A ", "A", map[string]any{"content": "B"}}}})
if err != nil || len(words) != 2 {
t.Fatalf("deduplicated hot words = %#v, %v", words, err)
}
for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(-1), float64(3), json.Number("bad"), "bad", true} {
if _, err := intValue(value); err == nil {
t.Fatalf("invalid int value accepted: %#v", value)
}
}
if _, err := intValue(int64(1)); err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("unsupported int64 result = %v", err)
}
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import "fmt"
// TranscriptCaller fetches one transcript page for the supplied nextToken.
// The empty token means the first page.
type TranscriptCaller func(nextToken string) (map[string]any, error)
// TranscriptResult is the complete or partial result of transcript pagination.
type TranscriptResult struct {
Paragraphs []map[string]any
Pages int
Duplicates int
Complete bool
NextToken string
FailurePage int
FailureMessage string
}
// CollectTranscript follows hasNext/nextToken, de-duplicates paragraphs and
// fails closed on cursor drift. When a later call fails it returns the partial
// paragraphs plus the error so callers can publish an honest recovery ledger.
func CollectTranscript(call TranscriptCaller, initialToken string, singlePage bool, maxPages int) (TranscriptResult, error) {
if call == nil {
return TranscriptResult{}, fmt.Errorf("minutes transcript caller is nil")
}
if maxPages <= 0 {
maxPages = 1000
}
result := TranscriptResult{Complete: false, NextToken: initialToken}
seenTokens := map[string]bool{}
seenItems := map[string]bool{}
token := initialToken
for pageIndex := 1; pageIndex <= maxPages; pageIndex++ {
if seenTokens[token] {
result.NextToken = token
result.FailurePage = pageIndex
result.FailureMessage = "cursor stalled or cycled"
return result, fmt.Errorf("minutes transcript cursor stalled or cycled at page %d", pageIndex)
}
seenTokens[token] = true
data, err := call(token)
if err != nil {
result.NextToken = token
result.FailurePage = pageIndex
result.FailureMessage = err.Error()
return result, fmt.Errorf("fetch minutes transcript page %d: %w", pageIndex, err)
}
page, err := ParseTranscriptPage(data)
if err != nil {
result.NextToken = token
result.FailurePage = pageIndex
result.FailureMessage = err.Error()
return result, fmt.Errorf("validate minutes transcript page %d: %w", pageIndex, err)
}
result.Pages++
for _, item := range page.Items {
key, err := StableItemKey(item)
if err != nil {
result.FailurePage = pageIndex
result.FailureMessage = err.Error()
return result, err
}
if seenItems[key] {
result.Duplicates++
continue
}
seenItems[key] = true
result.Paragraphs = append(result.Paragraphs, item)
}
result.NextToken = page.NextToken
if singlePage {
result.Complete = !page.HasMore
return result, nil
}
if !page.HasMore {
result.Complete = true
result.NextToken = ""
return result, nil
}
token = page.NextToken
}
result.FailurePage = maxPages + 1
result.FailureMessage = "page safety limit exceeded"
return result, fmt.Errorf("minutes transcript exceeded page safety limit %d", maxPages)
}
// TranscriptPayload converts a pagination result into the stable public view.
func TranscriptPayload(taskUUID, direction string, result TranscriptResult) map[string]any {
paragraphs := make([]any, 0, len(result.Paragraphs))
for _, paragraph := range result.Paragraphs {
paragraphs = append(paragraphs, paragraph)
}
payload := map[string]any{
"taskUuid": taskUUID,
"direction": direction,
"complete": result.Complete,
"pages": result.Pages,
"paragraphCount": len(paragraphs),
"duplicateCount": result.Duplicates,
"paragraphList": paragraphs,
}
if result.NextToken != "" {
payload["nextToken"] = result.NextToken
}
if result.FailurePage > 0 {
payload["failurePage"] = result.FailurePage
}
if result.FailureMessage != "" {
payload["failure"] = result.FailureMessage
}
return payload
}
@@ -0,0 +1,51 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import (
"errors"
"fmt"
"testing"
)
func TestCrossPlatformCoverageTranscriptCollectsPagesAndDeduplicatesE2E(t *testing.T) {
calls := []string{}
result, err := CollectTranscript(func(token string) (map[string]any, error) {
calls = append(calls, token)
if token == "" {
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"},{"paragraphId":"p2"}],"hasNext":true,"nextToken":"n2"}}`), nil
}
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p2"},{"paragraphId":"p3"}],"hasNext":false,"nextToken":""}}`), nil
}, "", false, 10)
if err != nil || !result.Complete || result.Pages != 2 || len(result.Paragraphs) != 3 || result.Duplicates != 1 || fmt.Sprint(calls) != "[ n2]" {
t.Fatalf("collect = %#v calls=%v err=%v", result, calls, err)
}
}
func TestCrossPlatformCoverageTranscriptPartialReadNeverBecomesSuccessE2E(t *testing.T) {
result, err := CollectTranscript(func(token string) (map[string]any, error) {
if token == "" {
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`), nil
}
return nil, errors.New("backend failed")
}, "", false, 10)
if err == nil || result.Complete || result.Pages != 1 || len(result.Paragraphs) != 1 || result.FailurePage != 2 {
t.Fatalf("partial read = %#v err=%v", result, err)
}
}
func TestCrossPlatformCoverageTranscriptCursorCycleAndSinglePageE2E(t *testing.T) {
cycle, err := CollectTranscript(func(string) (map[string]any, error) {
return decode(t, `{"result":{"paragraphList":[],"hasNext":true,"nextToken":"same"}}`), nil
}, "same", false, 10)
if err == nil || cycle.Complete {
t.Fatalf("cursor cycle = %#v err=%v", cycle, err)
}
one, err := CollectTranscript(func(string) (map[string]any, error) {
return decode(t, `{"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`), nil
}, "", true, 10)
if err != nil || one.Complete || one.Pages != 1 || one.NextToken != "n2" {
t.Fatalf("single page = %#v err=%v", one, err)
}
}
+441
View File
@@ -0,0 +1,441 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package minutesdata owns response validation and pagination facts shared by
// Minutes shortcuts. A missing or malformed collection is never treated as an
// empty successful result.
package minutesdata
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// Page is one validated Minutes collection page.
type Page struct {
Items []map[string]any
HasMore bool
HasMoreKnown bool
NextToken string
}
var listKeys = []string{"itemList", "minutesList", "items", "list", "records", "dataList"}
// ParseListPage validates list_by_keyword_and_time_range and returns its
// explicit collection. An explicit [] is success; an absent/null/wrongly typed
// collection is a contract error.
func ParseListPage(data map[string]any) (Page, error) {
if err := validateEnvelope(data); err != nil {
return Page{}, err
}
containers := collectionContainers(data)
for _, container := range containers {
for _, key := range listKeys {
value, exists := container[key]
if !exists {
continue
}
items, err := mapItems(value, key)
if err != nil {
return Page{}, err
}
hasMore, known := boolField(container, "hasMore", "hasNext")
if !known {
hasMore, known = boolField(data, "hasMore", "hasNext")
}
next := stringField(container, "nextToken", "nextCursor", "pageToken")
if next == "" {
next = stringField(data, "nextToken", "nextCursor", "pageToken")
}
if hasMore && next == "" {
return Page{}, fmt.Errorf("minutes list declares another page but omits nextToken")
}
return Page{Items: items, HasMore: hasMore, HasMoreKnown: known, NextToken: next}, nil
}
}
return Page{}, fmt.Errorf("minutes list response has no recognized collection (want result.itemList)")
}
// ParseTranscriptPage validates get_minutes_transcription. The real response
// collection is result.paragraphList and pagination uses hasNext/nextToken.
func ParseTranscriptPage(data map[string]any) (Page, error) {
if err := validateEnvelope(data); err != nil {
return Page{}, err
}
containers := collectionContainers(data)
for _, container := range containers {
value, exists := container["paragraphList"]
if !exists {
continue
}
items, err := mapItems(value, "paragraphList")
if err != nil {
return Page{}, err
}
hasMore, known := boolField(container, "hasNext", "hasMore")
if !known {
hasMore, known = boolField(data, "hasNext", "hasMore")
}
next := stringField(container, "nextToken", "nextCursor")
if next == "" {
next = stringField(data, "nextToken", "nextCursor")
}
if hasMore && next == "" {
return Page{}, fmt.Errorf("minutes transcript declares another page but omits nextToken")
}
return Page{Items: items, HasMore: hasMore, HasMoreKnown: known, NextToken: next}, nil
}
return Page{}, fmt.Errorf("minutes transcript response has no result.paragraphList collection")
}
// ValidateArtifact rejects successful-looking but structurally empty artifact
// responses. This is intentionally artifact-specific: a generic {} cannot
// prove that the requested Minutes business data was returned.
func ValidateArtifact(name, taskUUID string, data map[string]any) error {
if err := validateEnvelope(data); err != nil {
return err
}
result, ok := data["result"].(map[string]any)
if !ok {
return fmt.Errorf("minutes %s response has no result object", name)
}
switch name {
case "basic":
actual := TaskUUID(result)
if actual == "" {
return fmt.Errorf("minutes basic response has no task UUID")
}
if taskUUID != "" && actual != taskUUID {
return fmt.Errorf("minutes basic response task UUID mismatch")
}
case "summary":
if _, exists := result["fullSummary"]; !exists {
return fmt.Errorf("minutes summary response has no fullSummary field")
}
case "keywords":
if _, err := mapSliceField(result, "keywords"); err != nil {
return fmt.Errorf("minutes keywords response: %w", err)
}
case "todos":
if _, actionsErr := mapSliceField(result, "actions"); actionsErr != nil {
if _, todosErr := mapSliceField(result, "dingtalkTodoList"); todosErr != nil {
return fmt.Errorf("minutes todos response has neither actions nor dingtalkTodoList array")
}
}
default:
if len(result) == 0 {
return fmt.Errorf("minutes %s response result is empty", name)
}
}
return nil
}
// ValidateEnvelope exposes the common backend failure check for composite
// write/read steps that apply their own operation-specific shape validation.
func ValidateEnvelope(data map[string]any) error {
return validateEnvelope(data)
}
// Basic returns the validated basic-info result.
func Basic(taskUUID string, data map[string]any) (map[string]any, error) {
if err := ValidateArtifact("basic", taskUUID, data); err != nil {
return nil, err
}
return data["result"].(map[string]any), nil
}
// SummaryText returns the full summary, including an explicitly empty string.
func SummaryText(data map[string]any) (string, error) {
if err := ValidateArtifact("summary", "", data); err != nil {
return "", err
}
value, ok := data["result"].(map[string]any)["fullSummary"].(string)
if !ok {
return "", fmt.Errorf("minutes summary fullSummary has type %T, want string", data["result"].(map[string]any)["fullSummary"])
}
return value, nil
}
// MediaURL returns the actual audio/video download URL. An empty result is not
// accepted as "no media" because it cannot distinguish processing, expiry and
// permission failures.
func MediaURL(data map[string]any) (string, error) {
if err := validateEnvelope(data); err != nil {
return "", err
}
result, ok := data["result"].(map[string]any)
if !ok {
return "", fmt.Errorf("minutes media response has no result object")
}
value := stringField(result, "videoUrl", "audioUrl", "downloadUrl", "url")
if value == "" {
return "", fmt.Errorf("minutes media response has no audio/video URL")
}
return value, nil
}
// UploadSession extracts the two facts required to continue a local upload.
func UploadSession(data map[string]any) (sessionID, presignedURL string, err error) {
if err := validateEnvelope(data); err != nil {
return "", "", err
}
result, ok := data["result"].(map[string]any)
if !ok {
return "", "", fmt.Errorf("minutes upload create response has no result object")
}
sessionID = stringField(result, "sessionId", "uploadId")
presignedURL = stringField(result, "presignedUrl", "uploadUrl")
if sessionID == "" || presignedURL == "" {
return "", "", fmt.Errorf("minutes upload create response is missing sessionId or presignedUrl")
}
return sessionID, presignedURL, nil
}
// CompletedTaskUUID extracts the created Minutes task UUID from upload complete.
func CompletedTaskUUID(data map[string]any) (string, error) {
if err := validateEnvelope(data); err != nil {
return "", err
}
containers := collectionContainers(data)
for _, container := range containers {
if uuid := TaskUUID(container); uuid != "" {
return uuid, nil
}
}
return "", fmt.Errorf("minutes upload complete response has no task UUID")
}
// ProjectList converts validated list items into the stable Shortcut view.
// Every row must carry a real task UUID; malformed rows fail the entire page
// instead of disappearing and masquerading as an empty result.
func ProjectList(page Page) ([]map[string]any, error) {
out := make([]map[string]any, 0, len(page.Items))
for index, item := range page.Items {
uuid := TaskUUID(item)
if uuid == "" {
return nil, fmt.Errorf("minutes list item %d has no task UUID", index)
}
row := map[string]any{"taskUuid": uuid}
copyFirst(row, "title", item, "title", "name")
copyFirst(row, "creator", item, "creator", "creatorName", "createUserName", "creatorNick")
copyFirst(row, "startTime", item, "startTime", "gmtStart", "beginTime", "createTime")
copyFirst(row, "endTime", item, "endTime", "gmtEnd", "deadline")
copyFirst(row, "url", item, "url", "shareUrl", "link")
copyFirst(row, "status", item, "status", "taskStatus", "state")
out = append(out, row)
}
return out, nil
}
// TaskUUID returns only spellings known to identify a Minutes task. A generic
// id/minutesId is deliberately not accepted because record-control consumes
// this value directly as the task UUID.
func TaskUUID(item map[string]any) string {
return stringField(item, "taskUuid", "taskUUID", "task_uuid", "uuid")
}
// LatestTaskUUID selects the newest item by an explicit comparable timestamp.
// It never silently assumes that an undocumented response order is newest-first.
func LatestTaskUUID(page Page) (string, error) {
if len(page.Items) == 0 {
return "", nil
}
bestUUID := ""
var bestTime float64
haveTime := false
for index, item := range page.Items {
uuid := TaskUUID(item)
if uuid == "" {
return "", fmt.Errorf("minutes list item %d has no task UUID", index)
}
value, ok := timestamp(item, "createTime", "gmtCreate", "startTime", "createTimeStart")
if !ok {
continue
}
if !haveTime || value > bestTime {
haveTime = true
bestTime = value
bestUUID = uuid
}
}
if !haveTime {
return "", fmt.Errorf("minutes list is non-empty but no item has a comparable creation/start time")
}
return bestUUID, nil
}
// StableItemKey returns a deterministic de-duplication key for a transcript
// paragraph. Stable IDs win; otherwise the canonical JSON object is used.
func StableItemKey(item map[string]any) (string, error) {
if id := stringField(item, "paragraphId", "sentenceId", "segmentId", "id"); id != "" {
return "id:" + id, nil
}
raw, err := json.Marshal(item)
if err != nil {
return "", fmt.Errorf("encode transcript paragraph key: %w", err)
}
return "json:" + string(raw), nil
}
func validateEnvelope(data map[string]any) error {
if len(data) == 0 {
return fmt.Errorf("minutes response is empty")
}
if success, ok := data["success"].(bool); ok && !success {
return fmt.Errorf("minutes backend reported failure: %s", envelopeMessage(data))
}
for _, key := range []string{"errorCode", "dingOpenErrcode"} {
value, exists := data[key]
if !exists || isZeroCode(value) {
continue
}
return fmt.Errorf("minutes backend reported %s=%v: %s", key, value, envelopeMessage(data))
}
return nil
}
func collectionContainers(data map[string]any) []map[string]any {
containers := []map[string]any{data}
for _, key := range []string{"result", "data"} {
if inner, ok := data[key].(map[string]any); ok {
containers = append(containers, inner)
for _, nestedKey := range []string{"result", "data"} {
if nested, ok := inner[nestedKey].(map[string]any); ok {
containers = append(containers, nested)
}
}
}
}
return containers
}
func mapItems(value any, field string) ([]map[string]any, error) {
raw, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("minutes response field %s has type %T, want array", field, value)
}
items := make([]map[string]any, 0, len(raw))
for index, value := range raw {
item, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("minutes response field %s item %d has type %T, want object", field, index, value)
}
items = append(items, item)
}
return items, nil
}
func mapSliceField(data map[string]any, field string) ([]any, error) {
value, exists := data[field]
if !exists {
return nil, fmt.Errorf("missing %s", field)
}
items, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("field %s has type %T, want array", field, value)
}
return items, nil
}
func copyFirst(dst map[string]any, dstKey string, src map[string]any, keys ...string) {
for _, key := range keys {
if value, ok := src[key]; ok && value != nil {
dst[dstKey] = value
return
}
}
}
func boolField(data map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
if value, ok := data[key].(bool); ok {
return value, true
}
}
return false, false
}
func stringField(data map[string]any, keys ...string) string {
for _, key := range keys {
value, exists := data[key]
if !exists || value == nil {
continue
}
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) != "" {
return strings.TrimSpace(typed)
}
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
}
}
return ""
}
func timestamp(data map[string]any, keys ...string) (float64, bool) {
for _, key := range keys {
value, exists := data[key]
if !exists || value == nil {
continue
}
switch typed := value.(type) {
case float64:
return typed, true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case json.Number:
if parsed, err := typed.Float64(); err == nil {
return parsed, true
}
case string:
if parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil {
return parsed, true
}
if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(typed)); err == nil {
return float64(parsed.UnixNano()), true
}
}
}
return 0, false
}
func isZeroCode(value any) bool {
switch typed := value.(type) {
case nil:
return true
case string:
trimmed := strings.TrimSpace(typed)
return trimmed == "" || trimmed == "0"
case float64:
return typed == 0
case int:
return typed == 0
case int64:
return typed == 0
case json.Number:
return typed.String() == "0"
default:
return false
}
}
func envelopeMessage(data map[string]any) string {
for _, key := range []string{"errorMsg", "message", "msg"} {
if value := stringField(data, key); value != "" {
return value
}
}
return "no backend error message"
}
@@ -0,0 +1,113 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import (
"encoding/json"
"strings"
"testing"
)
func decode(t *testing.T, raw string) map[string]any {
t.Helper()
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("decode fixture: %v", err)
}
return data
}
func TestCrossPlatformCoverageListExplicitEmptyAndRealItemListE2E(t *testing.T) {
empty, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[],"hasMore":false,"nextToken":""}}`))
if err != nil || len(empty.Items) != 0 || empty.HasMore {
t.Fatalf("explicit empty = %#v, %v", empty, err)
}
page, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[{"uuid":"u1","title":"n","startTime":"2026-08-10T01:00:00Z"}],"hasMore":true,"nextToken":"next"}}`))
if err != nil || len(page.Items) != 1 || !page.HasMore || page.NextToken != "next" {
t.Fatalf("real itemList = %#v, %v", page, err)
}
rows, err := ProjectList(page)
if err != nil || len(rows) != 1 || rows[0]["taskUuid"] != "u1" {
t.Fatalf("projection = %#v, %v", rows, err)
}
}
func TestCrossPlatformCoverageListUnknownOrMalformedIsNotEmptySuccessE2E(t *testing.T) {
tests := []string{
`{}`,
`{"success":true}`,
`{"success":false,"errorMsg":"denied"}`,
`{"success":true,"result":{"itemList":null}}`,
`{"success":true,"result":{"itemList":{}}}`,
`{"success":true,"result":{"itemList":["bad"]}}`,
`{"success":true,"result":{"hasMore":true,"itemList":[]}}`,
}
for _, raw := range tests {
if page, err := ParseListPage(decode(t, raw)); err == nil {
t.Fatalf("unknown response accepted as %#v: %s", page, raw)
}
}
page, err := ParseListPage(decode(t, `{"success":true,"result":{"itemList":[{"title":"missing uuid"}]}}`))
if err != nil {
t.Fatalf("parse list envelope: %v", err)
}
if rows, err := ProjectList(page); err == nil || rows != nil {
t.Fatalf("malformed item projected: %#v, %v", rows, err)
}
}
func TestCrossPlatformCoverageTranscriptShapeAndPaginationContractE2E(t *testing.T) {
page, err := ParseTranscriptPage(decode(t, `{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`))
if err != nil || len(page.Items) != 1 || !page.HasMore || page.NextToken != "n2" {
t.Fatalf("transcript page = %#v, %v", page, err)
}
if key, err := StableItemKey(page.Items[0]); err != nil || key != "id:p1" {
t.Fatalf("stable key = %q, %v", key, err)
}
for _, raw := range []string{
`{"success":true,"result":{"paragraphList":null}}`,
`{"success":true,"result":{"paragraphList":[],"hasNext":true}}`,
`{"success":true,"result":{}}`,
} {
if _, err := ParseTranscriptPage(decode(t, raw)); err == nil {
t.Fatalf("invalid transcript accepted: %s", raw)
}
}
}
func TestCrossPlatformCoverageLatestRequiresComparableTimestampE2E(t *testing.T) {
page, err := ParseListPage(decode(t, `{"result":{"itemList":[{"uuid":"old","startTime":1},{"uuid":"new","startTime":"2"}]}}`))
if err != nil {
t.Fatal(err)
}
if got, err := LatestTaskUUID(page); err != nil || got != "new" {
t.Fatalf("latest = %q, %v", got, err)
}
page, err = ParseListPage(decode(t, `{"result":{"itemList":[{"uuid":"u"}]}}`))
if err != nil {
t.Fatal(err)
}
if _, err := LatestTaskUUID(page); err == nil || !strings.Contains(err.Error(), "comparable") {
t.Fatalf("missing time accepted: %v", err)
}
}
func TestCrossPlatformCoverageArtifactEmptyResultIsNotSuccessE2E(t *testing.T) {
valid := map[string]string{
"basic": `{"success":true,"result":{"taskUuid":"u"}}`,
"summary": `{"success":true,"result":{"fullSummary":""}}`,
"keywords": `{"success":true,"result":{"keywords":[]}}`,
"todos": `{"success":true,"result":{"actions":[]}}`,
}
for name, raw := range valid {
if err := ValidateArtifact(name, "u", decode(t, raw)); err != nil {
t.Fatalf("valid %s rejected: %v", name, err)
}
}
for _, name := range []string{"basic", "summary", "keywords", "todos"} {
if err := ValidateArtifact(name, "u", decode(t, `{"success":true,"result":{}}`)); err == nil {
t.Fatalf("empty %s result accepted", name)
}
}
}
+175
View File
@@ -0,0 +1,175 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
)
// RequireWriteAcknowledgement accepts only an explicit successful envelope or
// a non-empty result object. A bare {} cannot prove a remote write succeeded.
func RequireWriteAcknowledgement(operation string, data map[string]any) error {
if err := validateEnvelope(data); err != nil {
return err
}
if success, exists := data["success"]; exists {
value, ok := success.(bool)
if !ok || !value {
return fmt.Errorf("minutes %s response has invalid success acknowledgement", operation)
}
return nil
}
if result, ok := data["result"].(map[string]any); ok && len(result) > 0 {
return nil
}
return fmt.Errorf("minutes %s response has no explicit successful acknowledgement", operation)
}
// RecordResult validates the gateway's observed listening-note command result.
func RecordResult(expectedCmd, taskUUID string, data map[string]any) (map[string]any, error) {
if err := RequireWriteAcknowledgement("record "+expectedCmd, data); err != nil {
return nil, err
}
result, ok := data["result"].(map[string]any)
if !ok {
return nil, fmt.Errorf("minutes record %s response has no result object", expectedCmd)
}
cmd := stringField(result, "cmd")
if cmd != expectedCmd {
return nil, fmt.Errorf("minutes record response cmd %q does not match %q", cmd, expectedCmd)
}
if expectedCmd != "create" {
actual := TaskUUID(result)
if actual == "" || actual != taskUUID {
return nil, fmt.Errorf("minutes record %s response task UUID mismatch", expectedCmd)
}
}
return result, nil
}
// MindGraphStatus extracts the documented 0=processing, 1=success, 2=failed
// state and rejects empty status responses.
func MindGraphStatus(data map[string]any) (int, map[string]any, error) {
if err := validateEnvelope(data); err != nil {
return 0, nil, err
}
result, ok := data["result"].(map[string]any)
if !ok {
return 0, nil, fmt.Errorf("minutes mind graph response has no result object")
}
value, exists := result["taskStatus"]
if !exists {
return 0, nil, fmt.Errorf("minutes mind graph response has no taskStatus")
}
status, err := intValue(value)
if err != nil || status < 0 || status > 2 {
return 0, nil, fmt.Errorf("minutes mind graph taskStatus is invalid: %v", value)
}
return status, result, nil
}
// SpeakerSummaryTask extracts the recovery handle returned by task creation.
func SpeakerSummaryTask(data map[string]any) (taskID, status string, err error) {
if err := RequireWriteAcknowledgement("speaker summary create", data); err != nil {
return "", "", err
}
result, ok := data["result"].(map[string]any)
if !ok {
return "", "", fmt.Errorf("minutes speaker summary create response has no result object")
}
taskID = stringField(result, "taskId", "taskID")
status = strings.ToLower(stringField(result, "status"))
if taskID == "" {
return "", "", fmt.Errorf("minutes speaker summary create response has no taskId")
}
return taskID, status, nil
}
// SpeakerSummaryResult requires a concrete result payload. The API returns a
// business error while processing, so an empty object is never ready.
func SpeakerSummaryResult(data map[string]any) (any, error) {
if err := validateEnvelope(data); err != nil {
return nil, err
}
result, exists := data["result"]
if !exists || result == nil {
return nil, fmt.Errorf("minutes speaker summary response has no result")
}
switch value := result.(type) {
case map[string]any:
if len(value) == 0 {
return nil, fmt.Errorf("minutes speaker summary result is empty")
}
case []any:
if value == nil {
return nil, fmt.Errorf("minutes speaker summary result is null")
}
default:
return nil, fmt.Errorf("minutes speaker summary result has type %T", result)
}
return result, nil
}
// HotWords extracts the explicit current personal hot-word set. [] is valid;
// a missing/null/wrongly typed list is not.
func HotWords(data map[string]any) ([]string, error) {
if err := validateEnvelope(data); err != nil {
return nil, err
}
result, ok := data["result"].(map[string]any)
if !ok {
return nil, fmt.Errorf("minutes hot-word response has no result object")
}
raw, exists := result["hotWordList"]
if !exists || raw == nil {
return nil, fmt.Errorf("minutes hot-word response has no hotWordList array")
}
items, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("minutes hotWordList has type %T, want array", raw)
}
out := make([]string, 0, len(items))
seen := map[string]bool{}
for index, item := range items {
word := ""
switch value := item.(type) {
case string:
word = value
case map[string]any:
word = stringField(value, "hotWord", "word", "name", "content")
}
word = strings.TrimSpace(word)
if word == "" {
return nil, fmt.Errorf("minutes hotWordList item %d has no word", index)
}
if !seen[word] {
seen[word] = true
out = append(out, word)
}
}
return out, nil
}
func intValue(value any) (int, error) {
switch typed := value.(type) {
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed < 0 || typed > 2 {
return 0, fmt.Errorf("invalid status number %v", typed)
}
return int(typed), nil
case int:
return typed, nil
case json.Number:
parsed, err := strconv.Atoi(string(typed))
return parsed, err
case string:
return strconv.Atoi(strings.TrimSpace(typed))
default:
return 0, fmt.Errorf("unsupported number type %T", value)
}
}
@@ -0,0 +1,50 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package minutesdata
import (
"encoding/json"
"testing"
)
func TestCrossPlatformCoverageMinutesWorkflowShapesRejectUnknownE2E(t *testing.T) {
if err := RequireWriteAcknowledgement("write", map[string]any{}); err == nil {
t.Fatal("bare write response accepted")
}
if _, err := HotWords(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
t.Fatal("missing hot-word list accepted")
}
if _, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
t.Fatal("missing mind graph status accepted")
}
if _, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": 1.5}}); err == nil {
t.Fatal("fractional mind graph status accepted")
}
if _, err := SpeakerSummaryResult(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
t.Fatal("empty speaker summary accepted")
}
}
func TestCrossPlatformCoverageMinutesWorkflowObservedShapesE2E(t *testing.T) {
record, err := RecordResult("pause", "u1", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "u1"}})
if err != nil || record["cmd"] != "pause" {
t.Fatalf("record=%#v err=%v", record, err)
}
status, _, err := MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": float64(1)}})
if err != nil || status != 1 {
t.Fatalf("mind status=%d err=%v", status, err)
}
status, _, err = MindGraphStatus(map[string]any{"success": true, "result": map[string]any{"taskStatus": json.Number("1")}})
if err != nil || status != 1 {
t.Fatalf("number-preserving mind status=%d err=%v", status, err)
}
taskID, state, err := SpeakerSummaryTask(map[string]any{"success": true, "result": map[string]any{"taskId": "job-1", "status": "processing"}})
if err != nil || taskID != "job-1" || state != "processing" {
t.Fatalf("speaker task=%q state=%q err=%v", taskID, state, err)
}
words, err := HotWords(map[string]any{"success": true, "result": map[string]any{"hotWordList": []any{"DWS", map[string]any{"hotWord": "听记"}}}})
if err != nil || len(words) != 2 || words[1] != "听记" {
t.Fatalf("words=%#v err=%v", words, err)
}
}
@@ -334,12 +334,33 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"mail\u0000+thread-list": {},
"mail\u0000+unread-mail": {},
"mail\u0000+user-search": {},
"minutes\u0000+action-items": {},
"minutes\u0000+apply-permission": {},
"minutes\u0000+detail": {},
"minutes\u0000+download": {},
"minutes\u0000+export-pack": {},
"minutes\u0000+latest": {},
"minutes\u0000+list-all": {},
"minutes\u0000+list-mine": {},
"minutes\u0000+list-shared": {},
"minutes\u0000+mindmap": {},
"minutes\u0000+prepare-asr": {},
"minutes\u0000+record-pause": {},
"minutes\u0000+record-resume": {},
"minutes\u0000+record-start": {},
"minutes\u0000+record-stop": {},
"minutes\u0000+record-wrap-up": {},
"minutes\u0000+replace-batch": {},
"minutes\u0000+search": {},
"minutes\u0000+share": {},
"minutes\u0000+speaker-insights": {},
"minutes\u0000+speaker-replace": {},
"minutes\u0000+summary": {},
"minutes\u0000+transcript": {},
"minutes\u0000+unshare": {},
"minutes\u0000+update": {},
"minutes\u0000+upload": {},
"minutes\u0000+upload-and-analyze": {},
"oa\u0000+list-cc": {},
"oa\u0000+list-executed": {},
"oa\u0000+list-forms": {},
+4
View File
@@ -19,6 +19,9 @@ var docSemanticCatalogJSON []byte
//go:embed semantic_catalog_aitable.json
var aitableSemanticCatalogJSON []byte
//go:embed semantic_catalog_minutes.json
var minutesSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -40,6 +43,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
semanticCatalogJSON,
docSemanticCatalogJSON,
aitableSemanticCatalogJSON,
minutesSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -0,0 +1,35 @@
{
"version": 1,
"service": "minutes",
"default_availability": "available",
"shortcuts": {
"+search": {"disposition":"primary_smart","semantic_delta":"Validated itemList parsing, deterministic title filtering, bounded cursor pagination, de-duplication and completeness ledger; replaces the deprecated +minutes-search discovery route.","risk":"read","public":true,"reviewed":true},
"+minutes-search": {"disposition":"alias_internal","semantic_delta":"Hidden compatibility leaf preserving the historical Schema identity and argv while routing new discovery to +search.","risk":"read","primary":"+search","public":false,"reviewed":true},
"+download": {"disposition":"primary_smart","semantic_delta":"Resolves real audio/video URLs, validates response shape and performs batch safe atomic local downloads with an explicit failure ledger.","risk":"read","public":true,"reviewed":true},
"+upload": {"disposition":"primary_smart","semantic_delta":"Owns local file validation, create-PUT-complete polling, pre-complete transfer cancellation compensation, unknown-completion recovery and final task read-back verification.","risk":"write","public":true,"reviewed":true},
"+update": {"disposition":"semantic_adapter","semantic_delta":"Reads the current title, previews the diff, avoids no-op writes and verifies the final title by read-back.","risk":"write","public":true,"reviewed":true},
"+apply-permission": {"disposition":"semantic_adapter","semantic_delta":"Maps view/download/edit intent to policy 4/3/2 and validates the permission-request response.","risk":"write","public":true,"reviewed":true},
"+summary": {"disposition":"primary_smart","semantic_delta":"Reads current summary, supports literal/file/stdin, preserves Markdown images, previews the change and verifies full read-back.","risk":"write","public":true,"reviewed":true},
"+speaker-replace": {"disposition":"primary_smart","semantic_delta":"Fully paginates the transcript to preflight source speaker presence and verifies the nickname replacement after writing.","risk":"write","public":true,"reviewed":true},
"+record-wrap-up": {"disposition":"primary_smart","semantic_delta":"Stops recording once, then boundedly waits for selected validated artifacts and preserves taskUuid recovery on partial completion.","risk":"write","public":true,"reviewed":true},
"+upload-and-analyze": {"disposition":"primary_smart","semantic_delta":"Completes local upload with compensation, waits for validated artifacts, and optionally orchestrates mind map and speaker insights without re-upload recovery hazards.","risk":"write","public":true,"reviewed":true},
"+mindmap": {"disposition":"primary_smart","semantic_delta":"Creates the asynchronous mind-graph task exactly once and polls the explicit 0/1/2 taskStatus to success, failure or timeout.","risk":"write","public":true,"reviewed":true},
"+speaker-insights": {"disposition":"primary_smart","semantic_delta":"Requires a real async taskId, boundedly polls speaker summaries and returns task recovery handles when content is not ready.","risk":"write","public":true,"reviewed":true},
"+prepare-asr": {"disposition":"primary_smart","semantic_delta":"Reads the real personal hot-word set, computes deterministic add/delete differences, defaults to additive changes and verifies final state.","risk":"write","public":true,"reviewed":true},
"+export-pack": {"disposition":"primary_smart","semantic_delta":"Validates selected artifacts, safely publishes a no-clobber local directory and emits a manifest without signed URLs or credentials.","risk":"read","public":true,"reviewed":true},
"+share": {"disposition":"primary_smart","semantic_delta":"Grants semantic view/download/edit permissions per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.","risk":"write","public":true,"reviewed":true},
"+unshare": {"disposition":"primary_smart","semantic_delta":"Removes permission per stable member UID with stop/continue partial-write ledgers and explicit acknowledgements.","risk":"write","public":true,"reviewed":true},
"+list-mine": {"disposition":"schema_leaf","semantic_delta":"Stable projection of current-user Minutes with strict itemList response validation.","risk":"read","public":true,"reviewed":true},
"+list-shared": {"disposition":"schema_leaf","semantic_delta":"Stable projection of shared Minutes with strict itemList response validation.","risk":"read","public":true,"reviewed":true},
"+list-all": {"disposition":"schema_leaf","semantic_delta":"Stable projection of all accessible Minutes with strict itemList response validation.","risk":"read","public":true,"reviewed":true},
"+record-start": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-create facade with explicit confirmation and stable session parameter.","risk":"write","public":true,"reviewed":true},
"+record-pause": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-pause facade with explicit task UUID validation and confirmation.","risk":"write","public":true,"reviewed":true},
"+record-resume": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-resume facade with explicit task UUID validation and confirmation.","risk":"write","public":true,"reviewed":true},
"+record-stop": {"disposition":"semantic_adapter","semantic_delta":"Friendly recording-stop facade with explicit task UUID validation and confirmation.","risk":"write","public":true,"reviewed":true},
"+latest": {"disposition":"primary_smart","semantic_delta":"Validates the real itemList response and chooses latest only from an explicit comparable timestamp before reading details; +latest-minutes remains a compatibility alias.","risk":"read","public":true,"reviewed":true},
"+transcript": {"disposition":"primary_smart","semantic_delta":"Resolves an explicit or latest task, follows every transcript cursor, de-duplicates paragraphs and publishes completeness.","risk":"read","public":true,"reviewed":true},
"+action-items": {"disposition":"primary_smart","semantic_delta":"Resolves the latest task through strict itemList parsing and retrieves extracted Minutes actions without pretending to write Todo objects.","risk":"read","public":true,"reviewed":true},
"+detail": {"disposition":"primary_smart","semantic_delta":"Fans out selected artifacts, fully paginates transcript data, validates artifact-specific shapes and returns non-zero on partial reads.","risk":"read","public":true,"reviewed":true},
"+replace-batch": {"disposition":"primary_smart","semantic_delta":"Validates multi-rule input, supports JSON/file/stdin and reports partial writes with stop/continue policy and non-zero failure.","risk":"write","public":true,"reviewed":true}
}
}
+53 -25
View File
@@ -14,8 +14,10 @@
package smart
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// ActionItems: fetch the extracted to-do items (待办事项) from MY most recent
@@ -24,8 +26,7 @@ import (
// Steps:
// 1. list my minutes via list_by_keyword_and_time_range
// (belongingConditionId = "created", maxResults = 20);
// 2. pick the newest entry (largest create time, falling back to the first
// item since lists come back newest-first) and read its taskUuid;
// 2. pick the newest entry by an explicit comparable timestamp and read its taskUuid;
// 3. print that minute's extracted to-do items via list_minutes_todos.
//
// If the list is empty it reports "暂无妙记" instead of failing obscurely.
@@ -35,41 +36,68 @@ var ActionItems = shortcut.Shortcut{
Service: "minutes",
Command: "+action-items",
Product: "minutes",
Description: "取我最新一条妙记里的待办事项",
Intent: "当你刚开完会、只想立刻知道自己最近这条听记里系统帮你提取出了哪些待办事项," +
"却不想先翻听记列表、复制 taskUuid、再手动去查待办时使用;" +
"内部会先列出你创建的听记,自动挑出最新的一条,再拉取这条听记里提取的待办事项清单(含待办内容、参与人、待办时间等)。" +
"这是只读操作,不会新增或修改任何待办;若你名下没有任何听记则提示「暂无妙记」。",
Description: "读取指定或我最新一条听记中已抽取的行动项",
Intent: "当你要读取已知 taskUuid(--id)的听记行动项,或不传 --id 自动选择自己最新听记时使用;" +
"只接受服务端显式 actions/dingtalkTodoList 数组,合法空数组表示没有抽取到行动项,缺字段或错误响应不会被当成空成功。" +
"这是只读的 Minutes 产物读取,不会创建或修改钉钉 Todo。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: minutesSmartContract(
"+action-items",
"读取最新听记中已抽取的行动项",
"要读取指定听记(--id)或默认最新听记中由听记服务抽取的 actions/dingtalkTodoList 时使用;该命令只读,不会创建钉钉待办。",
[]string{"要创建或修改真正的钉钉待办时使用 Todo 产品命令"},
[]string{"dws minutes +action-items --id <taskUuid>", "dws minutes +action-items"},
nil,
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid;不传时选择我最新的一条"},
},
Tips: []string{
`dws minutes +action-items --id <taskUuid>`,
`dws minutes +action-items`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my minutes (newest first). belongingConditionId /
// maxResults mirror helpers.callListByKeywordRange.
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", map[string]any{
"belongingConditionId": "created",
"maxResults": float64(20),
taskUUID := rt.Str("id")
if taskUUID == "" {
// Step 1 — list my minutes (newest first). belongingConditionId /
// maxResults mirror helpers.callListByKeywordRange.
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", map[string]any{
"belongingConditionId": "created",
"maxResults": float64(20),
})
if err != nil {
return err
}
// Step 2 — locate the newest minute's taskUuid. Reuse the defensive
// list/UUID/createTime parsing from latest_minutes.go.
taskUUID, err = latestMinutesTaskUUID(data)
if err != nil {
return err
}
if taskUUID == "" {
return apperrors.NewValidation("暂无妙记")
}
}
// Step 3 — validate and print its extracted to-do items. Empty arrays are
// valid business data; a missing result/actions collection is not.
todosData, err := rt.CallMCPData("minutes", "list_minutes_todos", map[string]any{
"taskUuid": taskUUID,
})
if err != nil {
return err
}
// Step 2 — locate the newest minute's taskUuid. Reuse the defensive
// list/UUID/createTime parsing from latest_minutes.go.
taskUUID := latestMinutesTaskUUID(data)
if taskUUID == "" {
return apperrors.NewValidation("暂无妙记")
if err := minutesdata.ValidateArtifact("todos", taskUUID, todosData); err != nil {
return err
}
// Step 3 — print its extracted to-do items (taskUuid param mirrors
// helpers' list_minutes_todos call).
return rt.CallMCP("list_minutes_todos", map[string]any{
"taskUuid": taskUUID,
})
return rt.Output(todosData["result"])
},
}
func init() {
shortcut.Register(ActionItems)
shortcut.Register(finalizeMinutesSmartShortcut(ActionItems))
}
+44 -92
View File
@@ -14,8 +14,10 @@
package smart
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// LatestMinutes: fetch the details of MY most recent minutes (听记) in one step.
@@ -23,29 +25,40 @@ import (
// Steps:
// 1. list my minutes via list_by_keyword_and_time_range (belongingConditionId
// = "created"), optionally filtered by --keyword;
// 2. pick the newest entry (largest create time, falling back to the first
// item) and read its taskUuid;
// 2. pick the newest entry by an explicit comparable timestamp and read its taskUuid;
// 3. print that minute's basic info via get_minutes_basic_info.
//
// If the list is empty it reports "暂无妙记" instead of failing obscurely.
//
// dws minutes +latest-minutes
// dws minutes +latest-minutes --keyword 周会
// dws minutes +latest
// dws minutes +latest --keyword 周会
var LatestMinutes = shortcut.Shortcut{
Service: "minutes",
Command: "+latest-minutes",
Command: "+latest",
Aliases: []string{"+latest-minutes"},
Product: "minutes",
Description: "取我最新的一条妙记(听记)详情",
Intent: "当你只想快速看回自己最近的一条会议听记,却不想先翻列表、复制 taskUuid 再查详情时使用;" +
"内部先列出你创建的听记(可用 --keyword 缩小范围),自动挑出最新的一条,再拉取它的基础信息(标题、创建人、时间、访问链接等)。" +
"这是只读操作,不会修改任何听记;若你名下没有任何听记则提示「暂无妙记」。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: minutesSmartContract(
"+latest",
"取我最新的一条妙记(听记)详情",
"需要按真实创建/开始时间选出当前用户最新一条听记并直接读取基础详情时使用;可用 --keyword 缩小候选集。",
[]string{"已知 taskUuid 时直接使用基础信息原子命令;需要搜索多条结果时使用 +search"},
[]string{"dws minutes +latest", "dws minutes +latest --keyword 周会"},
[]string{"+latest-minutes"},
),
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false},
},
Tips: []string{
`dws minutes +latest-minutes`,
`dws minutes +latest-minutes --keyword 周会`,
`dws minutes +latest`,
`dws minutes +latest --keyword 周会`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my minutes (newest first). belongingConditionId /
@@ -63,103 +76,42 @@ var LatestMinutes = shortcut.Shortcut{
}
// Step 2 — locate the newest minute's taskUuid.
taskUUID := latestMinutesTaskUUID(data)
taskUUID, err := latestMinutesTaskUUID(data)
if err != nil {
return err
}
if taskUUID == "" {
return apperrors.NewValidation("暂无妙记")
}
// Step 3 — print its basic info (taskUuid param mirrors helper).
return rt.CallMCP("get_minutes_basic_info", map[string]any{
// Step 3 — validate and print its basic info. A successful-looking empty
// result must not become a Shortcut success.
basicData, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{
"taskUuid": taskUUID,
})
if err != nil {
return err
}
basic, err := minutesdata.Basic(taskUUID, basicData)
if err != nil {
return err
}
return rt.Output(basic)
},
}
// latestMinutesItems walks a list_by_keyword_and_time_range response and returns
// its minutes entries. The gateway wraps the list under one of several common
// container keys, so we probe them defensively before scanning for a bare list.
func latestMinutesItems(data map[string]any) []map[string]any {
for _, key := range []string{"result", "list", "minutesList", "items", "data", "records"} {
if arr, ok := data[key].([]any); ok {
return latestMinutesToMaps(arr)
}
// Some responses nest the list one level deeper, e.g. {"data": {"list": [...]}}.
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range []string{"list", "minutesList", "items", "records", "result"} {
if arr, ok := inner[k2].([]any); ok {
return latestMinutesToMaps(arr)
}
}
}
}
return nil
}
func latestMinutesToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// latestMinutesTaskUUID picks the newest minute's taskUuid: the item with the
// largest numeric create time, falling back to the first item that carries a
// taskUuid (lists come back newest-first).
func latestMinutesTaskUUID(data map[string]any) string {
items := latestMinutesItems(data)
if len(items) == 0 {
return ""
// largest explicit create/start time. Unknown list shapes and rows without a
// comparable timestamp fail closed instead of becoming an empty or arbitrary
// "latest" success.
func latestMinutesTaskUUID(data map[string]any) (string, error) {
page, err := minutesdata.ParseListPage(data)
if err != nil {
return "", err
}
best := ""
var bestTime float64
haveTime := false
firstUUID := ""
for _, m := range items {
uuid := latestMinutesUUID(m)
if uuid == "" {
continue
}
if firstUUID == "" {
firstUUID = uuid
}
if t, ok := latestMinutesCreateTime(m); ok {
if !haveTime || t > bestTime {
haveTime = true
bestTime = t
best = uuid
}
}
}
if haveTime && best != "" {
return best
}
return firstUUID
}
func latestMinutesUUID(m map[string]any) string {
for _, key := range []string{"taskUuid", "taskUUID", "uuid", "id"} {
if s, ok := m[key].(string); ok && s != "" {
return s
}
}
return ""
}
func latestMinutesCreateTime(m map[string]any) (float64, bool) {
for _, key := range []string{"createTime", "gmtCreate", "startTime", "createTimeStart"} {
if v, ok := m[key].(float64); ok {
return v, true
}
}
return 0, false
return minutesdata.LatestTaskUUID(page)
}
func init() {
shortcut.Register(LatestMinutes)
shortcut.Register(finalizeMinutesSmartShortcut(LatestMinutes))
}
@@ -0,0 +1,408 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
type smartMinutesFailWriter struct{}
func (smartMinutesFailWriter) Write([]byte) (int, error) {
return 0, errors.New("fixture output failure")
}
func runMinutesCLIWithWriter(t *testing.T, caller *smartCoverageCaller, writer io.Writer, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := newPlatformCoverageRoot()
root.SetOut(writer)
root.SetErr(&bytes.Buffer{})
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageMinutesSmartContractFinalizer(t *testing.T) {
value := shortcut.Shortcut{
Description: "desc", Intent: "intent",
Contract: corecmd.ContractDecl{Parameters: []contract.ParamDecl{
{Name: "a", Description: "parameter"},
{Name: "a", Description: "evidence present"},
{Name: "other", Description: "other"},
}},
Flags: []shortcut.Flag{{Name: "a", Desc: "flag"}},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"a"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"a"}, Description: "evidence"},
},
}
got := finalizeMinutesSmartShortcut(value)
if got.Contract.Selection.AgentSummary != "desc" || got.Contract.Selection.UseWhen[0] != "intent" || !strings.Contains(got.Flags[0].Desc, "evidence") || !strings.Contains(got.Contract.Parameters[0].Description, "evidence") || got.Contract.Parameters[1].Description != "evidence present" || got.Contract.Parameters[2].Description != "other" {
t.Fatalf("finalized=%#v", got)
}
}
func TestCrossPlatformCoverageMinutesLatestAndActionItemsBranchesE2E(t *testing.T) {
listKey := "minutes/list_by_keyword_and_time_range"
basicKey := "minutes/get_minutes_basic_info"
todoKey := "minutes/list_minutes_todos"
latestCases := []struct {
name string
responses map[string][]string
failAt map[string]int
wantError bool
}{
{name: "list call", failAt: map[string]int{listKey: 1}, wantError: true},
{name: "list parse", responses: map[string][]string{listKey: {`{"success":true,"result":{}}`}}, wantError: true},
{name: "empty", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}, wantError: true},
{name: "basic call", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}}, failAt: map[string]int{basicKey: 1}, wantError: true},
{name: "basic parse", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, basicKey: {`{"success":true,"result":{}}`}}, wantError: true},
{name: "success", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, basicKey: {`{"success":true,"result":{"taskUuid":"u1","title":"ok"}}`}}},
}
for _, test := range latestCases {
t.Run("latest "+test.name, func(t *testing.T) {
payload, _, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, "minutes", "+latest", "--keyword", "weekly")
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
latestOutput := &smartCoverageCaller{responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, basicKey: {`{"success":true,"result":{"taskUuid":"u1"}}`}}}
if err := runMinutesCLIWithWriter(t, latestOutput, smartMinutesFailWriter{}, "minutes", "+latest"); err == nil {
t.Fatal("latest output failure accepted")
}
if _, err := latestMinutesTaskUUID(map[string]any{}); err == nil {
t.Fatal("latest malformed data accepted")
}
actionCases := []struct {
name string
responses map[string][]string
failAt map[string]int
args []string
wantError bool
}{
{name: "list call", failAt: map[string]int{listKey: 1}, wantError: true},
{name: "list parse", responses: map[string][]string{listKey: {`{"success":true,"result":{}}`}}, wantError: true},
{name: "empty", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}, wantError: true},
{name: "todo call", failAt: map[string]int{todoKey: 1}, args: []string{"--id", "u1"}, wantError: true},
{name: "todo parse", responses: map[string][]string{todoKey: {`{"success":true,"result":{}}`}}, args: []string{"--id", "u1"}, wantError: true},
{name: "success id", responses: map[string][]string{todoKey: {`{"success":true,"result":{"actions":[]}}`}}, args: []string{"--id", "u1"}},
{name: "success latest", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, todoKey: {`{"success":true,"result":{"actions":[]}}`}}},
}
for _, test := range actionCases {
t.Run("actions "+test.name, func(t *testing.T) {
args := []string{"minutes", "+action-items"}
args = append(args, test.args...)
payload, _, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, args...)
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
actionOutput := &smartCoverageCaller{responses: map[string][]string{todoKey: {`{"success":true,"result":{"actions":[]}}`}}}
if err := runMinutesCLIWithWriter(t, actionOutput, smartMinutesFailWriter{}, "minutes", "+action-items", "--id", "u1"); err == nil {
t.Fatal("actions output failure accepted")
}
}
func TestCrossPlatformCoverageMinutesSearchAndTranscriptBranchesE2E(t *testing.T) {
listKey := "minutes/list_by_keyword_and_time_range"
transcriptKey := "minutes/get_minutes_transcription"
searchCases := []struct {
name string
responses map[string][]string
failAt map[string]int
}{
{name: "call", failAt: map[string]int{listKey: 1}},
{name: "project", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"title":"missing id"}]}}`}}},
}
for _, test := range searchCases {
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, "minutes", "+minutes-search", "--query", "x"); err == nil {
t.Fatalf("search %s failure accepted", test.name)
}
}
searchOutput := &smartCoverageCaller{responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}}
if err := runMinutesCLIWithWriter(t, searchOutput, smartMinutesFailWriter{}, "minutes", "+minutes-search", "--query", "x"); err == nil {
t.Fatal("search output failure accepted")
}
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+transcript", "--id", "u1", "--page-limit", "0"); err == nil {
t.Fatal("transcript page limit accepted")
}
transcriptCases := []struct {
name string
responses map[string][]string
failAt map[string]int
wantError bool
}{
{name: "list call", failAt: map[string]int{listKey: 1}, wantError: true},
{name: "list parse", responses: map[string][]string{listKey: {`{"success":true,"result":{}}`}}, wantError: true},
{name: "empty", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[]}}`}}, wantError: true},
{name: "success", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}},
{name: "partial", responses: map[string][]string{listKey: {`{"success":true,"result":{"itemList":[{"uuid":"u1","startTime":1}]}}`}, transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`}}, failAt: map[string]int{transcriptKey: 2}, wantError: true},
}
for _, test := range transcriptCases {
t.Run("transcript "+test.name, func(t *testing.T) {
payload, output, err := runMinutesCLI(t, &smartCoverageCaller{responses: test.responses, failAt: test.failAt}, "minutes", "+transcript", "--keyword", "weekly", "--direction", "1")
if (err != nil) != test.wantError {
t.Fatalf("payload=%#v output=%q err=%v", payload, output, err)
}
})
}
transcriptOutput := &smartCoverageCaller{responses: map[string][]string{transcriptKey: {`{"success":true,"result":{"paragraphList":[],"hasNext":false}}`}}}
if err := runMinutesCLIWithWriter(t, transcriptOutput, smartMinutesFailWriter{}, "minutes", "+transcript", "--id", "u1"); err == nil {
t.Fatal("transcript output failure accepted")
}
}
func TestCrossPlatformCoverageMinutesDetailRemainingBranchesE2E(t *testing.T) {
invalid := [][]string{
{"minutes", "+detail", "--id", "u1", "--page-limit", "0"},
{"minutes", "+detail", "--id", "u1", "--ids", "u2"},
{"minutes", "+detail"},
{"minutes", "+detail", "--id", "u1", "--transcript-output", "file", "--output-dir", "../escape"},
}
for _, args := range invalid {
if payload, output, err := runMinutesCLI(t, &smartCoverageCaller{}, args...); err == nil || payload != nil || output != "" {
t.Fatalf("detail invalid accepted %v payload=%#v output=%q err=%v", args, payload, output, err)
}
}
all := &smartCoverageCaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`},
"minutes/get_minutes_ai_summary": {`{"success":true,"result":{"fullSummary":"summary"}}`},
"minutes/get_minutes_keywords": {`{"success":true,"result":{"keywords":[]}}`},
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`},
"minutes/list_minutes_todos": {`{"success":true,"result":{"actions":[]}}`},
}}
payload, _, err := runMinutesCLI(t, all, "minutes", "+detail", "--id", "u1")
if err != nil || payload["complete"] != true {
t.Fatalf("detail all payload=%#v err=%v", payload, err)
}
if err := runMinutesCLIWithWriter(t, all, smartMinutesFailWriter{}, "minutes", "+detail", "--id", "u1", "--artifacts", "basic"); err == nil {
t.Fatal("detail single output failure accepted")
}
batchOutput := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`, `{"success":true,"result":{"taskUuid":"u2"}}`}}}
if err := runMinutesCLIWithWriter(t, batchOutput, smartMinutesFailWriter{}, "minutes", "+detail", "--ids", "u1,u2", "--artifacts", "basic"); err == nil {
t.Fatal("detail batch output failure accepted")
}
for _, artifact := range []string{"basic", "summary", "keywords", "todos"} {
tool := minutesArtifactTools[artifact]
caller := &smartCoverageCaller{failAt: map[string]int{"minutes/" + tool: 1}}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", artifact)
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("detail call %s payload=%#v err=%v", artifact, payload, err)
}
caller = &smartCoverageCaller{responses: map[string][]string{"minutes/" + tool: {`{"success":true,"result":{}}`}}}
payload, output, err = runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", artifact)
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("detail parse %s payload=%#v err=%v", artifact, payload, err)
}
}
transcript := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}}
testseam.Swap(t, &smartMinutesMarshalIndent, func(any, string, string) ([]byte, error) { return nil, errors.New("marshal") })
payload, output, err := runMinutesCLI(t, transcript, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript", "--transcript-output", "file", "--output-dir", "out")
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("detail marshal payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesDetailPublishFailure(t *testing.T) {
testseam.Swap(t, &smartMinutesPublishBytes, func([]byte, localio.PublishBytesOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("publish")
})
caller := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript", "--transcript-output", "both", "--output-dir", "out")
if err == nil || output == "" || payload["complete"] != false {
t.Fatalf("detail publish payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageMinutesDetailFileSuccess(t *testing.T) {
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
work := t.TempDir()
if err := os.Chdir(work); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
caller := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}}}
payload, _, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript", "--transcript-output", "file", "--output-dir", "out")
if err != nil || payload["complete"] != true {
t.Fatalf("detail file payload=%#v err=%v", payload, err)
}
if _, statErr := os.Stat(filepath.Join(work, "out", "u1.json")); statErr != nil {
t.Fatal(statErr)
}
helpers.InitDepsForTest(t, caller)
cmd := &cobra.Command{Use: "detail"}
cmd.Flags().String("cursor", "", "")
cmd.Flags().Bool("single-page", false, "")
cmd.Flags().Int("page-limit", 100, "")
cmd.Flags().String("transcript-output", "inline", "")
rt := shortcut.RuntimeContextForTest(cmd, MinutesDetail)
if bundle, failures := readMinutesDetail(rt, "u1", []string{"unknown"}, "0"); len(failures) != 0 || bundle["complete"] != true {
t.Fatalf("unknown detail bundle=%#v failures=%#v", bundle, failures)
}
}
func TestCrossPlatformCoverageMinutesReplaceParsing(t *testing.T) {
for _, raw := range [][]string{{"missing"}, {" =>x"}, {"a=>x", "a=>y"}} {
if pairs, err := parseReplacePairs(raw); err == nil || pairs != nil {
t.Fatalf("invalid pairs accepted: %#v pairs=%#v err=%v", raw, pairs, err)
}
}
if pairs, err := parseReplacePairs([]string{" a => b "}); err != nil || len(pairs) != 1 || pairs[0].orig != "a" || pairs[0].repl != "b" {
t.Fatalf("pairs=%#v err=%v", pairs, err)
}
for _, raw := range []string{"not-json", `[]`, `[{"replacement":"x"}]`} {
if pairs, err := parseReplaceJSON(raw); err == nil || pairs != nil {
t.Fatalf("invalid JSON accepted: %q pairs=%#v err=%v", raw, pairs, err)
}
}
if pairs, err := parseReplaceJSON(`{"replacements":[{"originalText":" a ","replacedText":" b "}]}`); err != nil || len(pairs) != 1 || pairs[0] != (replacePair{orig: "a", repl: "b"}) {
t.Fatalf("envelope pairs=%#v err=%v", pairs, err)
}
if pairs, err := parseReplaceJSON(`[{"source":"a","replacement":"b"}]`); err != nil || len(pairs) != 1 {
t.Fatalf("direct pairs=%#v err=%v", pairs, err)
}
for _, pairs := range [][]replacePair{nil, {{orig: " "}}, {{orig: "a"}, {orig: "a"}}} {
if got, err := validateReplacePairs(pairs); err == nil || got != nil {
t.Fatalf("invalid validated: %#v got=%#v err=%v", pairs, got, err)
}
}
valid := []replacePair{{orig: "a", repl: "b"}}
if got, err := validateReplacePairs(valid); err != nil || len(got) != 1 {
t.Fatalf("valid=%#v err=%v", got, err)
}
for _, test := range []struct {
before, after string
pair replacePair
beforeSource int
beforeTarget int
want bool
}{
{before: "same", after: "same", pair: replacePair{orig: "a", repl: "b"}},
{before: "a", after: "", pair: replacePair{orig: "a"}, beforeSource: 1, want: true},
{before: "a", after: "a", pair: replacePair{orig: "a"}, beforeSource: 1},
{before: "a", after: "aa", pair: replacePair{orig: "a", repl: "aa"}, beforeSource: 1, beforeTarget: 0, want: true},
{before: "a", after: "b", pair: replacePair{orig: "a", repl: "b"}, beforeSource: 1, beforeTarget: 0, want: true},
{before: "a", after: "c", pair: replacePair{orig: "a", repl: "b"}, beforeSource: 1, beforeTarget: 0},
} {
if got := replaceReadbackVerified(test.before, test.after, test.pair, test.beforeSource, test.beforeTarget); got != test.want {
t.Fatalf("verified(%#v)=%v", test, got)
}
}
}
func TestCrossPlatformCoverageMinutesReplaceLoadAndTranscript(t *testing.T) {
cmd := &cobra.Command{Use: "replace"}
cmd.Flags().StringSlice("pair", []string{"bad"}, "")
cmd.Flags().String("json", "", "")
if pairs, err := loadReplacePairs(shortcut.RuntimeContextForTest(cmd, ReplaceBatch)); err == nil || pairs != nil {
t.Fatalf("direct malformed pair accepted pairs=%#v err=%v", pairs, err)
}
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>b", "--page-limit", "0"); err == nil {
t.Fatal("replace page limit accepted")
}
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--json", "@missing", "--dry-run"); err == nil {
t.Fatal("missing JSON file accepted")
}
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--json", "bad", "--dry-run"); err == nil {
t.Fatal("malformed JSON accepted")
}
if _, _, err := runMinutesCLI(t, &smartCoverageCaller{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>b", "--json", `[{"source":"a","replacement":"c"}]`, "--dry-run"); err == nil {
t.Fatal("cross-input duplicate accepted")
}
for _, test := range []struct {
name string
responses []string
failAt int
}{
{name: "call", failAt: 1},
{name: "empty", responses: []string{`{"success":true,"result":{"paragraphList":[],"hasNext":false}}`}},
{name: "no text", responses: []string{`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":false}}`}},
} {
t.Run(test.name, func(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": test.responses}, failAt: map[string]int{"minutes/get_minutes_transcription": test.failAt}}
helpers.InitDepsForTest(t, caller)
cmd := &cobra.Command{Use: "replace"}
rt := shortcut.RuntimeContextForTest(cmd, ReplaceBatch)
if text, err := minutesReplaceTranscriptText(rt, "u1", 1); err == nil || text != "" {
t.Fatalf("text=%q err=%v", text, err)
}
})
}
nested := &smartCoverageCaller{responses: map[string][]string{"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraph":"one","sentences":[{"text":"two"}]}],"hasNext":false}}`}}}
helpers.InitDepsForTest(t, nested)
if text, err := minutesReplaceTranscriptText(shortcut.RuntimeContextForTest(&cobra.Command{Use: "replace"}, ReplaceBatch), "u1", 1); err != nil || !strings.Contains(text, "one") || !strings.Contains(text, "two") {
t.Fatalf("nested text=%q err=%v", text, err)
}
}
func TestCrossPlatformCoverageMinutesReplaceExecutionBranchesE2E(t *testing.T) {
transcriptKey := "minutes/get_minutes_transcription"
writeKey := "minutes/replace_minutes_text"
preRead := &smartCoverageCaller{failAt: map[string]int{transcriptKey: 1}}
if _, _, err := runMinutesCLI(t, preRead, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes"); err == nil {
t.Fatal("replace pre-read failure accepted")
}
missing := &smartCoverageCaller{responses: map[string][]string{transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraph":"other"}],"hasNext":false}}`}}}
if _, _, err := runMinutesCLI(t, missing, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes"); err == nil || missing.counts[writeKey] != 0 {
t.Fatal("missing source write accepted")
}
readback := &smartCoverageCaller{
responses: map[string][]string{
transcriptKey: {`{"success":true,"result":{"paragraphList":[{"paragraph":"a"}],"hasNext":false}}`},
writeKey: {`{"success":true,"result":{}}`},
},
failAt: map[string]int{transcriptKey: 2},
}
payload, output, err := runMinutesCLI(t, readback, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes")
if err == nil || output == "" || payload["failed"] != float64(1) {
t.Fatalf("readback payload=%#v err=%v", payload, err)
}
success := func() *smartCoverageCaller {
return &smartCoverageCaller{responses: map[string][]string{
transcriptKey: {
`{"success":true,"result":{"paragraphList":[{"paragraph":"a"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraph":"A"}],"hasNext":false}}`,
},
writeKey: {`{"success":true,"result":{}}`},
}}
}
payload, output, err = runMinutesCLI(t, success(), "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes")
if err != nil || output == "" || payload["ok"] != true || payload["applied"] != float64(1) {
t.Fatalf("success payload=%#v err=%v", payload, err)
}
if err := runMinutesCLIWithWriter(t, success(), smartMinutesFailWriter{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes"); err == nil {
t.Fatal("replace output failure accepted")
}
if err := runMinutesCLIWithWriter(t, &smartCoverageCaller{}, smartMinutesFailWriter{}, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--dry-run"); err == nil {
t.Fatal("replace dry-run output failure accepted")
}
}
@@ -0,0 +1,82 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func minutesSmartContract(command, description, useWhen string, avoidWhen, examples, aliases []string) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
identityAliases := make([]string, 0, len(aliases))
for _, alias := range aliases {
identityAliases = append(identityAliases, "minutes "+alias)
}
return corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "minutes",
Name: name,
CanonicalPath: "minutes." + name,
CLIPath: "minutes " + command,
PrimaryCLIPath: "minutes " + command,
Aliases: identityAliases,
},
Description: description,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: "The executable Shortcut owns strict Minutes response validation, orchestration, completeness and final output; no single RPC represents the final command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{useWhen},
AvoidWhen: avoidWhen,
Examples: examples,
},
}
}
// finalizeMinutesSmartShortcut keeps Minutes shortcuts that live in the smart
// package under the same declare=delivery contract as the Minutes package.
func finalizeMinutesSmartShortcut(value shortcut.Shortcut) shortcut.Shortcut {
value.Contract.Selection.AgentSummary = value.Description
value.Contract.Selection.UseWhen = []string{value.Intent}
for _, constraint := range value.Constraints {
if constraint.Kind != shortcut.ConstraintCustom {
continue
}
evidence := strings.TrimSpace(constraint.Description)
if evidence == "" {
continue
}
for flagIndex := range value.Flags {
flag := &value.Flags[flagIndex]
if !smartMinutesContains(constraint.Flags, flag.Name) || strings.Contains(flag.Desc, evidence) {
continue
}
flag.Desc = strings.TrimRight(flag.Desc, ";。 ") + ";约束:" + evidence
}
for parameterIndex := range value.Contract.Parameters {
parameter := &value.Contract.Parameters[parameterIndex]
if !smartMinutesContains(constraint.Flags, parameter.Name) || strings.Contains(parameter.Description, evidence) {
continue
}
parameter.Description = strings.TrimRight(parameter.Description, ";。 ") + ";约束:" + evidence
}
}
return value
}
func smartMinutesContains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
+168 -29
View File
@@ -14,12 +14,22 @@
package smart
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
var (
smartMinutesMarshalIndent = json.MarshalIndent
smartMinutesPublishBytes = localio.PublishBytes
)
// MinutesDetail: fetch several artifacts of ONE minute (听记) in a single command
@@ -44,10 +54,10 @@ var MinutesDetail = shortcut.Shortcut{
Service: "minutes",
Command: "+detail",
Product: "minutes",
Description: "一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办)",
Intent: "当你已经有某条听记的 taskUuid,想在一次操作里同时拿到它的基础信息、AI 摘要、关键词、逐字稿和待办,而不想分别敲 4~5 个子命令再自己拼时使用;" +
"内部按 --artifacts 选择要拉的产物(默认全部:basic/summary/keywords/transcript/todos),逐个调用对应的原子工具并聚合成一个结果," +
"某一项失败不会中断整体(会以错误字符串记录在该项下)。这是纯只读操作,不会修改听记;--direction 仅影响逐字稿排序(0=正序默认,1=倒序)。",
Description: "批量聚合听记基础信息、摘要、关键词、完整逐字稿和行动项,支持安全文件输出",
Intent: "当你已有一个或最多 50 个 taskUuid,要一次读取所选 basic/summary/keywords/transcript/todos 产物时使用;" +
"逐字稿默认追完所有分页并可用 file/both 安全落盘,批量结果带 complete 与逐项 failure ledger。" +
"任一所选产物失败都会保留已取得结果并返回非零,绝不会把 partial bundle 当成完整成功;这是纯只读操作。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -61,7 +71,7 @@ var MinutesDetail = shortcut.Shortcut{
CLIPath: "minutes +detail",
PrimaryCLIPath: "minutes +detail",
},
Description: "一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办)",
Description: "批量聚合听记基础信息、摘要、关键词、完整逐字稿和行动项,支持安全文件输出",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -73,22 +83,49 @@ var MinutesDetail = shortcut.Shortcut{
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws minutes +detail --id <taskUuid>",
"dws minutes +detail --id <taskUuid> --artifacts summary,todos",
"dws minutes +detail --ids <uuid1,uuid2> --artifacts summary,transcript --transcript-output file",
},
},
},
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true},
{Name: "id", Type: shortcut.FlagString, Desc: "单个听记 taskUuid"},
{Name: "ids", Type: shortcut.FlagStringSlice, Desc: "多个听记 taskUuid,最多 50 个"},
{Name: "artifacts", Type: shortcut.FlagStringSlice, Desc: "要拉取的产物子集(默认全部)", Required: false, Enum: []string{"basic", "summary", "keywords", "transcript", "todos"}},
{Name: "direction", Type: shortcut.FlagString, Desc: "逐字稿排序: 0=正序(默认), 1=倒序(可选)", Required: false, Enum: []string{"0", "1"}},
{Name: "cursor", Type: shortcut.FlagString, Desc: "逐字稿单页/续拉的起始 nextToken"},
{Name: "single-page", Type: shortcut.FlagBool, Desc: "逐字稿只读取一页并返回 nextToken"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿自动翻页安全上限"},
{Name: "transcript-output", Type: shortcut.FlagString, Default: "inline", Desc: "逐字稿输出方式", Enum: []string{"inline", "file", "both"}},
{Name: "output-dir", Type: shortcut.FlagString, Default: "minutes-transcripts", Desc: "file/both 模式下的安全相对输出目录"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"id", "ids"}, Description: "--id 与 --ids 只能选择一种,去重后 taskUuid 必须为 1..50 个"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"transcript-output", "output-dir"}, Description: "file/both 输出目录必须是安全相对路径"},
},
Tips: []string{
`dws minutes +detail --id <taskUuid>`,
`dws minutes +detail --id <taskUuid> --artifacts summary,todos`,
`dws minutes +detail --id <taskUuid> --direction 1`,
`dws minutes +detail --ids <uuid1,uuid2> --artifacts summary,transcript --transcript-output file`,
`dws minutes +detail --id <taskUuid> --direction 1 --transcript-output both`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
if strings.TrimSpace(rt.Str("id")) != "" && len(rt.StrSlice("ids")) > 0 {
return apperrors.NewValidation("--id 与 --ids 只能选择一种")
}
ids := minutesDetailIDs(rt)
if len(ids) == 0 || len(ids) > 50 {
return apperrors.NewValidation("taskUuid 数量必须为 1..50")
}
if rt.Str("transcript-output") != "inline" {
return localio.ValidateOutput(rt.Str("output-dir"))
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
taskUUID := rt.Str("id")
ids := minutesDetailIDs(rt)
direction := rt.Str("direction")
if direction == "" {
direction = "0"
@@ -100,30 +137,132 @@ var MinutesDetail = shortcut.Shortcut{
want = minutesArtifactOrder
}
bundle := map[string]any{"taskUuid": taskUUID}
for _, raw := range want {
name := strings.ToLower(strings.TrimSpace(raw))
tool, ok := minutesArtifactTools[name]
if !ok {
continue // guarded by Validate, but stay defensive
results := make([]map[string]any, 0, len(ids))
allFailures := make([]map[string]any, 0)
succeeded := 0
for _, taskUUID := range ids {
bundle, failures := readMinutesDetail(rt, taskUUID, want, direction)
results = append(results, bundle)
if len(failures) == 0 {
succeeded++
}
params := map[string]any{"taskUuid": taskUUID}
if name == "transcript" {
params["direction"] = direction
for _, failure := range failures {
failure["taskUuid"] = taskUUID
allFailures = append(allFailures, failure)
}
data, err := rt.CallMCPData("minutes", tool, params)
if err != nil {
// Partial-failure tolerance: record and continue.
bundle[name] = map[string]any{"error": err.Error()}
continue
}
bundle[name] = data
}
return rt.Output(bundle)
if !rt.Changed("ids") && len(results) == 1 {
if err := rt.Output(results[0]); err != nil {
return err
}
if len(allFailures) > 0 {
return minutesDetailReadError(ids, allFailures)
}
return nil
}
payload := map[string]any{
"operation": "minutes.detail", "complete": len(allFailures) == 0,
"requested": len(ids), "succeeded": succeeded, "failed": len(ids) - succeeded,
"results": results, "failures": allFailures,
}
if err := rt.Output(payload); err != nil {
return err
}
if len(allFailures) > 0 {
return minutesDetailReadError(ids, allFailures)
}
return nil
},
}
func readMinutesDetail(rt *shortcut.RuntimeContext, taskUUID string, want []string, direction string) (map[string]any, []map[string]any) {
bundle := map[string]any{"taskUuid": taskUUID}
failures := make([]map[string]any, 0)
for _, raw := range want {
name := strings.ToLower(strings.TrimSpace(raw))
tool, ok := minutesArtifactTools[name]
if !ok {
continue
}
if name == "transcript" {
result, err := collectMinutesTranscript(rt, taskUUID, direction, rt.Str("cursor"), rt.Bool("single-page"), rt.Int("page-limit"))
transcript := minutesdata.TranscriptPayload(taskUUID, direction, result)
if err == nil && rt.Str("transcript-output") != "inline" {
raw, marshalErr := smartMinutesMarshalIndent(transcript, "", " ")
if marshalErr == nil {
raw = append(raw, '\n')
published, publishErr := smartMinutesPublishBytes(raw, localio.PublishBytesOptions{Output: strings.TrimSuffix(rt.Str("output-dir"), "/") + "/", PreferredName: taskUUID + ".json"})
if publishErr == nil {
transcript["path"] = published.RelativePath
transcript["sizeBytes"] = published.SizeBytes
transcript["fileComplete"] = true
if rt.Str("transcript-output") == "file" {
delete(transcript, "paragraphList")
transcript["inline"] = false
}
} else {
err = publishErr
}
} else {
err = marshalErr
}
}
bundle[name] = transcript
if err != nil {
failures = append(failures, map[string]any{"artifact": name, "error": err.Error()})
}
continue
}
data, err := rt.CallMCPData("minutes", tool, map[string]any{"taskUuid": taskUUID})
if err != nil {
bundle[name] = map[string]any{"error": err.Error()}
failures = append(failures, map[string]any{"artifact": name, "error": err.Error()})
continue
}
if err := minutesdata.ValidateArtifact(name, taskUUID, data); err != nil {
bundle[name] = map[string]any{"error": err.Error()}
failures = append(failures, map[string]any{"artifact": name, "error": err.Error()})
continue
}
bundle[name] = data
}
bundle["complete"] = len(failures) == 0
bundle["failureCount"] = len(failures)
if len(failures) > 0 {
bundle["failures"] = failures
}
return bundle, failures
}
func minutesDetailIDs(rt *shortcut.RuntimeContext) []string {
values := []string{rt.Str("id")}
values = append(values, rt.StrSlice("ids")...)
seen := map[string]bool{}
out := []string{}
for _, value := range values {
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item != "" && !seen[item] {
seen[item] = true
out = append(out, item)
}
}
}
return out
}
func minutesDetailReadError(ids []string, failures []map[string]any) error {
return apperrors.NewAPI(
fmt.Sprintf("听记详情读取不完整:%d 个产物失败", len(failures)),
apperrors.WithOperation("minutes/+detail"),
apperrors.WithOrigin("shortcut"),
apperrors.WithFailureStage("artifact_read"),
apperrors.WithReason("minutes_detail_incomplete"),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{"taskUuids": ids, "failures": failures}),
)
}
// minutesArtifactTools maps the user-facing artifact name to the real MCP tool
// (ground truth: internal/helpers/minutes.go). Each tool takes a single
// "taskUuid"; transcript additionally takes "direction".
@@ -140,5 +279,5 @@ var minutesArtifactTools = map[string]string{
var minutesArtifactOrder = []string{"basic", "summary", "keywords", "transcript", "todos"}
func init() {
shortcut.Register(MinutesDetail)
shortcut.Register(finalizeMinutesSmartShortcut(MinutesDetail))
}
+230
View File
@@ -0,0 +1,230 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func runMinutesCLI(t *testing.T, caller *smartCoverageCaller, args ...string) (map[string]any, string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := newPlatformCoverageRoot()
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetArgs(args)
err := root.Execute()
if stdout.Len() == 0 {
return nil, "", err
}
var payload map[string]any
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
}
return payload, stdout.String(), err
}
func TestCrossPlatformCoverageMinutesDetailPaginatesRealTranscriptShapeE2E(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"},{"paragraphId":"p2"}],"hasNext":true,"nextToken":"n2"}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2"},{"paragraphId":"p3"}],"hasNext":false,"nextToken":""}}`,
},
}}
payload, _, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript")
if err != nil {
t.Fatalf("detail: %v", err)
}
transcript := payload["transcript"].(map[string]any)
if payload["complete"] != true || transcript["complete"] != true || transcript["pages"] != float64(2) || transcript["paragraphCount"] != float64(3) || transcript["duplicateCount"] != float64(1) {
t.Fatalf("detail payload = %#v", payload)
}
args := caller.arguments["minutes/get_minutes_transcription"]
if len(args) != 2 || args[1]["nextToken"] != "n2" {
t.Fatalf("transcript calls = %#v", args)
}
}
func TestCrossPlatformCoverageMinutesDetailUnknownOrPartialTranscriptIsNotSuccessE2E(t *testing.T) {
tests := []struct {
name string
responses []string
failAt int
wantCount float64
}{
{name: "missing collection", responses: []string{`{"success":true,"result":{}}`}, wantCount: 0},
{name: "later page failure", responses: []string{`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`}, failAt: 2, wantCount: 1},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &smartCoverageCaller{
responses: map[string][]string{"minutes/get_minutes_transcription": test.responses},
failAt: map[string]int{"minutes/get_minutes_transcription": test.failAt},
}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--id", "u1", "--artifacts", "transcript")
if err == nil || output == "" || payload["complete"] != false || payload["failureCount"] != float64(1) {
t.Fatalf("incomplete detail accepted = payload:%#v output:%q err:%v", payload, output, err)
}
transcript := payload["transcript"].(map[string]any)
if transcript["complete"] != false || transcript["paragraphCount"] != test.wantCount {
t.Fatalf("incomplete transcript = %#v", transcript)
}
})
}
}
func TestCrossPlatformCoverageMinutesDetailBatchWritesVerifiedTranscriptFilesE2E(t *testing.T) {
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
work := t.TempDir()
if err := os.Chdir(work); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
caller := &smartCoverageCaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"one"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p2","paragraph":"two"}],"hasNext":false}}`,
},
}}
payload, _, err := runMinutesCLI(t, caller, "minutes", "+detail", "--ids", "u1,u2", "--artifacts", "transcript", "--transcript-output", "file", "--output-dir", "transcripts")
if err != nil || payload["complete"] != true || payload["requested"] != float64(2) || payload["succeeded"] != float64(2) {
t.Fatalf("batch detail=%#v err=%v", payload, err)
}
for _, result := range payload["results"].([]any) {
transcript := result.(map[string]any)["transcript"].(map[string]any)
if transcript["inline"] != false || transcript["fileComplete"] != true || transcript["paragraphList"] != nil {
t.Fatalf("file transcript=%#v", transcript)
}
path := transcript["path"].(string)
info, statErr := os.Stat(filepath.Join(work, filepath.FromSlash(path)))
if statErr != nil || info.Size() <= 0 || float64(info.Size()) != transcript["sizeBytes"] {
t.Fatalf("transcript file path=%q info=%#v err=%v", path, info, statErr)
}
}
}
func TestCrossPlatformCoverageMinutesDetailBatchPartialIsNonZeroE2E(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"one"}],"hasNext":false}}`,
`{"success":true,"result":{}}`,
},
}}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+detail", "--ids", "u1,u2", "--artifacts", "transcript")
if err == nil || output == "" || payload["complete"] != false || payload["requested"] != float64(2) || payload["succeeded"] != float64(1) || payload["failed"] != float64(1) {
t.Fatalf("batch partial accepted: payload=%#v output=%q err=%v", payload, output, err)
}
}
func TestCrossPlatformCoverageMinutesSearchAndLatestRejectUnknownButAcceptItemListE2E(t *testing.T) {
unknown := &smartCoverageCaller{responses: map[string][]string{
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{}}`},
}}
if payload, output, err := runMinutesCLI(t, unknown, "minutes", "+minutes-search", "--query", "weekly"); err == nil || payload != nil || output != "" {
t.Fatalf("unknown search accepted = payload:%#v output:%q err:%v", payload, output, err)
}
valid := &smartCoverageCaller{responses: map[string][]string{
"minutes/list_by_keyword_and_time_range": {`{"success":true,"result":{"itemList":[{"uuid":"u1","title":"weekly","startTime":1}]}}`},
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1","title":"weekly"}}`},
}}
_, _, err := runMinutesCLI(t, valid, "minutes", "+latest-minutes")
if err != nil {
t.Fatalf("latest itemList: %v", err)
}
if calls := valid.arguments["minutes/get_minutes_basic_info"]; len(calls) != 1 || calls[0]["taskUuid"] != "u1" {
t.Fatalf("latest readback calls = %#v", calls)
}
}
func TestCrossPlatformCoverageMinutesTranscriptSinglePagePublishesIncompleteCursorE2E(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
"minutes/get_minutes_transcription": {`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1"}],"hasNext":true,"nextToken":"n2"}}`},
}}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+transcript", "--id", "u1", "--single-page")
if err != nil || output == "" || payload["complete"] != false || payload["nextToken"] != "n2" || payload["paragraphCount"] != float64(1) {
t.Fatalf("single page = payload:%#v output:%q err:%v", payload, output, err)
}
if strings.Contains(output, `"complete": true`) {
t.Fatalf("single page falsely complete: %s", output)
}
}
func TestCrossPlatformCoverageMinutesReplaceBatchPartialWriteIsNonZeroE2E(t *testing.T) {
for _, policy := range []struct {
name string
args []string
wantCalls int
wantApplied float64
wantUnattempted float64
}{
{name: "stop", wantCalls: 2, wantApplied: 1, wantUnattempted: 1},
{name: "continue", args: []string{"--failure-policy", "continue"}, wantCalls: 3, wantApplied: 2, wantUnattempted: 0},
} {
t.Run(policy.name, func(t *testing.T) {
caller := &smartCoverageCaller{
responses: map[string][]string{
"minutes/replace_minutes_text": {`{"success":true}`, `{"success":true}`, `{"success":true}`},
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"a b c"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"A b c"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"A b C"}],"hasNext":false}}`,
},
},
failAt: map[string]int{"minutes/replace_minutes_text": 2},
}
args := []string{"minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--pair", "b=>B", "--pair", "c=>C", "--yes"}
args = append(args, policy.args...)
payload, output, err := runMinutesCLI(t, caller, args...)
if err == nil || output == "" || payload["ok"] != false || payload["failed"] != float64(1) || payload["applied"] != policy.wantApplied || payload["unattempted"] != policy.wantUnattempted {
t.Fatalf("partial replace = payload:%#v output:%q err:%v", payload, output, err)
}
if got := caller.counts["minutes/replace_minutes_text"]; got != policy.wantCalls {
t.Fatalf("replace calls = %d, want %d", got, policy.wantCalls)
}
})
}
}
func TestCrossPlatformCoverageMinutesReplaceAcknowledgedButUnchangedReadbackIsNonZeroE2E(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
"minutes/replace_minutes_text": {`{"success":true}`},
"minutes/get_minutes_transcription": {
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"a"}],"hasNext":false}}`,
`{"success":true,"result":{"paragraphList":[{"paragraphId":"p1","paragraph":"a"}],"hasNext":false}}`,
},
}}
payload, output, err := runMinutesCLI(t, caller, "minutes", "+replace-batch", "--id", "u1", "--pair", "a=>A", "--yes")
if err == nil || output == "" || payload["ok"] != false || payload["failed"] != float64(1) || payload["applied"] != float64(0) {
t.Fatalf("unchanged readback accepted: payload=%#v output=%q err=%v", payload, output, err)
}
result := payload["results"].([]any)[0].(map[string]any)
if result["acknowledged"] != true || result["remoteEffectUnknown"] != true || result["verified"] == true {
t.Fatalf("unchanged result=%#v", result)
}
}
func TestCrossPlatformCoverageMinutesReplaceJSONDryRunDoesNotWriteE2E(t *testing.T) {
caller := &smartCoverageCaller{}
payload, output, err := runMinutesCLI(t, caller,
"minutes", "+replace-batch", "--id", "u1",
"--json", `[{"source":"a","replacement":"A"},{"originalText":"b","replacedText":"B"}]`,
"--dry-run")
if err != nil || output == "" || payload["dryRun"] != true || payload["executed"] != false || payload["total"] != float64(2) {
t.Fatalf("replace dry-run = payload:%#v output:%q err:%v", payload, output, err)
}
if caller.counts["minutes/replace_minutes_text"] != 0 {
t.Fatalf("dry-run performed writes: %#v", caller.counts)
}
}
+15 -40
View File
@@ -16,8 +16,8 @@ package smart
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// MinutesSearch: search MY minutes (听记) by keyword and print a projected list.
@@ -86,50 +86,25 @@ var MinutesSearch = shortcut.Shortcut{
return err
}
// Step 2 — project matched entries. latestMinutesItems (from
// latest_minutes.go) defensively unwraps the list container.
items := latestMinutesItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"title": minutesSearchTitle(m),
"createTime": minutesSearchCreateTime(m),
// latestMinutesUUID probes taskUuid/taskUUID/uuid/id in order.
"taskUuid": latestMinutesUUID(m),
})
// Step 2 — validate and project matched entries. An explicit [] is a
// successful empty search; an unknown/malformed shape is a contract error.
page, err := minutesdata.ParseListPage(data)
if err != nil {
return err
}
results, err := minutesdata.ProjectList(page)
if err != nil {
return err
}
// Step 3 — empty result guard.
if len(results) == 0 {
return apperrors.NewValidation("没搜到妙记")
}
return rt.Output(map[string]any{"minutes": results})
return rt.Output(map[string]any{"count": len(results), "minutes": results})
},
}
// minutesSearchTitle reads a minute's display title, tolerating the common
// title keys the gateway may use.
func minutesSearchTitle(m map[string]any) string {
for _, key := range []string{"title", "minutesTitle", "name", "subject"} {
if s, ok := m[key].(string); ok && s != "" {
return s
}
}
return ""
}
// minutesSearchCreateTime reads a minute's create time, returning the raw value
// (usually epoch millis) under whichever candidate key is present.
func minutesSearchCreateTime(m map[string]any) any {
for _, key := range []string{"createTime", "gmtCreate", "startTime", "createTimeStart"} {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
}
func init() {
shortcut.Register(MinutesSearch)
// Keep the historical Schema identity as a hidden compatibility
// leaf. +search is the only public discovery route, but removing this
// independently shipped ToolSpec would break existing Schema consumers.
shortcut.Register(finalizeMinutesSmartShortcut(MinutesSearch))
}
+245 -29
View File
@@ -14,6 +14,7 @@
package smart
import (
"encoding/json"
"fmt"
"strings"
@@ -21,7 +22,9 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// ReplaceBatch: apply MANY word replacements to one minute (听记) in a single
@@ -33,18 +36,19 @@ import (
// repeatedly and eyeballing each result. This shortcut takes multiple --pair
// "原文=>替换" entries, validates them (rejecting duplicate source words so two
// rules don't fight over the same term), applies each via replace_minutes_text
// and aggregates a per-pair {applied|error} report through rt.Output — one
// failing pair does not abort the rest.
// and aggregates a per-pair {applied|error} report through rt.Output. The
// default policy stops at the first error; explicit continue still returns
// non-zero when any write fails.
//
// dws minutes +replace-batch --id <taskUuid> --pair "张三=>张三丰" --pair "Q2=>第二季度"
var ReplaceBatch = shortcut.Shortcut{
Service: "minutes",
Command: "+replace-batch",
Product: "minutes",
Description: "对一条妙记(听记)批量执行多组文字替换(原文=>替换)",
Intent: "当你要在同一条听记里一次性纠正多个词(如把多个错识别的人名/术语统一替换),而底层工具一次只能替换一组时使用;" +
"内部按多个 --pair \"原文=>替换\" 逐组调用替换工具,先在本地校验去重(同一个「原文」不能出现两次,避免两条规则互相打架)," +
"再逐组应用并聚合每组的成功/失败结果,某一组失败不会中断其余组。这是写操作,会实际修改听记文字内容,请确认 taskUuid 与替换规则无误。",
Description: "预检并批量执行多组听记文字替换,逐项验证且失败必定非零",
Intent: "当你要在同一条听记里一次纠正多个错识别人名或术语时使用;支持重复 --pair,或通过 --json 接受字面量、@相对文件和 stdin。" +
"命令会先读取完整逐字稿并校验所有原文、空值和重复规则,再逐项写入并重新读取完整逐字稿验证效果。" +
"默认首错停止;只有显式 failure-policy=continue 才继续,但任一失败仍返回非零及 applied/failed/unattempted ledger。底层不是事务且没有自动回滚,并会同时影响逐字稿和摘要。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
@@ -58,7 +62,7 @@ var ReplaceBatch = shortcut.Shortcut{
CLIPath: "minutes +replace-batch",
PrimaryCLIPath: "minutes +replace-batch",
},
Description: "对一条妙记(听记)批量执行多组文字替换(原文=>替换)",
Description: "预检并批量执行多组听记文字替换,逐项验证且失败必定非零",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -73,34 +77,50 @@ var ReplaceBatch = shortcut.Shortcut{
"dws minutes +replace-batch --id <taskUuid> --pair \"Q2=>第二季度\" --pair \"PM=>产品经理\"",
},
},
DryRun: &contract.DryRunSpec{
PreviewKind: contract.DryRunPreviewPlan,
RemoteReads: false,
},
Parameters: []contract.ParamDecl{
{Name: "pair", Description: `替换规则,格式 "原文=>替换",可重复传多组(必填);每组原文不能为空且不能重复`},
{Name: "json", Description: "JSON 字面量、@工作目录相对文件或 - 表示 stdin"},
{Name: "failure-policy", Description: "stop=首错停止;continue=显式继续,但只要有失败仍返回非零"},
},
},
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid(必填)", Required: true},
{Name: "pair", Type: shortcut.FlagStringSlice, Desc: `替换规则,格式 "原文=>替换",可重复传多组(必填)`, Required: true},
{Name: "pair", Type: shortcut.FlagStringSlice, Desc: `替换规则,格式 "原文=>替换",可重复传多组`},
{Name: "json", Type: shortcut.FlagString, Desc: "替换规则 JSON 字面量、@相对文件或 - 表示 stdin"},
{Name: "failure-policy", Type: shortcut.FlagString, Default: "stop", Desc: "失败策略", Enum: []string{"stop", "continue"}},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "逐字稿写前/写后验证的翻页上限"},
},
Constraints: []shortcut.Constraint{
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"pair"},
Description: `每个 --pair 必须使用 "原文=>替换" 格式,原文不能为空且不能重复`,
Flags: []string{"pair", "json"},
Description: `--pair 与 --json 至少提供一种;规则必须使用 "原文=>替换" 或 JSON 数组;原文不能为空且不能重复`,
},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"},
},
Tips: []string{
`dws minutes +replace-batch --id <taskUuid> --pair "张三=>张三丰"`,
`dws minutes +replace-batch --id <taskUuid> --pair "Q2=>第二季度" --pair "PM=>产品经理"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
_, err := parseReplacePairs(rt.StrSlice("pair"))
return err
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
if len(rt.StrSlice("pair")) > 0 {
_, err := parseReplacePairs(rt.StrSlice("pair"))
return err
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
taskUUID := rt.Str("id")
pairs, err := parseReplacePairs(rt.StrSlice("pair"))
pairs, err := loadReplacePairs(rt)
if err != nil {
return err // already validated, but stay defensive
return err
}
if rt.DryRun() {
replacements := make([]map[string]any, 0, len(pairs))
@@ -111,40 +131,109 @@ var ReplaceBatch = shortcut.Shortcut{
})
}
return rt.Output(map[string]any{
"dryRun": true,
"taskUuid": taskUUID,
"total": len(pairs),
"replacements": replacements,
"operation": "minutes.replace_batch",
"dry_run": true,
"dryRun": true,
"preview_kind": contract.DryRunPreviewPlan,
"executed": false,
"failurePolicy": rt.Str("failure-policy"),
"taskUuid": taskUUID,
"total": len(pairs),
"replacements": replacements,
})
}
currentText, err := minutesReplaceTranscriptText(rt, taskUUID, rt.Int("page-limit"))
if err != nil {
return err
}
for _, pair := range pairs {
if strings.Count(currentText, pair.orig) == 0 {
return apperrors.NewValidation(fmt.Sprintf("完整逐字稿中不存在待替换原文 %q;未执行任何写入", pair.orig))
}
}
results := make([]map[string]any, 0, len(pairs))
applied := 0
for _, p := range pairs {
failed := 0
unattempted := 0
for index, p := range pairs {
// Params mirror helpers replace_minutes_text call site: taskUuid /
// originalText / replacedText.
_, callErr := rt.CallMCPWriteData("minutes", "replace_minutes_text", map[string]any{
writeData, callErr := rt.CallMCPWriteDataStrict("minutes", "replace_minutes_text", map[string]any{
"taskUuid": taskUUID,
"originalText": p.orig,
"replacedText": p.repl,
})
if callErr == nil {
callErr = minutesdata.RequireWriteAcknowledgement("replace text", writeData)
}
entry := map[string]any{"originalText": p.orig, "replacedText": p.repl}
acknowledged := callErr == nil
if acknowledged {
entry["acknowledged"] = true
beforeSource := strings.Count(currentText, p.orig)
beforeTarget := strings.Count(currentText, p.repl)
afterText, readErr := minutesReplaceTranscriptText(rt, taskUUID, rt.Int("page-limit"))
if readErr != nil {
callErr = fmt.Errorf("写入已确认但逐字稿读回失败: %w", readErr)
} else if !replaceReadbackVerified(currentText, afterText, p, beforeSource, beforeTarget) {
callErr = fmt.Errorf("写入已确认但逐字稿读回未证明替换生效")
} else {
currentText = afterText
entry["verified"] = true
}
}
if callErr != nil {
entry["error"] = callErr.Error()
entry["remoteEffectUnknown"] = acknowledged
failed++
results = append(results, entry)
if rt.Str("failure-policy") == "stop" {
for _, pending := range pairs[index+1:] {
results = append(results, map[string]any{
"originalText": pending.orig,
"replacedText": pending.repl,
"unattempted": true,
})
unattempted++
}
break
}
} else {
entry["applied"] = true
applied++
results = append(results, entry)
}
results = append(results, entry)
}
return rt.Output(map[string]any{
"taskUuid": taskUUID,
"total": len(pairs),
"applied": applied,
"failed": len(pairs) - applied,
"results": results,
})
payload := map[string]any{
"ok": failed == 0,
"partial": applied > 0 && (failed > 0 || unattempted > 0),
"taskUuid": taskUUID,
"failurePolicy": rt.Str("failure-policy"),
"total": len(pairs),
"applied": applied,
"failed": failed,
"unattempted": unattempted,
"results": results,
}
if err := rt.Output(payload); err != nil {
return err
}
if failed > 0 {
return apperrors.NewAPI(
fmt.Sprintf("批量替换未完成:成功 %d、失败 %d、未执行 %d", applied, failed, unattempted),
apperrors.WithOperation("minutes/replace_minutes_text"),
apperrors.WithOrigin("shortcut"),
apperrors.WithFailureStage("write"),
apperrors.WithReason("minutes_replace_partial_failure"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithDetails(payload),
)
}
return nil
},
}
@@ -183,6 +272,133 @@ func parseReplacePairs(raw []string) ([]replacePair, error) {
return pairs, nil
}
func loadReplacePairs(rt *shortcut.RuntimeContext) ([]replacePair, error) {
pairs, err := parseReplacePairs(rt.StrSlice("pair"))
if err != nil {
return nil, err
}
if rt.Changed("json") {
text, err := localio.ReadTextInput(rt.Str("json"), rt.Command().InOrStdin(), 8<<20)
if err != nil {
return nil, apperrors.NewValidation(err.Error())
}
jsonPairs, err := parseReplaceJSON(text)
if err != nil {
return nil, err
}
pairs = append(pairs, jsonPairs...)
}
return validateReplacePairs(pairs)
}
func parseReplaceJSON(raw string) ([]replacePair, error) {
type wirePair struct {
Source string `json:"source"`
Replacement string `json:"replacement"`
OriginalText string `json:"originalText"`
ReplacedText string `json:"replacedText"`
}
var direct []wirePair
if err := json.Unmarshal([]byte(raw), &direct); err != nil {
var envelope struct {
Replacements []wirePair `json:"replacements"`
}
if envelopeErr := json.Unmarshal([]byte(raw), &envelope); envelopeErr != nil || envelope.Replacements == nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--json 必须是替换规则数组或 {\"replacements\":[...]}: %v", err))
}
direct = envelope.Replacements
}
if len(direct) == 0 {
return nil, apperrors.NewValidation("--json 至少需要一条替换规则")
}
pairs := make([]replacePair, 0, len(direct))
for index, item := range direct {
source := item.Source
if source == "" {
source = item.OriginalText
}
replacement := item.Replacement
if replacement == "" {
replacement = item.ReplacedText
}
if strings.TrimSpace(source) == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--json 第 %d 条规则缺少 source/originalText", index))
}
pairs = append(pairs, replacePair{orig: strings.TrimSpace(source), repl: strings.TrimSpace(replacement)})
}
return pairs, nil
}
func validateReplacePairs(pairs []replacePair) ([]replacePair, error) {
if len(pairs) == 0 {
return nil, apperrors.NewValidation("至少需要一条替换规则")
}
seen := map[string]bool{}
for _, pair := range pairs {
if strings.TrimSpace(pair.orig) == "" {
return nil, apperrors.NewValidation("替换规则的原文不能为空")
}
if seen[pair.orig] {
return nil, apperrors.NewValidation(fmt.Sprintf("重复的「原文」%q:同一个原文只能有一条替换规则", pair.orig))
}
seen[pair.orig] = true
}
return pairs, nil
}
func minutesReplaceTranscriptText(rt *shortcut.RuntimeContext, taskUUID string, pageLimit int) (string, error) {
result, err := collectMinutesTranscript(rt, taskUUID, "0", "", false, pageLimit)
if err != nil {
return "", err
}
if len(result.Paragraphs) == 0 {
return "", fmt.Errorf("逐字稿为空,无法证明文字替换目标或写入结果")
}
parts := make([]string, 0, len(result.Paragraphs))
var collect func(any)
collect = func(value any) {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
switch key {
case "paragraph", "sentence", "word", "text", "content":
if text, ok := child.(string); ok && text != "" {
parts = append(parts, text)
}
default:
collect(child)
}
}
case []any:
for _, child := range typed {
collect(child)
}
}
}
for _, paragraph := range result.Paragraphs {
collect(paragraph)
}
if len(parts) == 0 {
return "", fmt.Errorf("逐字稿没有可验证的文本字段")
}
return strings.Join(parts, "\n"), nil
}
func replaceReadbackVerified(before, after string, pair replacePair, beforeSource, beforeTarget int) bool {
if before == after {
return false
}
afterSource := strings.Count(after, pair.orig)
if pair.repl == "" {
return afterSource < beforeSource
}
afterTarget := strings.Count(after, pair.repl)
if strings.Contains(pair.repl, pair.orig) {
return afterTarget > beforeTarget
}
return afterSource < beforeSource && afterTarget > beforeTarget
}
func init() {
shortcut.Register(ReplaceBatch)
shortcut.Register(finalizeMinutesSmartShortcut(ReplaceBatch))
}
+95 -33
View File
@@ -14,8 +14,12 @@
package smart
import (
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutesdata"
)
// Transcript: fetch the verbatim transcript (逐字稿 / 语音转写原文) of MY most
@@ -38,41 +42,66 @@ var Transcript = shortcut.Shortcut{
Service: "minutes",
Command: "+transcript",
Product: "minutes",
Description: "取我最新一条妙记(听记)的逐字稿(语音转写原文)",
Intent: "当你想直接读回自己最近一次会议听记的逐字稿(完整语音转写原文),又不想先翻列表、复制 taskUuid 再单独查转写时使用;" +
"内部先列出你创建的听记(可用 --keyword 缩小范围),自动挑出最新的一条,再拉取它的逐字记录(每条含发言人、文本、时间戳)。" +
"可用 --direction 控制排序:0=正序(时间递增,默认),1=倒序。这是只读操作,不会修改任何听记;" +
"若你名下没有任何听记则提示「暂无妙记」。",
Description: "读取指定或我最新一条听记的完整逐字稿,并交付分页完整性证据",
Intent: "当你要读取已知 taskUuid(--id)的完整逐字稿,或不传 --id 自动选择自己最新听记时使用;" +
"默认追完 nextToken、跨页去重并输出 complete/pages/nextToken,只有显式 --single-page 才停在一页。" +
"可用 --direction 控制排序,--keyword 仅在自动选择最新听记时缩小候选;任何分页漂移或中途失败都返回非零而不是把部分原文当成全集。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false},
{Name: "direction", Type: shortcut.FlagString, Desc: "排序方向: 0=正序(默认), 1=倒序(可选)", Required: false},
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: minutesSmartContract(
"+transcript",
"读取指定或最新听记的完整逐字稿",
"需要读取逐字稿并自动追完 nextToken、去重段落,同时看到 complete/pages/nextToken 完整性证据时使用;不传 --id 时严格选择最新听记。",
[]string{"只需单个原始分页响应时使用底层转写命令;需要汇总多个制品时使用 +detail"},
[]string{"dws minutes +transcript --id <taskUuid>", "dws minutes +transcript --keyword 周会"},
nil,
),
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "听记 taskUuid;不传时选择我最新的一条"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "按关键字过滤听记(可选)", Required: false},
{Name: "direction", Type: shortcut.FlagString, Desc: "排序方向: 0=正序(默认), 1=倒序(可选)", Required: false, Enum: []string{"0", "1"}},
{Name: "cursor", Type: shortcut.FlagString, Desc: "单页/续拉的起始 nextToken"},
{Name: "single-page", Type: shortcut.FlagBool, Desc: "只读取一页;输出 complete/nextToken"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "100", Desc: "自动翻页安全上限"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须大于 0"}},
Tips: []string{
`dws minutes +transcript`,
`dws minutes +transcript --id <taskUuid>`,
`dws minutes +transcript --keyword 周会`,
`dws minutes +transcript --direction 1`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("page-limit") <= 0 {
return apperrors.NewValidation("--page-limit 必须大于 0")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my minutes (newest first). belongingConditionId /
// maxResults / keyword mirror helpers.callListByKeywordRange.
listArgs := map[string]any{
"belongingConditionId": "created",
"maxResults": float64(20),
}
if kw := rt.Str("keyword"); kw != "" {
listArgs["keyword"] = kw
}
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", listArgs)
if err != nil {
return err
}
// Step 2 — locate the newest minute's taskUuid (reuses helper from
// latest_minutes.go).
taskUUID := latestMinutesTaskUUID(data)
taskUUID := rt.Str("id")
if taskUUID == "" {
return apperrors.NewValidation("暂无妙记")
// Step 1 — list my minutes (newest first). belongingConditionId /
// maxResults / keyword mirror helpers.callListByKeywordRange.
listArgs := map[string]any{
"belongingConditionId": "created",
"maxResults": float64(20),
}
if kw := rt.Str("keyword"); kw != "" {
listArgs["keyword"] = kw
}
data, err := rt.CallMCPData("minutes", "list_by_keyword_and_time_range", listArgs)
if err != nil {
return err
}
// Step 2 — locate the newest minute's taskUuid.
taskUUID, err = latestMinutesTaskUUID(data)
if err != nil {
return err
}
if taskUUID == "" {
return apperrors.NewValidation("暂无妙记")
}
}
// Step 3 — print its verbatim transcript. taskUuid + direction mirror
@@ -82,13 +111,46 @@ var Transcript = shortcut.Shortcut{
if direction == "" {
direction = "0"
}
return rt.CallMCP("get_minutes_transcription", map[string]any{
"taskUuid": taskUUID,
"direction": direction,
})
result, readErr := collectMinutesTranscript(rt, taskUUID, direction, rt.Str("cursor"), rt.Bool("single-page"), rt.Int("page-limit"))
payload := minutesdata.TranscriptPayload(taskUUID, direction, result)
if err := rt.Output(payload); err != nil {
return err
}
if readErr != nil {
return minutesTranscriptReadError(taskUUID, result, readErr)
}
return nil
},
}
func collectMinutesTranscript(rt *shortcut.RuntimeContext, taskUUID, direction, cursor string, singlePage bool, pageLimit int) (minutesdata.TranscriptResult, error) {
return minutesdata.CollectTranscript(func(nextToken string) (map[string]any, error) {
params := map[string]any{"taskUuid": taskUUID, "direction": direction}
if nextToken != "" {
params["nextToken"] = nextToken
}
return rt.CallMCPData("minutes", "get_minutes_transcription", params)
}, cursor, singlePage, pageLimit)
}
func minutesTranscriptReadError(taskUUID string, result minutesdata.TranscriptResult, cause error) error {
return apperrors.NewAPI(
fmt.Sprintf("逐字稿读取不完整:已读取 %d 页、%d 个段落", result.Pages, len(result.Paragraphs)),
apperrors.WithOperation("minutes/get_minutes_transcription"),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("pagination"),
apperrors.WithReason("minutes_transcript_incomplete"),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{
"taskUuid": taskUUID,
"pages": result.Pages,
"paragraphCount": len(result.Paragraphs),
"nextToken": result.NextToken,
"cause": cause.Error(),
}),
)
}
func init() {
shortcut.Register(Transcript)
shortcut.Register(finalizeMinutesSmartShortcut(Transcript))
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package skillprovenance builds the per-Skill provenance records stored in
// the single ~/.dws/skills-state.json metadata file.
package skillprovenance
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
const (
DigestScope = "skill-directory-v1"
SourceSkillSetup = "dws-skill-setup"
SourceUpgrade = "dws-upgrade"
)
// Record identifies one DWS-managed Skill. Ownership is derived from these
// records, never from files placed inside an Agent's Skill directory.
type Record struct {
Name string `json:"name"`
Version string `json:"version"`
Source string `json:"source"`
Digest string `json:"digest"`
DigestScope string `json:"digest_scope"`
}
var (
provenanceWalkDir = filepath.WalkDir
provenanceRelative = filepath.Rel
provenanceReadFile = os.ReadFile
)
// Build returns a deterministic provenance record for a Skill directory.
func Build(name, dir, version, source string) (Record, error) {
name = strings.TrimSpace(name)
if name == "" {
return Record{}, fmt.Errorf("provenance Skill name is empty")
}
digest, err := DigestDir(dir)
if err != nil {
return Record{}, err
}
version = strings.TrimSpace(version)
if version == "" {
version = "unknown"
}
source = strings.TrimSpace(source)
if source == "" {
return Record{}, fmt.Errorf("provenance source is empty")
}
return Record{
Name: name,
Version: version,
Source: source,
Digest: digest,
DigestScope: DigestScope,
}, nil
}
// DigestDir hashes every regular file ordered by slash-normalized relative
// path. Paths and contents are NUL-delimited.
func DigestDir(root string) (string, error) {
var paths []string
err := provenanceWalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
rel, err := provenanceRelative(root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if !entry.Type().IsRegular() {
return fmt.Errorf("unsupported non-regular Skill file: %s", path)
}
paths = append(paths, rel)
return nil
})
if err != nil {
return "", err
}
sort.Strings(paths)
hash := sha256.New()
for _, rel := range paths {
_, _ = hash.Write([]byte(rel))
_, _ = hash.Write([]byte{0})
content, err := provenanceReadFile(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
return "", err
}
_, _ = hash.Write(content)
_, _ = hash.Write([]byte{0})
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil)), nil
}
// Merge replaces records with matching names and returns a name-sorted set.
func Merge(existing, updates []Record) []Record {
byName := make(map[string]Record, len(existing)+len(updates))
for _, record := range existing {
if strings.TrimSpace(record.Name) != "" {
byName[record.Name] = record
}
}
for _, record := range updates {
if strings.TrimSpace(record.Name) != "" {
byName[record.Name] = record
}
}
names := make([]string, 0, len(byName))
for name := range byName {
names = append(names, name)
}
sort.Strings(names)
out := make([]Record, 0, len(names))
for _, name := range names {
out = append(out, byName[name])
}
return out
}
func Names(records []Record) map[string]bool {
names := make(map[string]bool, len(records))
for _, record := range records {
if record.Name != "" {
names[record.Name] = true
}
}
return names
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package skillprovenance
import (
"errors"
"io/fs"
"os"
"path/filepath"
"reflect"
"testing"
)
type fakeDirEntry struct {
name string
mode fs.FileMode
}
func (entry fakeDirEntry) Name() string { return entry.name }
func (entry fakeDirEntry) IsDir() bool { return entry.mode.IsDir() }
func (entry fakeDirEntry) Type() fs.FileMode { return entry.mode.Type() }
func (entry fakeDirEntry) Info() (fs.FileInfo, error) { return nil, errors.New("unused") }
func swapProvenanceSeam[T any](t *testing.T, target *T, replacement T) {
t.Helper()
original := *target
*target = replacement
t.Cleanup(func() { *target = original })
}
func TestCrossPlatformCoverageSkillProvenanceBuildDigestAndMerge(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("skill"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "references", "a.md"), []byte("ref"), 0o644); err != nil {
t.Fatal(err)
}
record, err := Build("dingtalk-a", dir, "", SourceUpgrade)
if err != nil {
t.Fatal(err)
}
if record.Version != "unknown" || record.Source != SourceUpgrade || record.DigestScope != DigestScope {
t.Fatalf("record = %#v", record)
}
first := record.Digest
if err := os.WriteFile(filepath.Join(dir, "references", "a.md"), []byte("changed"), 0o644); err != nil {
t.Fatal(err)
}
second, err := Build("dingtalk-a", dir, "v2", SourceSkillSetup)
if err != nil || second.Digest == first {
t.Fatalf("second = %#v, %v", second, err)
}
merged := Merge([]Record{record, {Name: "dingtalk-b"}}, []Record{second, {}})
if !reflect.DeepEqual(merged, []Record{second, {Name: "dingtalk-b"}}) {
t.Fatalf("merged = %#v", merged)
}
if got := Names(merged); !got["dingtalk-a"] || !got["dingtalk-b"] || len(got) != 2 {
t.Fatalf("names = %#v", got)
}
if _, err := Build("", dir, "v1", "test"); err == nil {
t.Fatal("empty name accepted")
}
if _, err := Build("dingtalk-a", dir, "v1", " "); err == nil {
t.Fatal("empty source accepted")
}
if _, err := Build("dingtalk-a", filepath.Join(dir, "missing"), "v1", "test"); err == nil {
t.Fatal("missing Skill directory accepted")
}
}
func TestCrossPlatformCoverageSkillProvenanceDigestErrors(t *testing.T) {
want := errors.New("injected")
t.Run("walk", func(t *testing.T) {
swapProvenanceSeam(t, &provenanceWalkDir, func(string, fs.WalkDirFunc) error { return want })
if _, err := DigestDir(t.TempDir()); !errors.Is(err, want) {
t.Fatal(err)
}
})
t.Run("callback", func(t *testing.T) {
swapProvenanceSeam(t, &provenanceWalkDir, func(root string, walk fs.WalkDirFunc) error { return walk(root, nil, want) })
if _, err := DigestDir(t.TempDir()); !errors.Is(err, want) {
t.Fatal(err)
}
})
t.Run("relative", func(t *testing.T) {
swapProvenanceSeam(t, &provenanceWalkDir, func(root string, walk fs.WalkDirFunc) error {
return walk(filepath.Join(root, "file"), fakeDirEntry{name: "file"}, nil)
})
swapProvenanceSeam(t, &provenanceRelative, func(string, string) (string, error) { return "", want })
if _, err := DigestDir(t.TempDir()); !errors.Is(err, want) {
t.Fatal(err)
}
})
t.Run("nonregular", func(t *testing.T) {
swapProvenanceSeam(t, &provenanceWalkDir, func(root string, walk fs.WalkDirFunc) error {
return walk(filepath.Join(root, "link"), fakeDirEntry{name: "link", mode: fs.ModeSymlink}, nil)
})
if _, err := DigestDir(t.TempDir()); err == nil {
t.Fatal("nonregular accepted")
}
})
t.Run("read", func(t *testing.T) {
dir := t.TempDir()
_ = os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("x"), 0o644)
swapProvenanceSeam(t, &provenanceReadFile, func(string) ([]byte, error) { return nil, want })
if _, err := DigestDir(dir); !errors.Is(err, want) {
t.Fatal(err)
}
})
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package skillstate persists the official Skill snapshot and centralized
// ownership metadata written after setup and upgrade. Bundled skills are
// always fully refreshed from the current release and local absence is not an
// exclusion.
package skillstate
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
)
const stateFile = "skills-state.json"
// legacyOfficialSkillNames is the frozen set of official multi-skill names
// shipped before centralized ownership metadata. Exact names are safe
// migration evidence; a dingtalk-* prefix is not.
//
// Keep retired names here permanently so an old installation can still be
// migrated after that Skill has been folded into another bundle.
var legacyOfficialSkillNames = map[string]struct{}{
"dingtalk-agoal": {},
"dingtalk-aiapp": {},
"dingtalk-aisearch": {},
"dingtalk-aitable": {},
"dingtalk-attendance": {},
"dingtalk-calendar": {},
"dingtalk-chat": {},
"dingtalk-contact": {},
"dingtalk-dev": {},
"dingtalk-devapp": {},
"dingtalk-devdoc": {},
"dingtalk-ding": {},
"dingtalk-doc": {},
"dingtalk-drive": {},
"dingtalk-event": {},
"dingtalk-hrbrain": {},
"dingtalk-live": {},
"dingtalk-mail": {},
"dingtalk-markdown": {},
"dingtalk-minutes": {},
"dingtalk-misc": {},
"dingtalk-oa": {},
"dingtalk-pat": {},
"dingtalk-profile": {},
"dingtalk-report": {},
"dingtalk-shared": {},
"dingtalk-sheet": {},
"dingtalk-skill": {},
"dingtalk-todo": {},
"dingtalk-wiki": {},
"dws-shared": {},
}
var (
skillStateReadFile = os.ReadFile
skillStateRemove = os.Remove
)
type State struct {
Version string `json:"version"`
OfficialSkills []string `json:"official_skills"`
UpdatedSkills []string `json:"updated_skills"`
ManagedSkills []skillprovenance.Record `json:"managed_skills"`
UpdatedAt string `json:"updated_at"`
}
// IsLegacyOfficialSkillName reports whether name was an exact official
// multi-skill directory name before centralized ownership metadata shipped.
func IsLegacyOfficialSkillName(name string) bool {
_, ok := legacyOfficialSkillNames[name]
return ok
}
func Path(home string) string {
if configured := strings.TrimSpace(os.Getenv("DWS_CONFIG_DIR")); configured != "" {
return filepath.Join(configured, stateFile)
}
return filepath.Join(home, ".dws", stateFile)
}
func Read(home string) (*State, bool, error) {
data, err := skillStateReadFile(Path(home))
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return nil, false, fmt.Errorf("skill 状态不可读: %w", err)
}
return &state, true, nil
}
func Write(home string, state State) error {
state.OfficialSkills = uniqueSorted(state.OfficialSkills)
state.UpdatedSkills = uniqueSorted(state.UpdatedSkills)
state.ManagedSkills = skillprovenance.Merge(nil, state.ManagedSkills)
data, _ := json.MarshalIndent(state, "", " ")
if err := helpers.AtomicWriteJSON(Path(home), append(data, '\n')); err != nil {
return fmt.Errorf("保存 skill 状态失败: %w", err)
}
return nil
}
func ManagedSkillNames(state *State) map[string]bool {
if state == nil {
return map[string]bool{}
}
return skillprovenance.Names(state.ManagedSkills)
}
func Remove(home string) error {
if err := skillStateRemove(Path(home)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("清理 skill 状态失败: %w", err)
}
return nil
}
func uniqueSorted(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Strings(out)
return out
}
+97
View File
@@ -0,0 +1,97 @@
package skillstate
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillStateReadWriteRemoveAndErrors(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", "")
home := t.TempDir()
if state, readable, err := Read(home); err != nil || readable || state != nil {
t.Fatalf("missing = %#v, %v, %v", state, readable, err)
}
want := State{
Version: "1.2.3",
OfficialSkills: []string{"dingtalk-b", "dingtalk-a", "dingtalk-a"},
UpdatedSkills: []string{"dingtalk-a"},
ManagedSkills: []skillprovenance.Record{
{Name: "dingtalk-b", Version: "old"},
{Name: "dingtalk-a", Version: "1"},
{Name: "dingtalk-b", Version: "2"},
},
}
if err := Write(home, want); err != nil {
t.Fatal(err)
}
got, readable, err := Read(home)
if err != nil || !readable || !reflect.DeepEqual(got.OfficialSkills, []string{"dingtalk-a", "dingtalk-b"}) {
t.Fatalf("round trip = %#v, %v, %v", got, readable, err)
}
if !reflect.DeepEqual(got.ManagedSkills, []skillprovenance.Record{{Name: "dingtalk-a", Version: "1"}, {Name: "dingtalk-b", Version: "2"}}) {
t.Fatalf("managed skills = %#v", got.ManagedSkills)
}
if names := ManagedSkillNames(got); !reflect.DeepEqual(names, map[string]bool{"dingtalk-a": true, "dingtalk-b": true}) {
t.Fatalf("managed names = %#v", names)
}
if names := ManagedSkillNames(nil); len(names) != 0 {
t.Fatalf("nil managed names = %#v", names)
}
if err := Remove(home); err != nil {
t.Fatal(err)
}
if err := Remove(home); err != nil {
t.Fatal(err)
}
badHome := t.TempDir()
if err := os.MkdirAll(filepath.Dir(Path(badHome)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(Path(badHome), []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := Read(badHome); err == nil || !strings.Contains(err.Error(), "不可读") {
t.Fatalf("malformed = %v", err)
}
blocked := filepath.Join(t.TempDir(), "home-file")
if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := Write(blocked, State{}); err == nil {
t.Fatal("blocked write succeeded")
}
failure := errors.New("denied")
testseam.Swap(t, &skillStateReadFile, func(string) ([]byte, error) { return nil, failure })
if _, _, err := Read(blocked); !errors.Is(err, failure) {
t.Fatal("blocked read succeeded")
}
testseam.Swap(t, &skillStateRemove, func(string) error { return failure })
if err := Remove(blocked); !errors.Is(err, failure) {
t.Fatal("blocked remove succeeded")
}
configured := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", " "+configured+" ")
if Path("ignored") != filepath.Join(configured, stateFile) {
t.Fatal("configured path ignored")
}
}
func TestCrossPlatformCoverageIsLegacyOfficialSkillName(t *testing.T) {
for _, name := range []string{"dingtalk-aitable", "dingtalk-devdoc", "dws-shared"} {
if !IsLegacyOfficialSkillName(name) {
t.Fatalf("historical official Skill %q not recognized", name)
}
}
for _, name := range []string{"dingtalk-custom", "other-skill", ""} {
if IsLegacyOfficialSkillName(name) {
t.Fatalf("user Skill %q must not be treated as historical official", name)
}
}
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// SwapUserHomeDirForTest swaps the home-dir seam used by UpgradeSkillLocations
// and related upgrade path helpers. Restored via t.Cleanup; not safe with
// t.Parallel.
func SwapUserHomeDirForTest(t *testing.T, fn func() (string, error)) {
t.Helper()
testseam.Swap(t, &upgradeUserHomeDir, fn)
}
+671 -15
View File
@@ -4,10 +4,17 @@
package upgrade
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
)
// Permission constants following Unix best practices.
@@ -50,13 +57,83 @@ var knownSkillDirs = []string{
}
var (
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeUserHomeDir = os.UserHomeDir
upgradeExecutable = os.Executable
upgradeEvalSymlinks = filepath.EvalSymlinks
upgradeCopyDir = copyDir
upgradeEnsureDir = ensureDir
upgradeRemoveAll = os.RemoveAll
upgradeMkdirAll = os.MkdirAll
upgradeMkdirTemp = os.MkdirTemp
upgradeReadDir = os.ReadDir
upgradeStat = os.Stat
upgradeBuildProvenance = skillprovenance.Build
upgradeReadSkillState = skillstate.Read
upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") }
upgradeWriteSkillState = skillstate.Write
upgradeNow = time.Now
)
// skillBackupSubdir is the user-level directory where skill directories are
// preserved before a layout-changing install/upgrade removes them. Non-
// interactive flows (install scripts, npm postinstall, `dws upgrade`) cannot
// ask for confirmation, so deletions must stay reversible instead.
const skillBackupSubdir = ".dws/skill-backups"
// backupAndRemoveSkillDir moves dir into <homeDir>/.dws/skill-backups/
// <stamp>/<rel> instead of destroying it, and returns the backup path. It is
// fail-safe: a directory that cannot be backed up is NOT removed and the
// error is returned so the caller can surface it (and never install the
// opposite layout next to it silently). Missing paths and regular files are
// no-ops ("", nil).
func backupAndRemoveSkillDir(homeDir, dir string) (string, error) {
info, err := upgradeStat(dir)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err)
}
if !info.IsDir() {
return "", nil
}
rel, relErr := filepath.Rel(homeDir, dir)
if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") {
rel = filepath.Base(dir)
}
name := strings.NewReplacer(string(filepath.Separator), "-", "/", "-").Replace(rel)
stamp := upgradeBackupStamp()
backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp)
target := filepath.Join(backupRoot, name)
for i := 1; ; i++ {
if _, err := os.Stat(target); os.IsNotExist(err) {
break
}
backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i))
target = filepath.Join(backupRoot, name)
if i > 1000 {
return "", fmt.Errorf("备份目录冲突无法解决: %s", target)
}
}
if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil {
return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err)
}
if err := upgradeRename(dir, target); err != nil {
return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err)
}
// Keep the backup directory bounded; a prune failure must not fail the
// install (the backup itself succeeded).
_ = pruneSkillBackups(homeDir)
return target, nil
}
// BackupAndRemoveSkillDir is the exported wrapper over
// backupAndRemoveSkillDir for callers outside the upgrade package (the
// skill-setup channel in internal/app).
func BackupAndRemoveSkillDir(homeDir, dir string) (string, error) {
return backupAndRemoveSkillDir(homeDir, dir)
}
// skillDirBlacklist contains parent directories whose skills are managed by
// external mechanisms (e.g. IDE extensions) and must NOT be touched by upgrade.
var skillDirBlacklist = []string{
@@ -107,22 +184,341 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult {
return out
}
// UpgradeSkillLocations installs skills from extractedDir into all locations
// where they are currently installed or expected.
// UpgradeSkillLocations refreshes skills from extractedDir into agent homes.
// extractedDir may be a multi-skill bundle root (subdirectories each containing
// SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that
// resolve a release zip usually pass LocateSkillsRoot's result (multi/
// preferred when present).
//
// Package-driven layout:
// - release zip has multi/ → install and overwrite the complete official
// bundle. Locally deleted bundled skills are restored on the next upgrade;
// local absence is never treated as a persistent exclusion.
// - dingtalk-shared is mandatory whenever it exists in the bundle.
// - legacy zip with no multi tree → mono refresh path (unchanged fallback)
//
// Fresh install defaults to multi with opt-in mono via the interactive
// `dws skill setup --mode mono` flow.
//
// Strategy (matches npm install.js installSkillsToHomes):
// - ~/.agents/skills/dws/ is ALWAYS updated (primary install location)
// - ~/.agents/skills/ is ALWAYS updated (primary install location)
// - Other agent dirs (claude, cursor, ...) are updated only when the parent
// directory exists (e.g. ~/.claude/ exists => user has Claude)
// - ~/.real/ and other blacklisted paths are NEVER touched
// - If no location was updated at all, fall back to ~/.agents/skills/dws/
// - If no location was updated at all, fall back to ~/.agents/skills/
//
// Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then
// removed so mono and multi never co-exist after an upgrade; a leftover that
// cannot be backed up is never removed and fails that home. Same-name bundle
// skills are refreshed in place (verified DWS-managed overwrite). Caches
// under ~/.dws/skills/{multi,mono} are refreshed best-effort.
func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
return UpgradeSkillLocationsWithOptions(extractedDir, SkillUpgradeOptions{})
}
type SkillUpgradeOptions struct {
Version string
}
func UpgradeSkillLocationsWithOptions(extractedDir string, opts SkillUpgradeOptions) (*SkillUpgradeResult, error) {
homeDir, err := upgradeUserHomeDir()
if err != nil {
return nil, err
}
multiRoot, skills := resolveMultiBundle(extractedDir)
if len(skills) > 0 {
official := append([]string(nil), skills...)
managedSkills, provenanceErr := buildUpgradeProvenanceRecords(multiRoot, official, opts.Version)
if provenanceErr != nil {
return nil, fmt.Errorf("生成统一 Skill provenance 失败: %w", provenanceErr)
}
result, installErr := upgradeMultiSkillLocations(homeDir, multiRoot, official)
if installErr != nil {
return result, installErr
}
if len(result.Failed()) == 0 && len(result.Succeeded()) > 0 {
state := skillstate.State{
Version: opts.Version,
OfficialSkills: official,
UpdatedSkills: official,
ManagedSkills: managedSkills,
UpdatedAt: upgradeNow().UTC().Format(time.RFC3339),
}
if writeErr := upgradeWriteSkillState(homeDir, state); writeErr != nil {
return result, fmt.Errorf("skill 已同步但状态未写入: %w", writeErr)
}
}
return result, nil
}
monoSrc := resolveMonoSkillSrc(extractedDir)
if monoSrc != "" {
return upgradeMonoSkillLocations(homeDir, monoSrc)
}
return nil, fmt.Errorf("升级包中找不到可安装的 skill 源")
}
func buildUpgradeProvenanceRecords(root string, names []string, version string) ([]skillprovenance.Record, error) {
records := make([]skillprovenance.Record, 0, len(names))
for _, name := range names {
record, err := upgradeBuildProvenance(name, filepath.Join(root, name), version, skillprovenance.SourceUpgrade)
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
records = append(records, record)
}
return records, nil
}
// skillBackupKeep limits ~/.dws/skill-backups/ growth: only the newest
// backups are kept.
const skillBackupKeep = 5
// pruneSkillBackups removes the oldest backup directories when more than
// skillBackupKeep remain. Best-effort: a removal failure never aborts, but
// pruning failures are reported so callers can warn the user.
func pruneSkillBackups(homeDir string) error {
root := filepath.Join(homeDir, skillBackupSubdir)
entries, err := upgradeReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names)
var firstErr error
for len(names) > skillBackupKeep {
old := filepath.Join(root, names[0])
names = names[1:]
if err := upgradeRemoveAll(old); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// resolveMultiBundle returns the multi skill root and its skill names when
// extractedDir is itself a bundle, or when a multi/ child of the parent
// extract root carries one (LocateSkillsRoot already prefers that child).
func resolveMultiBundle(extractedDir string) (string, []string) {
if skills := bundleSkillNames(extractedDir); len(skills) > 0 {
return extractedDir, skills
}
child := filepath.Join(extractedDir, "multi")
if skills := bundleSkillNames(child); len(skills) > 0 {
return child, skills
}
return "", nil
}
// resolveMonoSkillSrc finds a mono skill tree for the legacy mono-only
// package fallback: the path itself, a sibling mono/ next to a multi root,
// or the extract-root SKILL.md copy that release zips still ship.
func resolveMonoSkillSrc(extractedDir string) string {
if skillTreeHasRoot(extractedDir) {
return extractedDir
}
sibling := filepath.Join(filepath.Dir(extractedDir), "mono")
if skillTreeHasRoot(sibling) {
return sibling
}
parent := filepath.Dir(extractedDir)
if skillTreeHasRoot(parent) {
return parent
}
child := filepath.Join(extractedDir, "mono")
if skillTreeHasRoot(child) {
return child
}
return ""
}
type skillStageSpec struct {
src string
dest string
}
type stagedSkillDir struct {
staged string
dest string
}
type backedUpSkillDir struct {
original string
backup string
}
// stageSkillSet builds a complete replacement next to its final destinations.
// Nothing Agent-visible is changed until every copy succeeds.
func stageSkillSet(destBase, prefix string, specs []skillStageSpec) (stageRoot string, staged []stagedSkillDir, err error) {
if err := upgradeMkdirAll(destBase, dirPermShared); err != nil {
return "", nil, fmt.Errorf("创建 Skill 目标目录失败 %s: %w", destBase, err)
}
stageRoot, err = upgradeMkdirTemp(destBase, prefix)
if err != nil {
return "", nil, fmt.Errorf("创建 Skill staging 失败 %s: %w", destBase, err)
}
defer func() {
if err == nil {
return
}
if cleanupErr := upgradeRemoveAll(stageRoot); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
}
}()
staged = make([]stagedSkillDir, 0, len(specs))
for _, spec := range specs {
stageDir := filepath.Join(stageRoot, filepath.Base(spec.dest))
if err := upgradeCopyDir(spec.src, stageDir); err != nil {
return stageRoot, nil, fmt.Errorf("拷贝 Skill staging 失败 %s: %w", stageDir, err)
}
staged = append(staged, stagedSkillDir{staged: stageDir, dest: spec.dest})
}
return stageRoot, staged, nil
}
func uniqueSkillDirs(paths []string) []string {
seen := make(map[string]bool, len(paths))
unique := make([]string, 0, len(paths))
for _, path := range paths {
path = filepath.Clean(path)
if seen[path] {
continue
}
seen[path] = true
unique = append(unique, path)
}
return unique
}
// restoreSkillSet removes any newly published directories, then restores all
// original directories in reverse backup order.
func restoreSkillSet(published []string, backups []backedUpSkillDir) error {
var restoreErr error
for i := len(published) - 1; i >= 0; i-- {
if err := upgradeRemoveAll(published[i]); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
}
}
for i := len(backups) - 1; i >= 0; i-- {
backup := backups[i]
if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err))
continue
}
if err := upgradeRename(backup.backup, backup.original); err != nil {
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err))
}
}
return restoreErr
}
// backupSkillSet moves every victim aside as one logical operation. If a
// later backup fails, earlier moves are restored before the error is returned.
func backupSkillSet(homeDir string, victims []string) ([]backedUpSkillDir, error) {
backups := make([]backedUpSkillDir, 0, len(victims))
for _, victim := range uniqueSkillDirs(victims) {
backup, err := backupAndRemoveSkillDir(homeDir, victim)
if err != nil {
if restoreErr := restoreSkillSet(nil, backups); restoreErr != nil {
return nil, errors.Join(err, fmt.Errorf("恢复已备份 Skill 失败: %w", restoreErr))
}
return nil, err
}
if backup != "" {
backups = append(backups, backedUpSkillDir{original: victim, backup: backup})
}
}
return backups, nil
}
// publishStagedSkillSet switches a fully staged set into place. Any publish
// failure removes the partial new set and restores every original directory.
func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []string) error {
backups, err := backupSkillSet(homeDir, victims)
if err != nil {
return err
}
published := make([]string, 0, len(staged))
for _, skill := range staged {
if err := upgradeRename(skill.staged, skill.dest); err != nil {
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err)
if restoreErr := restoreSkillSet(published, backups); restoreErr != nil {
return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr))
}
return publishErr
}
published = append(published, skill.dest)
}
return nil
}
func monoUpgradeVictims(baseDir, destDir string, managed ...map[string]bool) ([]string, error) {
victims, err := managedMultiSkillVictims(baseDir, managed...)
if err != nil {
return nil, err
}
return append(victims, destDir), nil
}
func multiUpgradeVictims(destBase string, skillSet map[string]bool, skills []string, managed ...map[string]bool) ([]string, error) {
victims, err := oppositeModeSkillVictims(destBase, skillSet, managed...)
if err != nil {
return nil, err
}
for _, name := range skills {
victims = append(victims, filepath.Join(destBase, name))
}
return victims, nil
}
func publishMonoUpgradeTarget(homeDir, destBase, skillSrc string, managed ...map[string]bool) error {
destDir := filepath.Join(destBase, "dws")
victims, err := monoUpgradeVictims(destBase, destDir, managed...)
if err != nil {
return err
}
stageRoot, staged, err := stageSkillSet(destBase, ".dws-upgrade-mono-", []skillStageSpec{{src: skillSrc, dest: destDir}})
if err != nil {
return err
}
defer func() { _ = upgradeRemoveAll(stageRoot) }()
return publishStagedSkillSet(homeDir, staged, victims)
}
func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []string, skillSet map[string]bool, managed ...map[string]bool) error {
victims, err := multiUpgradeVictims(destBase, skillSet, skills, managed...)
if err != nil {
return err
}
specs := make([]skillStageSpec, 0, len(skills))
for _, name := range skills {
specs = append(specs, skillStageSpec{
src: filepath.Join(multiRoot, name),
dest: filepath.Join(destBase, name),
})
}
stageRoot, staged, err := stageSkillSet(destBase, ".dws-upgrade-multi-", specs)
if err != nil {
return err
}
defer func() { _ = upgradeRemoveAll(stageRoot) }()
return publishStagedSkillSet(homeDir, staged, victims)
}
// upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory
// per agent home.
func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) {
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
for i, agentDir := range knownSkillDirs {
destDir := filepath.Join(homeDir, agentDir, "dws")
@@ -140,19 +536,21 @@ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
}
}
os.RemoveAll(destDir)
if err := upgradeCopyDir(extractedDir, destDir); err != nil {
if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK})
}
// Fallback: if nothing succeeded, force the primary location
// Fallback: if nothing succeeded, force the primary location. The multi
// leftovers under the primary base are the usual reason the primary
// install failed, so clean them first — failing loud like the multi
// fallback — instead of letting mono and multi co-exist marked OK.
if len(result.Succeeded()) == 0 {
dest := filepath.Join(homeDir, ".agents", "skills", "dws")
os.MkdirAll(filepath.Dir(dest), dirPermShared)
if err := upgradeCopyDir(extractedDir, dest); err != nil {
destBase := filepath.Join(homeDir, ".agents", "skills")
dest := filepath.Join(destBase, "dws")
if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
@@ -169,9 +567,267 @@ func UpgradeSkillLocations(extractedDir string) (*SkillUpgradeResult, error) {
}
}
// Best-effort: refresh the user-level mono cache so that
// `dws skill setup --mode mono` fallbacks stay on the upgraded version
// (symmetric with the multi cache refresh in upgradeMultiSkillLocations).
_ = refreshSkillCache(homeDir, "mono", skillSrc)
return result, nil
}
// upgradeMultiSkillLocations installs every skill of the multi bundle into
// each agent home as sibling directories and backs up + removes the
// opposite-mode leftovers. A home is marked failed (and multi is NOT
// installed into it) when leftover backup/removal fails, so mono and multi
// never co-exist.
func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*SkillUpgradeResult, error) {
skillSet := make(map[string]bool, len(skills))
for _, s := range skills {
skillSet[s] = true
}
result := &SkillUpgradeResult{}
managedNames := readManagedSkillNames(homeDir)
for i, agentDir := range knownSkillDirs {
destBase := filepath.Join(homeDir, agentDir)
if isBlacklisted(agentDir) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted})
continue
}
if i > 0 {
parentGate := filepath.Dir(destBase)
if _, err := os.Stat(parentGate); os.IsNotExist(err) {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped})
continue
}
}
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err})
continue
}
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
// Fallback: if nothing succeeded, force the primary location
if len(result.Succeeded()) == 0 {
destBase := filepath.Join(homeDir, ".agents", "skills")
if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil {
return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err)
}
// Replace the earlier failed entry for this dir (if any) or append a new one
replaced := false
for idx, r := range result.Results {
if r.Dir == destBase {
result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK}
replaced = true
break
}
}
if !replaced {
result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK})
}
}
// Best-effort: refresh the user-level caches so that `dws skill setup`
// fallbacks stay on the upgraded version. The release zip ships both
// trees, so when the sibling mono/ tree is present the mono cache is
// refreshed as well.
_ = refreshSkillCache(homeDir, "multi", multiRoot)
if monoSrc := filepath.Join(filepath.Dir(multiRoot), "mono"); skillTreeHasRoot(monoSrc) {
_ = refreshSkillCache(homeDir, "mono", monoSrc)
}
return result, nil
}
// refreshSkillCache mirrors src into ~/.dws/skills/<name>/ through a staged
// sibling directory. The existing cache is moved aside only after the staged
// copy is complete, and is restored if publishing the new cache fails.
func refreshSkillCache(homeDir, name, src string) error {
cacheDir := filepath.Join(homeDir, ".dws", "skills", name)
cacheParent := filepath.Dir(cacheDir)
if err := upgradeMkdirAll(cacheParent, dirPermShared); err != nil {
return fmt.Errorf("创建 Skill 缓存目录失败 %s: %w", cacheParent, err)
}
stagedDir, err := upgradeMkdirTemp(cacheParent, "."+name+".tmp-")
if err != nil {
return fmt.Errorf("创建 Skill 缓存临时目录失败 %s: %w", cacheParent, err)
}
stagedPublished := false
defer func() {
if !stagedPublished {
_ = upgradeRemoveAll(stagedDir)
}
}()
if err := upgradeCopyDir(src, stagedDir); err != nil {
return fmt.Errorf("暂存 Skill 缓存失败 %s: %w", stagedDir, err)
}
if _, err := upgradeStat(cacheDir); os.IsNotExist(err) {
if err := upgradeRename(stagedDir, cacheDir); err != nil {
return fmt.Errorf("发布 Skill 缓存失败 %s: %w", cacheDir, err)
}
stagedPublished = true
return nil
} else if err != nil {
return fmt.Errorf("检查 Skill 缓存失败 %s: %w", cacheDir, err)
}
rollbackDir, err := upgradeMkdirTemp(cacheParent, "."+name+".old-")
if err != nil {
return fmt.Errorf("创建 Skill 缓存回滚目录失败 %s: %w", cacheParent, err)
}
if err := upgradeRemoveAll(rollbackDir); err != nil {
return fmt.Errorf("准备 Skill 缓存回滚目录失败 %s: %w", rollbackDir, err)
}
if err := upgradeRename(cacheDir, rollbackDir); err != nil {
return fmt.Errorf("暂存原 Skill 缓存失败 %s: %w", cacheDir, err)
}
if err := upgradeRename(stagedDir, cacheDir); err != nil {
if restoreErr := upgradeRename(rollbackDir, cacheDir); restoreErr != nil {
return fmt.Errorf("发布 Skill 缓存失败 %s: %w(恢复原缓存也失败: %v)", cacheDir, err, restoreErr)
}
return fmt.Errorf("发布 Skill 缓存失败 %s: %w", cacheDir, err)
}
stagedPublished = true
_ = upgradeRemoveAll(rollbackDir)
return nil
}
// skillTreeHasRoot reports whether dir carries a top-level SKILL.md.
func skillTreeHasRoot(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "SKILL.md"))
return err == nil && !info.IsDir()
}
// cleanupMultiLeftovers backs up + removes every proven DWS-managed
// multi-mode skill directory inside one agent home before mono is installed.
// A missing base directory simply means no leftovers; any other read failure
// is reported so mono never silently co-exists with multi. Removal is
// reversible: each leftover is preserved under ~/.dws/skill-backups/ and a
// backup failure aborts the removal for that home.
func cleanupMultiLeftovers(homeDir, baseDir string) error {
victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
}
return nil
}
func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) {
entries, err := upgradeReadDir(baseDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("读取技能目录失败 %s: %w", baseDir, err)
}
victims := make([]string, 0)
for _, e := range entries {
if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(baseDir, e.Name()))
}
return victims, nil
}
// cleanupOppositeModeLeftovers backs up + removes, inside one agent home, the
// legacy mono directory (dws/) and every proven DWS-managed multi skill
// directory that is not part of the new bundle. A dingtalk-* prefix alone is
// not proof of ownership because market/user skills may use the same prefix.
// Removal is reversible: each
// directory is preserved under ~/.dws/skill-backups/ and a backup failure
// aborts the removal for that home.
func cleanupOppositeModeLeftovers(homeDir, destBase string, skillSet map[string]bool) error {
victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
}
return nil
}
func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) {
victims := []string{filepath.Join(destBase, "dws")}
entries, err := upgradeReadDir(destBase)
if err != nil {
if os.IsNotExist(err) {
return victims, nil
}
return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err)
}
for _, e := range entries {
if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) {
continue
}
victims = append(victims, filepath.Join(destBase, e.Name()))
}
return victims, nil
}
// isManagedMultiSkillDir accepts only centralized metadata or the frozen exact
// official-name migration list. Files inside Skill directories are ignored.
func isManagedMultiSkillDir(dir string, managed ...map[string]bool) bool {
if skillstate.IsLegacyOfficialSkillName(filepath.Base(dir)) {
return true
}
return len(managed) > 0 && managed[0][filepath.Base(dir)]
}
func readManagedSkillNames(homeDir string) map[string]bool {
state, readable, err := upgradeReadSkillState(homeDir)
if err != nil || !readable {
return map[string]bool{}
}
return skillstate.ManagedSkillNames(state)
}
// bundleSkillNames returns the sorted names of subdirectories of dir that
// contain a SKILL.md. It returns nil when dir itself carries a top-level
// SKILL.md (mono layout) so callers can distinguish the two layouts.
func bundleSkillNames(dir string) []string {
if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err == nil {
return nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var names []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(dir, e.Name(), "SKILL.md")); err == nil {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names
}
// LocateSkillsRoot resolves the skill root inside an extracted dws-skills.zip,
// preferring the multi bundle ({extractDir}/multi) over the legacy mono
// layouts handled by LocateSkillMD.
func LocateSkillsRoot(extractDir string) string {
multiRoot := filepath.Join(extractDir, "multi")
if skills := bundleSkillNames(multiRoot); len(skills) > 0 {
return multiRoot
}
return LocateSkillMD(extractDir)
}
// LocateSkillMD finds the directory containing SKILL.md in an extracted zip.
// It handles both flat layouts (SKILL.md at root) and nested layouts (dws/SKILL.md).
func LocateSkillMD(extractDir string) string {
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -59,6 +59,10 @@ func TestLocateSkillMD(t *testing.T) {
}
func TestUpgradeSkillLocations(t *testing.T) {
// Fake HOME: the mono path refreshes ~/.dws/skills/mono best-effort, and
// the test must never touch the real user cache.
homeDir := withFakeHome(t)
skillSrc := t.TempDir()
os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# test skill"), 0644)
os.MkdirAll(filepath.Join(skillSrc, "references"), 0755)
@@ -74,7 +78,6 @@ func TestUpgradeSkillLocations(t *testing.T) {
t.Fatal("UpgradeSkillLocations() returned 0 succeeded locations")
}
homeDir, _ := os.UserHomeDir()
primaryDest := filepath.Join(homeDir, ".agents", "skills", "dws")
found := false
+251
View File
@@ -0,0 +1,251 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func writeSkillCacheFixture(t *testing.T, dir, content string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func readSkillCacheFixture(t *testing.T, dir string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
if err != nil {
t.Fatalf("read cache fixture: %v", err)
}
return string(data)
}
// TestCrossPlatformCoverageRefreshSkillCacheTransaction pins the cache refresh
// transaction on every platform: copying is staged before the old cache moves,
// and a publish failure restores the prior usable cache.
func TestCrossPlatformCoverageRefreshSkillCacheTransaction(t *testing.T) {
newSource := func(t *testing.T) string {
t.Helper()
src := t.TempDir()
writeSkillCacheFixture(t, src, "new")
return src
}
cachePath := func(home string) string {
return filepath.Join(home, ".dws", "skills", "multi")
}
assertOldCache := func(t *testing.T, home string) {
t.Helper()
if got := readSkillCacheFixture(t, cachePath(home)); got != "old" {
t.Fatalf("cache content = %q, want old", got)
}
}
t.Run("replace existing cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
if err := refreshSkillCache(home, "multi", newSource(t)); err != nil {
t.Fatalf("refreshSkillCache() error = %v", err)
}
if got := readSkillCacheFixture(t, cachePath(home)); got != "new" {
t.Fatalf("cache content = %q, want new", got)
}
entries, err := os.ReadDir(filepath.Dir(cachePath(home)))
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != "multi" {
t.Fatalf("cache parent entries = %v, want only multi", entries)
}
})
t.Run("publish new cache", func(t *testing.T) {
home := t.TempDir()
if err := refreshSkillCache(home, "multi", newSource(t)); err != nil {
t.Fatalf("refreshSkillCache() error = %v", err)
}
if got := readSkillCacheFixture(t, cachePath(home)); got != "new" {
t.Fatalf("cache content = %q, want new", got)
}
})
t.Run("parent creation failure", func(t *testing.T) {
home := t.TempDir()
failure := errors.New("mkdir denied")
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
})
t.Run("staging creation failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("temp denied")
testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("copy failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("copy denied")
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("cache stat failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("stat denied")
testseam.Swap(t, &upgradeStat, func(string) (os.FileInfo, error) { return nil, failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("new cache publish failure cleans staging", func(t *testing.T) {
home := t.TempDir()
failure := errors.New("rename denied")
testseam.Swap(t, &upgradeRename, func(string, string) error { return failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
entries, err := os.ReadDir(filepath.Dir(cachePath(home)))
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("cache parent entries = %v, want empty", entries)
}
})
t.Run("rollback staging creation failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("rollback temp denied")
original := upgradeMkdirTemp
calls := 0
testseam.Swap(t, &upgradeMkdirTemp, func(dir, pattern string) (string, error) {
calls++
if calls == 2 {
return "", failure
}
return original(dir, pattern)
})
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("rollback preparation failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("remove denied")
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("old cache move failure preserves old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("old move denied")
testseam.Swap(t, &upgradeRename, func(string, string) error { return failure })
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("publish failure restores old cache", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
failure := errors.New("publish denied")
original := upgradeRename
calls := 0
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
calls++
if calls == 2 {
return failure
}
return original(src, dst)
})
if err := refreshSkillCache(home, "multi", newSource(t)); !errors.Is(err, failure) {
t.Fatalf("refreshSkillCache() error = %v, want %v", err, failure)
}
assertOldCache(t, home)
})
t.Run("restore failure reports both errors and retains backup", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
publishFailure := errors.New("publish denied")
restoreFailure := errors.New("restore denied")
original := upgradeRename
calls := 0
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
calls++
switch calls {
case 2:
return publishFailure
case 3:
return restoreFailure
default:
return original(src, dst)
}
})
err := refreshSkillCache(home, "multi", newSource(t))
if !errors.Is(err, publishFailure) || !strings.Contains(err.Error(), restoreFailure.Error()) {
t.Fatalf("refreshSkillCache() error = %v, want publish and restore errors", err)
}
matches, globErr := filepath.Glob(filepath.Join(filepath.Dir(cachePath(home)), ".multi.old-*", "SKILL.md"))
if globErr != nil || len(matches) != 1 {
t.Fatalf("rollback cache matches = %v, err = %v", matches, globErr)
}
if got := readSkillCacheFixture(t, filepath.Dir(matches[0])); got != "old" {
t.Fatalf("rollback cache content = %q, want old", got)
}
})
t.Run("post publish cleanup is best effort", func(t *testing.T) {
home := t.TempDir()
writeSkillCacheFixture(t, cachePath(home), "old")
original := upgradeRemoveAll
calls := 0
testseam.Swap(t, &upgradeRemoveAll, func(path string) error {
calls++
if calls == 2 {
return errors.New("cleanup denied")
}
return original(path)
})
if err := refreshSkillCache(home, "multi", newSource(t)); err != nil {
t.Fatalf("refreshSkillCache() error = %v", err)
}
if got := readSkillCacheFixture(t, cachePath(home)); got != "new" {
t.Fatalf("cache content = %q, want new", got)
}
})
}
@@ -0,0 +1,292 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func seedUpgradeSkill(t *testing.T, dir, content string, managed bool) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
_ = managed
}
func assertUpgradeSkillContent(t *testing.T, dir, want string) {
t.Helper()
got, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
if err != nil {
t.Fatalf("read %s: %v", dir, err)
}
if string(got) != want {
t.Fatalf("%s content = %q, want %q", dir, got, want)
}
}
func assertNoUpgradeStaging(t *testing.T, base string) {
t.Helper()
entries, err := os.ReadDir(base)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-upgrade-") {
t.Fatalf("staging directory leaked after failed transaction: %s", filepath.Join(base, entry.Name()))
}
}
}
func seedMultiUpgradeTarget(t *testing.T) (home, base, multiRoot string, skills []string, skillSet map[string]bool) {
t.Helper()
home = t.TempDir()
base = filepath.Join(home, ".agents", "skills")
seedUpgradeSkill(t, filepath.Join(base, "dws"), "old mono", false)
seedUpgradeSkill(t, filepath.Join(base, "dingtalk-a"), "old a", true)
seedUpgradeSkill(t, filepath.Join(base, "dingtalk-b"), "old b", true)
seedUpgradeSkill(t, filepath.Join(base, "dingtalk-stale"), "old stale", true)
multiRoot = writeMultiBundle(t, t.TempDir(), "dingtalk-a", "dingtalk-b")
skills = []string{"dingtalk-a", "dingtalk-b"}
skillSet = map[string]bool{"dingtalk-a": true, "dingtalk-b": true}
useUpgradeManagedNames(t, "dingtalk-a", "dingtalk-b", "dingtalk-stale")
return home, base, multiRoot, skills, skillSet
}
func assertOriginalMultiUpgradeTarget(t *testing.T, base string) {
t.Helper()
assertUpgradeSkillContent(t, filepath.Join(base, "dws"), "old mono")
assertUpgradeSkillContent(t, filepath.Join(base, "dingtalk-a"), "old a")
assertUpgradeSkillContent(t, filepath.Join(base, "dingtalk-b"), "old b")
assertUpgradeSkillContent(t, filepath.Join(base, "dingtalk-stale"), "old stale")
assertNoUpgradeStaging(t, base)
}
func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing.T) {
failure := errors.New("injected transaction failure")
t.Run("copy failure before backup", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
originalCopy := upgradeCopyDir
testseam.Swap(t, &upgradeCopyDir, func(src, dest string) error {
if filepath.Base(src) == "dingtalk-b" {
return failure
}
return originalCopy(src, dest)
})
if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) {
t.Fatalf("copy failure = %v, want injected failure", err)
}
assertOriginalMultiUpgradeTarget(t, base)
})
t.Run("backup failure restores earlier victims", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
originalRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
if src == filepath.Join(base, "dingtalk-b") {
return failure
}
return originalRename(src, dest)
})
if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) {
t.Fatalf("backup failure = %v, want injected failure", err)
}
assertOriginalMultiUpgradeTarget(t, base)
})
t.Run("publish failure rolls back complete set", func(t *testing.T) {
home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t)
originalRename := upgradeRename
failed := false
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
if !failed && strings.Contains(src, ".dws-upgrade-multi-") && filepath.Base(dest) == "dingtalk-b" {
failed = true
return failure
}
return originalRename(src, dest)
})
if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) {
t.Fatalf("publish failure = %v, want injected failure", err)
}
assertOriginalMultiUpgradeTarget(t, base)
})
}
func TestCrossPlatformCoverageMonoUpgradeTransactionPreservesOldSet(t *testing.T) {
failure := errors.New("injected mono transaction failure")
seed := func(t *testing.T) (home, base, monoRoot string) {
t.Helper()
home = t.TempDir()
base = filepath.Join(home, ".agents", "skills")
seedUpgradeSkill(t, filepath.Join(base, "dws"), "old mono", false)
seedUpgradeSkill(t, filepath.Join(base, "dingtalk-a"), "old multi", true)
useUpgradeManagedNames(t, "dingtalk-a")
monoRoot = t.TempDir()
if err := os.WriteFile(filepath.Join(monoRoot, "SKILL.md"), []byte("new mono"), 0o644); err != nil {
t.Fatal(err)
}
return home, base, monoRoot
}
assertOld := func(t *testing.T, base string) {
t.Helper()
assertUpgradeSkillContent(t, filepath.Join(base, "dws"), "old mono")
assertUpgradeSkillContent(t, filepath.Join(base, "dingtalk-a"), "old multi")
assertNoUpgradeStaging(t, base)
}
t.Run("copy failure before backup", func(t *testing.T) {
home, base, monoRoot := seed(t)
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return failure })
if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) {
t.Fatalf("copy failure = %v, want injected failure", err)
}
assertOld(t, base)
})
t.Run("publish failure rolls back mono and multi", func(t *testing.T) {
home, base, monoRoot := seed(t)
originalRename := upgradeRename
failed := false
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
if !failed && strings.Contains(src, ".dws-upgrade-mono-") && filepath.Base(dest) == "dws" {
failed = true
return failure
}
return originalRename(src, dest)
})
if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) {
t.Fatalf("publish failure = %v, want injected failure", err)
}
assertOld(t, base)
})
}
func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) {
failure := errors.New("injected failure")
restoreFailure := errors.New("injected restore failure")
t.Run("stage setup and cleanup failures", func(t *testing.T) {
destBase := filepath.Join(t.TempDir(), "skills")
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return failure })
if _, _, err := stageSkillSet(destBase, ".stage-", nil); !errors.Is(err, failure) {
t.Fatalf("stage parent failure = %v", err)
}
})
t.Run("stage temp failure", func(t *testing.T) {
testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", failure })
if _, _, err := stageSkillSet(t.TempDir(), ".stage-", nil); !errors.Is(err, failure) {
t.Fatalf("stage temp failure = %v", err)
}
})
t.Run("stage cleanup failure joins copy error", func(t *testing.T) {
destBase := t.TempDir()
src := t.TempDir()
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return failure })
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return restoreFailure })
_, _, err := stageSkillSet(destBase, ".stage-", []skillStageSpec{{src: src, dest: filepath.Join(destBase, "dws")}})
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
t.Fatalf("joined stage cleanup error = %v", err)
}
})
t.Run("duplicate victims are removed once", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "dws")
got := uniqueSkillDirs([]string{path, path})
if len(got) != 1 || got[0] != path {
t.Fatalf("uniqueSkillDirs = %v", got)
}
})
t.Run("restore reports removal and directory failures", func(t *testing.T) {
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure })
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return restoreFailure })
err := restoreSkillSet(
[]string{filepath.Join(t.TempDir(), "published")},
[]backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}},
)
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
t.Fatalf("restore removal/directory error = %v", err)
}
})
t.Run("restore reports rename failure", func(t *testing.T) {
testseam.Swap(t, &upgradeRename, func(string, string) error { return restoreFailure })
err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}})
if !errors.Is(err, restoreFailure) {
t.Fatalf("restore rename error = %v", err)
}
})
t.Run("backup failure joins restore failure", func(t *testing.T) {
home := t.TempDir()
first := filepath.Join(home, "first")
second := filepath.Join(home, "second")
seedUpgradeSkill(t, first, "first", false)
seedUpgradeSkill(t, second, "second", false)
originalRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
switch {
case src == second:
return failure
case strings.Contains(filepath.ToSlash(src), skillBackupSubdir):
return restoreFailure
default:
return originalRename(src, dest)
}
})
_, err := backupSkillSet(home, []string{first, second})
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
t.Fatalf("backup/restore error = %v", err)
}
})
t.Run("publish failure joins restore failure", func(t *testing.T) {
home := t.TempDir()
old := filepath.Join(home, "skills", "dws")
staged := filepath.Join(home, "skills", ".stage", "dws")
seedUpgradeSkill(t, old, "old", false)
seedUpgradeSkill(t, staged, "new", false)
originalRename := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dest string) error {
switch {
case src == staged:
return failure
case strings.Contains(filepath.ToSlash(src), skillBackupSubdir):
return restoreFailure
default:
return originalRename(src, dest)
}
})
err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old})
if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) {
t.Fatalf("publish/restore error = %v", err)
}
})
t.Run("victim scans fail closed", func(t *testing.T) {
testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
base := t.TempDir()
if _, err := monoUpgradeVictims(base, filepath.Join(base, "dws")); !errors.Is(err, failure) {
t.Fatalf("mono victim scan error = %v", err)
}
if _, err := multiUpgradeVictims(base, map[string]bool{}, []string{"dingtalk-a"}); !errors.Is(err, failure) {
t.Fatalf("multi victim scan error = %v", err)
}
if err := publishMultiUpgradeTarget(base, base, base, []string{"dingtalk-a"}, map[string]bool{}); !errors.Is(err, failure) {
t.Fatalf("multi target victim scan error = %v", err)
}
})
}
+82
View File
@@ -0,0 +1,82 @@
package upgrade
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageSkillUpgradeAlwaysRestoresOfficialBundle(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", "")
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"})
testseam.Swap(t, &upgradeNow, func() time.Time { return time.Date(2026, 8, 10, 1, 2, 3, 0, time.UTC) })
base := filepath.Join(home, ".agents", "skills")
for _, name := range []string{"dingtalk-a", "dingtalk-shared"} {
if err := os.MkdirAll(filepath.Join(base, name), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(base, name, "SKILL.md"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := skillstate.Write(home, skillstate.State{OfficialSkills: []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}}); err != nil {
t.Fatal(err)
}
multi := writeMultiBundle(t, t.TempDir(), "dingtalk-a", "dingtalk-b", "dingtalk-c", "dingtalk-shared")
result, err := UpgradeSkillLocationsWithOptions(multi, SkillUpgradeOptions{Version: "1.1.0"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("full refresh = %#v, %v", result, err)
}
for _, name := range []string{"dingtalk-a", "dingtalk-b", "dingtalk-c", "dingtalk-shared"} {
if _, err := os.Stat(filepath.Join(base, name, "SKILL.md")); err != nil {
t.Fatalf("official Skill %s was not restored: %v", name, err)
}
}
state, readable, err := skillstate.Read(home)
wantOfficial := []string{"dingtalk-a", "dingtalk-b", "dingtalk-c", "dingtalk-shared"}
if err != nil || !readable || !reflect.DeepEqual(state.OfficialSkills, wantOfficial) || !reflect.DeepEqual(state.UpdatedSkills, wantOfficial) {
t.Fatalf("state = %#v, %v, %v", state, readable, err)
}
if len(state.ManagedSkills) != len(wantOfficial) {
t.Fatalf("managed provenance = %#v", state.ManagedSkills)
}
for _, provenance := range state.ManagedSkills {
if provenance.Version != "1.1.0" || provenance.Source != skillprovenance.SourceUpgrade || !strings.HasPrefix(provenance.Digest, "sha256:") {
t.Fatalf("provenance = %#v", provenance)
}
}
}
func TestCrossPlatformCoverageFullSkillUpgradeIgnoresOldStateAndReportsWriteFailure(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", "")
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"})
multi := writeMultiBundle(t, t.TempDir(), "dingtalk-a", "dingtalk-b", "dingtalk-shared")
home := withFakeHome(t)
statePath := skillstate.Path(home)
if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(statePath, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocationsWithOptions(multi, SkillUpgradeOptions{})
if err != nil || len(result.Succeeded()) != 1 {
t.Fatalf("corrupt old state should not block full refresh = %#v, %v", result, err)
}
home2 := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil })
testseam.Swap(t, &upgradeWriteSkillState, func(string, skillstate.State) error { return errors.New("denied") })
result, err = UpgradeSkillLocationsWithOptions(multi, SkillUpgradeOptions{})
if err == nil || !strings.Contains(err.Error(), "状态未写入") || len(result.Succeeded()) != 1 {
t.Fatalf("write failure = %#v, %v", result, err)
}
}
+1
View File
@@ -22,6 +22,7 @@ SEMANTIC_PATHS = [
ROOT / "internal" / "shortcut" / "semantic_catalog.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_doc.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_aitable.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_minutes.json",
]
+521 -36
View File
@@ -13,6 +13,7 @@ set -eu
# Environment variables (optional):
# DWS_VERSION — release tag (default: latest)
# DWS_SKILLS_ROOT — base path for agent dirs (default: $PWD)
# DWS_SKILL_MODE — mono | multi (default: multi)
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
# Gitee API instead of GitHub (China mirror)
@@ -25,6 +26,13 @@ VERSION="${DWS_VERSION:-latest}"
SKILL_NAME="dws"
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
DWS_CACHE_ROOT="${DWS_CACHE_ROOT:-$HOME/.dws}"
DWS_STATE_ROOT="${DWS_CONFIG_DIR:-$DWS_CACHE_ROOT}"
MANAGED_SKILL_DIGEST_SCOPE="skill-directory-v1"
SKILL_MODE="$(printf '%s' "${DWS_SKILL_MODE:-multi}" | tr '[:upper:]' '[:lower:]')"
case "$SKILL_MODE" in
mono|multi) ;;
*) printf '❌ Invalid DWS_SKILL_MODE=%s. Use mono or multi.\n' "${DWS_SKILL_MODE:-}" >&2; exit 1 ;;
esac
# ── Helpers ──────────────────────────────────────────────────────────────────
@@ -35,6 +43,227 @@ need_cmd() {
fi
}
# backup_and_remove_skill_dir <dir>
# Moves <dir> into $HOME/.dws/skill-backups/<stamp>/<name> instead of
# destroying it (non-interactive installs cannot confirm, so removals must
# stay reversible). Missing paths are a no-op success. On any backup failure
# the directory is left in place and a non-zero status is returned so callers
# skip that target rather than silently deleting data.
DWS_LAST_SKILL_BACKUP=""
backup_and_remove_skill_dir() {
_bed_dir="$1"
DWS_LAST_SKILL_BACKUP=""
[ -d "$_bed_dir" ] || return 0
_bed_root="${HOME}/.dws/skill-backups"
_bed_stamp="$(date -u +%Y%m%d-%H%M%S)"
_bed_name="$(basename "$_bed_dir")"
_bed_target="$_bed_root/$_bed_stamp/$_bed_name"
_bed_i=1
while [ -e "$_bed_target" ]; do
_bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
_bed_i=$((_bed_i + 1))
if [ "$_bed_i" -gt 1000 ]; then
printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$_bed_dir"
return 1
fi
done
mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || {
printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir"
return 1
}
if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then
DWS_LAST_SKILL_BACKUP="$_bed_target"
printf ' × 已备份并移除 %s → %s\n' "$_bed_dir" "$_bed_target"
return 0
fi
printf ' ⚠️ 备份失败,保留原目录 %s\n' "$_bed_dir"
return 1
}
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
# use it too. Ownership comes from the centralized skills-state.json.
is_managed_multi_skill_dir() {
_managed_dir="$1"
_managed_name="$(basename "$_managed_dir")"
is_legacy_official_multi_skill_name "$_managed_name" && return 0
[ -f "$DWS_STATE_ROOT/skills-state.json" ] || return 1
_managed_json_name="$(json_escape "$_managed_name")"
_managed_compact='"name":"'"$_managed_json_name"'"'
_managed_spaced='"name": "'"$_managed_json_name"'"'
DWS_MANAGED_COMPACT="$_managed_compact" DWS_MANAGED_SPACED="$_managed_spaced" awk '
/^[[:space:]]*"managed_skills"[[:space:]]*:[[:space:]]*\[[[:space:]]*$/ { inside = 1; next }
inside && /^[[:space:]]*\][[:space:]]*,?[[:space:]]*$/ { closed = 1; exit }
inside && (index($0, ENVIRON["DWS_MANAGED_COMPACT"]) || index($0, ENVIRON["DWS_MANAGED_SPACED"])) { found = 1 }
END { exit !(closed && found) }
' "$DWS_STATE_ROOT/skills-state.json"
}
# Frozen exact names shipped before centralized ownership metadata. Never replace this
# with a dingtalk-* prefix check: user/market Skills may use that prefix.
is_legacy_official_multi_skill_name() {
case "$1" in
dingtalk-agoal|dingtalk-aiapp|dingtalk-aisearch|dingtalk-aitable|dingtalk-attendance|dingtalk-calendar|dingtalk-chat|dingtalk-contact|dingtalk-dev|dingtalk-devapp|dingtalk-devdoc|dingtalk-ding|dingtalk-doc|dingtalk-drive|dingtalk-event|dingtalk-hrbrain|dingtalk-live|dingtalk-mail|dingtalk-markdown|dingtalk-minutes|dingtalk-misc|dingtalk-oa|dingtalk-pat|dingtalk-profile|dingtalk-report|dingtalk-shared|dingtalk-sheet|dingtalk-skill|dingtalk-todo|dingtalk-wiki|dws-shared) return 0 ;;
esac
return 1
}
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
sha256_stdin() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 | awk '{print $NF}'
else
return 1
fi
}
digest_skill_dir() {
_digest_dir="$1"
_digest="$({
find "$_digest_dir" -type f -print | LC_ALL=C sort | while IFS= read -r _digest_file; do
_digest_rel="${_digest_file#"$_digest_dir"/}"
printf '%s\0' "$_digest_rel"
cat "$_digest_file"
printf '\0'
done
} | sha256_stdin)" || return 1
printf 'sha256:%s' "$_digest"
}
write_skills_state() {
_state_multi="$1"
_state_source="$2"
mkdir -p "$DWS_STATE_ROOT" || return 1
_state_tmp="$(mktemp "$DWS_STATE_ROOT/.skills-state.XXXXXX")" || return 1
_state_version="$(json_escape "$VERSION")"
_state_names=""
for _state_dir in "$_state_multi"/*/; do
[ -f "${_state_dir}SKILL.md" ] || continue
_state_names="${_state_names}$(basename "$_state_dir")\n"
done
{
printf '{\n "version": "%s",\n' "$_state_version"
for _state_field in official_skills updated_skills; do
printf ' "%s": [' "$_state_field"
_state_first=1
printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do
[ -n "$_state_name" ] || continue
[ "$_state_first" -eq 1 ] || printf ', '
printf '"%s"' "$(json_escape "$_state_name")"
_state_first=0
done
printf '],\n'
done
printf ' "managed_skills": [\n'
_state_first=1
printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do
[ -n "$_state_name" ] || continue
_state_digest="$(digest_skill_dir "$_state_multi/$_state_name")" || exit 1
[ "$_state_first" -eq 1 ] || printf ',\n'
printf ' {"name":"%s","version":"%s","source":"%s","digest":"%s","digest_scope":"%s"}' "$(json_escape "$_state_name")" "$_state_version" "$_state_source" "$_state_digest" "$MANAGED_SKILL_DIGEST_SCOPE"
_state_first=0
done
printf '\n ],\n "updated_at": "%s"\n}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$_state_tmp" || { rm -f "$_state_tmp"; return 1; }
mv "$_state_tmp" "$DWS_STATE_ROOT/skills-state.json"
}
# backup_and_record_skill_dir <victim> <manifest>
# Records exact original/backup pairs so a multi-set transaction can restore
# earlier moves when any later backup or publication fails.
backup_and_record_skill_dir() {
_bars_victim="$1"
_bars_manifest="$2"
backup_and_remove_skill_dir "$_bars_victim" || return 1
if [ -n "$DWS_LAST_SKILL_BACKUP" ]; then
if ! printf '%s\n%s\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP" >> "$_bars_manifest"; then
mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || printf ' ⚠️ 备份记录失败且无法自动恢复: %s(备份位于 %s)\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP"
return 1
fi
fi
}
# restore_multi_skill_set <published-manifest> <backup-manifest>
# Removes partial new publications, then restores every old directory from
# its exact backup path. Paths containing newlines are outside the supported
# installer path contract; spaces are preserved.
restore_multi_skill_set() {
_rms_published="$1"
_rms_backups="$2"
_rms_ok=1
if [ -f "$_rms_published" ]; then
while IFS= read -r _rms_dest; do
[ -n "$_rms_dest" ] || continue
rm -rf "$_rms_dest" || _rms_ok=0
done < "$_rms_published"
fi
if [ -f "$_rms_backups" ]; then
while IFS= read -r _rms_original && IFS= read -r _rms_backup; do
[ -n "$_rms_backup" ] || continue
if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup"
_rms_ok=0
fi
done < "$_rms_backups"
fi
[ "$_rms_ok" -eq 1 ]
}
# publish_skill_cache <source> <cache-dir>
# Stages a complete sibling cache before publishing it. Any copy or publish
# failure leaves the previous cache in place (or in the reported recovery dir
# when even restoration fails).
publish_skill_cache() {
_psc_src="$1"
_psc_cache="$2"
_psc_parent="$(dirname "$_psc_cache")"
_psc_name="$(basename "$_psc_cache")"
_psc_stage=""
_psc_old=""
mkdir -p "$_psc_parent" || return 1
_psc_stage="$(mktemp -d "$_psc_parent/.${_psc_name}.tmp.XXXXXX")" || return 1
if ! cp -R "$_psc_src/." "$_psc_stage/" 2>/dev/null && \
! cp -r "$_psc_src/." "$_psc_stage/" 2>/dev/null; then
rm -rf "$_psc_stage"
return 1
fi
if [ -e "$_psc_cache" ]; then
_psc_old="$(mktemp -d "$_psc_parent/.${_psc_name}.old.XXXXXX")" || {
rm -rf "$_psc_stage"
return 1
}
rmdir "$_psc_old" || {
rm -rf "$_psc_stage" "$_psc_old"
return 1
}
if ! mv "$_psc_cache" "$_psc_old"; then
rm -rf "$_psc_stage"
return 1
fi
fi
if mv "$_psc_stage" "$_psc_cache"; then
if [ -n "$_psc_old" ] && ! rm -rf "$_psc_old"; then
printf ' ⚠️ 新 Skill 缓存已生效,但旧缓存清理失败: %s\n' "$_psc_old"
fi
return 0
fi
rm -rf "$_psc_stage"
if [ -n "$_psc_old" ] && ! mv "$_psc_old" "$_psc_cache"; then
printf ' ⚠️ Skill 缓存发布失败,原缓存保留在 %s\n' "$_psc_old"
fi
return 1
}
# Fetch a Gitee API endpoint, retrying transient 502/503 from Gitee's gateway.
gitee_api() {
_url="$1"
@@ -114,11 +343,17 @@ _copy_skill_summary() {
_label="$3"
if [ -d "$_dest" ]; then
rm -rf "$_dest"
backup_and_remove_skill_dir "$_dest" || {
printf ' ⚠️ 跳过 %s(保留原目录)\n' "$_dest"
return 1
}
fi
mkdir -p "$_dest"
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
mkdir -p "$_dest" || return 1
if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then
printf ' ⚠️ Skill 复制失败,目标未计为安装成功: %s\n' "$_dest"
return 1
fi
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
printf ' ✅ Skills → %s (%s files)\n' "$_label" "$file_count"
@@ -131,11 +366,17 @@ _copy_skill() {
_label="$3"
if [ -d "$_dest" ]; then
rm -rf "$_dest"
backup_and_remove_skill_dir "$_dest" || {
printf ' ⚠️ 跳过 %s(保留原目录)\n' "$_dest"
return 1
}
fi
mkdir -p "$_dest"
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
mkdir -p "$_dest" || return 1
if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then
printf ' ⚠️ Skill 复制失败,目标未计为安装成功: %s\n' "$_dest"
return 1
fi
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
printf ' ✅ Skills → %s (%s files)\n' "$_label" "$file_count"
@@ -151,11 +392,29 @@ _copy_skill() {
done
}
# Same semantics as build/npm/install.js installSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
install_skills_to_root() {
skill_src="$1"
# multi_tree_has_skills returns 0 only when the given multi bundle directory
# contains at least one product skill (a subdirectory with a SKILL.md). An
# empty or corrupt multi/ tree must never select the multi branch: installing
# it would delete existing dws/ + dingtalk-* skills and lay down nothing.
# (Go bundleSkillNames and install.js multiTreeHasSkills guard the same way.)
multi_tree_has_skills() {
_dir="$1"
[ -d "$_dir" ] || return 1
for _sub in "$_dir"/*/; do
if [ -f "${_sub}SKILL.md" ]; then
return 0
fi
done
return 1
}
# Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
install_multi_skills_to_root() {
multi_src="$1"
root="$2"
installed=0
attempted=0
failed=0
idx=0
for agent_dir in \
".agents/skills" \
@@ -181,28 +440,242 @@ install_skills_to_root() {
idx=$((idx + 1))
continue
fi
dest="$base_dir/$SKILL_NAME"
attempted=$((attempted + 1))
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir"
fi
idx=$((idx + 1))
done
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
installed=$((installed + 1))
fi
if [ "$installed" -eq 0 ]; then
printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'
return 1
fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装失败\n' "$failed"
return 1
fi
write_skills_state "$multi_src" "install-skills.sh" || return 1
}
_install_multi_to_base() {
_msrc="$1"
_base="$2"
_root="$3"
_agent_dir="$4"
mkdir -p "$_base" || return 1
# Build the complete replacement set before moving any Agent-visible
# directory. The manifests remain inside the private staging directory.
_ms_stage="$(mktemp -d "$_base/.dws-multi-set.XXXXXX")" || return 1
_ms_backups="$_ms_stage/.backups"
_ms_published="$_ms_stage/.published"
: > "$_ms_backups" || { rm -rf "$_ms_stage"; return 1; }
: > "$_ms_published" || { rm -rf "$_ms_stage"; return 1; }
for skill_dir in "$_msrc"/*/; do
[ -f "${skill_dir}SKILL.md" ] || continue
_name="$(basename "$skill_dir")"
_ms_staged_skill="$_ms_stage/$_name"
mkdir -p "$_ms_staged_skill" || { rm -rf "$_ms_stage"; return 1; }
if ! cp -R "$skill_dir/." "$_ms_staged_skill/" 2>/dev/null && ! cp -r "$skill_dir/." "$_ms_staged_skill/"; then
rm -rf "$_ms_stage"
return 1
fi
done
# Mutual exclusion: back up + remove the mono leftover.
if ! backup_and_record_skill_dir "$_base/$SKILL_NAME" "$_ms_backups"; then
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
fi
# Back up + remove stale, proven DWS-managed skills not in the new bundle.
# Never infer ownership from the dingtalk-* prefix alone.
for existing in "$_base"/*/; do
[ -d "$existing" ] || continue
_name="$(basename "$existing")"
if is_managed_multi_skill_dir "$existing" && [ ! -f "$_msrc/$_name/SKILL.md" ]; then
if ! backup_and_record_skill_dir "$existing" "$_ms_backups"; then
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
fi
fi
done
if [ -d "$_base/dws-shared" ] && [ ! -f "$_msrc/dws-shared/SKILL.md" ]; then
if ! backup_and_record_skill_dir "$_base/dws-shared" "$_ms_backups"; then
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
fi
fi
# Back up all replaced skills as one logical operation. Any failure restores
# every earlier move before this target reports failure.
for skill_dir in "$_msrc"/*/; do
[ -f "${skill_dir}SKILL.md" ] || continue
_name="$(basename "$skill_dir")"
_dest="$_base/$_name"
if ! backup_and_record_skill_dir "$_dest" "$_ms_backups"; then
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
fi
done
_count=0
for skill_dir in "$_msrc"/*/; do
[ -f "${skill_dir}SKILL.md" ] || continue
_name="$(basename "$skill_dir")"
_dest="$_base/$_name"
printf '%s\n' "$_dest" >> "$_ms_published" || {
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
}
if ! mv "$_ms_stage/$_name" "$_dest"; then
printf ' ⚠️ multi Skill 集合发布失败,正在恢复原集合: %s\n' "$_dest"
restore_multi_skill_set "$_ms_published" "$_ms_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n'
rm -rf "$_ms_stage"
return 1
fi
_count=$((_count + 1))
done
rm -rf "$_ms_stage" || return 1
if [ "$_root" = "$HOME" ]; then
_label="~/$_agent_dir/"
else
_label="$_root/$_agent_dir/"
fi
printf ' ✅ Skills → %s (%s product skills)\n' "$_label" "$_count"
}
# Publish mono and all mutually-exclusive managed multi directories as one
# transaction. The complete dws/ tree is staged before any visible directory
# moves; any later backup or publish failure restores the exact old set.
_install_mono_to_base() {
_mono_src="$1"
_mono_base="$2"
_mono_label="$3"
mkdir -p "$_mono_base" || return 1
_mono_stage="$(mktemp -d "$_mono_base/.dws-mono-set.XXXXXX")" || return 1
_mono_backups="$_mono_stage/.backups"
_mono_published="$_mono_stage/.published"
: > "$_mono_backups" || { rm -rf "$_mono_stage"; return 1; }
: > "$_mono_published" || { rm -rf "$_mono_stage"; return 1; }
mkdir -p "$_mono_stage/$SKILL_NAME" || { rm -rf "$_mono_stage"; return 1; }
if ! cp -R "$_mono_src/." "$_mono_stage/$SKILL_NAME/" 2>/dev/null && ! cp -r "$_mono_src/." "$_mono_stage/$SKILL_NAME/"; then
rm -rf "$_mono_stage"
return 1
fi
if ! backup_and_record_skill_dir "$_mono_base/$SKILL_NAME" "$_mono_backups"; then
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
rm -rf "$_mono_stage"
return 1
fi
for existing in "$_mono_base"/*/; do
[ -d "$existing" ] || continue
is_managed_multi_skill_dir "$existing" || continue
if ! backup_and_record_skill_dir "$existing" "$_mono_backups"; then
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
rm -rf "$_mono_stage"
return 1
fi
done
_mono_dest="$_mono_base/$SKILL_NAME"
printf '%s\n' "$_mono_dest" >> "$_mono_published" || {
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
rm -rf "$_mono_stage"
return 1
}
if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then
printf ' ⚠️ mono Skill 集合发布失败,正在恢复原集合: %s\n' "$_mono_dest"
restore_multi_skill_set "$_mono_published" "$_mono_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n'
rm -rf "$_mono_stage"
return 1
fi
rm -rf "$_mono_stage" || return 1
_mono_count="$(find "$_mono_dest" -type f | wc -l | tr -d ' ')"
printf ' ✅ Skills → %s (%s files)\n' "$_mono_label" "$_mono_count"
}
# Same semantics as build/npm/install.js installSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
install_skills_to_root() {
skill_src="$1"
root="$2"
installed=0
attempted=0
failed=0
idx=0
for agent_dir in \
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".github/skills" \
".windsurf/skills" \
".augment/skills" \
".cline/skills" \
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
continue
fi
attempted=$((attempted + 1))
if [ "$root" = "$HOME" ]; then
label="~/$agent_dir/$SKILL_NAME"
else
label="$root/$agent_dir/$SKILL_NAME"
fi
if [ "$installed" -eq 0 ]; then
_copy_skill "$skill_src" "$dest" "$label"
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
installed=$((installed + 1))
else
_copy_skill_summary "$skill_src" "$dest" "$label"
failed=$((failed + 1))
fi
installed=$((installed + 1))
idx=$((idx + 1))
done
if [ "$installed" -eq 0 ]; then
if [ "$attempted" -eq 0 ]; then
if [ "$root" = "$HOME" ]; then
flabel="~/.agents/skills/$SKILL_NAME"
else
flabel="$root/.agents/skills/$SKILL_NAME"
fi
_copy_skill "$skill_src" "$root/.agents/skills/$SKILL_NAME" "$flabel"
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
if [ "$installed" -eq 0 ]; then
printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'
return 1
fi
if [ "$failed" -gt 0 ]; then
printf ' ⚠️ 有 %s 个 Agent 目标安装 mono Skill 失败\n' "$failed"
return 1
fi
rm -f "$DWS_STATE_ROOT/skills-state.json"
}
# ── Main ─────────────────────────────────────────────────────────────────────
@@ -236,31 +709,43 @@ main() {
SKILL_SRC="$TMPDIR_WORK/extracted/${SKILL_NAME}"
fi
if [ ! -f "$SKILL_SRC/SKILL.md" ]; then
printf ' ❌ Skill source not found in release asset\n' >&2
exit 1
fi
printf '\n'
printf ' Installing under root: %s\n' "$ROOT"
install_skills_to_root "$SKILL_SRC" "$ROOT"
printf ' Installing under root: %s (mode: %s)\n' "$ROOT" "$SKILL_MODE"
# Multi first: a release may ship only the multi/ tree without the root
# mono copy, so the mono SKILL.md gate must never block a multi install.
# An empty/corrupt multi/ tree (no */SKILL.md) falls back to mono with a
# warning — installing it would wipe existing skills and lay down nothing.
if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$TMPDIR_WORK/extracted/multi"; then
install_multi_skills_to_root "$TMPDIR_WORK/extracted/multi" "$ROOT"
else
if [ "$SKILL_MODE" = "multi" ]; then
printf ' ⚠️ Multi skill tree not found or empty in release asset; falling back to mono.\n'
fi
if [ ! -f "$SKILL_SRC/SKILL.md" ]; then
printf ' ❌ Skill source not found in release asset\n' >&2
exit 1
fi
install_skills_to_root "$SKILL_SRC" "$ROOT"
fi
# Cache multi/ (and a mono copy) under ~/.dws/skills so that subsequent
# `dws skill setup --mode multi|mono` invocations can find a source.
if [ -d "$TMPDIR_WORK/extracted/multi" ]; then
# `dws skill setup --mode multi|mono` invocations can find a source. An
# empty/corrupt tree must never wipe a previously good cache.
if multi_tree_has_skills "$TMPDIR_WORK/extracted/multi"; then
cache_dir="${DWS_CACHE_ROOT}/skills/multi"
rm -rf "$cache_dir"
mkdir -p "$cache_dir"
cp -R "$TMPDIR_WORK/extracted/multi/"* "$cache_dir/" 2>/dev/null || \
cp -r "$TMPDIR_WORK/extracted/multi/"* "$cache_dir/" 2>/dev/null || true
file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')"
printf ' ✅ Cached multi skills → %s (%s files)\n' "$cache_dir" "$file_count"
if publish_skill_cache "$TMPDIR_WORK/extracted/multi" "$cache_dir"; then
file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')"
printf ' ✅ Cached multi skills → %s (%s files)\n' "$cache_dir" "$file_count"
else
printf ' ⚠️ Multi Skill 缓存刷新失败,未覆盖原缓存: %s\n' "$cache_dir"
fi
fi
if [ -f "$SKILL_SRC/SKILL.md" ]; then
mono_cache="${DWS_CACHE_ROOT}/skills/mono"
if ! publish_skill_cache "$SKILL_SRC" "$mono_cache"; then
printf ' ⚠️ Mono Skill 缓存刷新失败,未覆盖原缓存: %s\n' "$mono_cache"
fi
fi
mono_cache="${DWS_CACHE_ROOT}/skills/mono"
rm -rf "$mono_cache"
mkdir -p "$mono_cache"
cp -R "$SKILL_SRC/"* "$mono_cache/" 2>/dev/null || \
cp -r "$SKILL_SRC/"* "$mono_cache/" 2>/dev/null || true
printf '\n'
printf ' 📖 Skill includes:\n'
+543 -77
View File
@@ -17,7 +17,7 @@
# DWS_ARCH — architecture override (amd64 or arm64)
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else multi)
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
# Gitee API instead of GitHub (China mirror)
#
@@ -39,6 +39,18 @@ $NoSkills = $env:DWS_NO_SKILLS -eq "1"
$SkillsOnly = $env:DWS_SKILLS_ONLY -eq "1"
$SkillName = "dws"
$SkillMode = ""
$SkillStateRoot = if ($env:DWS_CONFIG_DIR) { $env:DWS_CONFIG_DIR } else { Join-Path $HOME ".dws" }
$ManagedSkillDigestScope = "skill-directory-v1"
$LegacyOfficialMultiSkills = @(
"dingtalk-agoal", "dingtalk-aiapp", "dingtalk-aisearch", "dingtalk-aitable",
"dingtalk-attendance", "dingtalk-calendar", "dingtalk-chat", "dingtalk-contact",
"dingtalk-dev", "dingtalk-devapp", "dingtalk-devdoc", "dingtalk-ding",
"dingtalk-doc", "dingtalk-drive", "dingtalk-event", "dingtalk-hrbrain",
"dingtalk-live", "dingtalk-mail", "dingtalk-markdown", "dingtalk-minutes",
"dingtalk-misc", "dingtalk-oa", "dingtalk-pat", "dingtalk-profile",
"dingtalk-report", "dingtalk-shared", "dingtalk-sheet", "dingtalk-skill",
"dingtalk-todo", "dingtalk-wiki", "dws-shared"
)
# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS).
$AgentDirs = @(
@@ -73,6 +85,105 @@ function Write-Err {
exit 1
}
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
# use it too. Ownership comes from the centralized skills-state.json.
function Test-ManagedMultiSkillDir {
param([string]$Dir)
$name = Split-Path $Dir -Leaf
if ($LegacyOfficialMultiSkills -contains $name) { return $true }
$statePath = Join-Path $SkillStateRoot "skills-state.json"
if (!(Test-Path $statePath -PathType Leaf)) { return $false }
try {
$state = Get-Content -Path $statePath -Raw | ConvertFrom-Json -ErrorAction Stop
return @($state.managed_skills | Where-Object { $_.name -eq $name }).Count -gt 0
} catch {
return $false
}
}
function Get-SkillDirectoryDigest {
param([string]$Dir)
$root = [System.IO.Path]::GetFullPath($Dir).TrimEnd([char[]]@('\', '/'))
$files = @(
Get-ChildItem -Path $root -Recurse -File -Force |
ForEach-Object {
[pscustomobject]@{
Relative = $_.FullName.Substring($root.Length).TrimStart([char[]]@('\', '/')).Replace('\', '/')
FullName = $_.FullName
}
} |
Sort-Object -Property Relative
)
$stream = [System.IO.MemoryStream]::new()
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
foreach ($file in $files) {
$pathBytes = [System.Text.Encoding]::UTF8.GetBytes($file.Relative)
$stream.Write($pathBytes, 0, $pathBytes.Length)
$stream.WriteByte(0)
$content = [System.IO.File]::ReadAllBytes($file.FullName)
$stream.Write($content, 0, $content.Length)
$stream.WriteByte(0)
}
$hash = $sha.ComputeHash($stream.ToArray())
return "sha256:" + ([System.BitConverter]::ToString($hash).Replace("-", "").ToLowerInvariant())
} finally {
$sha.Dispose()
$stream.Dispose()
}
}
function Write-SkillsState {
param([string]$MultiSrc)
$stateDir = $SkillStateRoot
New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
$versionValue = if ([string]::IsNullOrWhiteSpace($Version)) { "unknown" } else { $Version }
$skills = @(Get-ChildItem -Path $MultiSrc -Directory | Where-Object {
Test-Path (Join-Path $_.FullName "SKILL.md")
} | Sort-Object -Property Name)
$names = @($skills | ForEach-Object { $_.Name })
$managed = @($skills | ForEach-Object {
[ordered]@{
name = $_.Name
version = $versionValue
source = "install.ps1"
digest = Get-SkillDirectoryDigest -Dir $_.FullName
digest_scope = $ManagedSkillDigestScope
}
})
$state = [ordered]@{
version = $versionValue
official_skills = $names
updated_skills = $names
managed_skills = $managed
updated_at = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
}
$statePath = Join-Path $stateDir "skills-state.json"
$tempPath = Join-Path $stateDir (".skills-state-" + [guid]::NewGuid().ToString("N") + ".tmp")
$backupPath = Join-Path $stateDir (".skills-state-" + [guid]::NewGuid().ToString("N") + ".previous")
try {
[System.IO.File]::WriteAllText($tempPath, (($state | ConvertTo-Json -Depth 5) + "`n"), [System.Text.UTF8Encoding]::new($false))
if (Test-Path $statePath -PathType Leaf) {
[System.IO.File]::Replace($tempPath, $statePath, $backupPath, $true)
Remove-Item -LiteralPath $backupPath -Force -ErrorAction SilentlyContinue
} else {
Move-Item -LiteralPath $tempPath -Destination $statePath
}
} finally {
if (Test-Path $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }
}
}
# Central move seam for transactional Skill publication. Tests replace this
# function to inject backup/publish failures without relying on ACL behavior.
function Move-SkillPath {
param(
[string]$Source,
[string]$Destination
)
Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop
}
function Get-Arch {
# Allow manual override via environment variable
if ($env:DWS_ARCH) {
@@ -230,12 +341,130 @@ function Copy-DirRecursive {
return $count
}
function Publish-SkillCache {
param([string]$Source, [string]$CacheDir)
$cacheParent = Split-Path $CacheDir -Parent
$cacheName = Split-Path $CacheDir -Leaf
New-Item -ItemType Directory -Path $cacheParent -Force -ErrorAction Stop | Out-Null
$stagedDir = Join-Path $cacheParent ".$cacheName.tmp-$([Guid]::NewGuid().ToString('N'))"
$rollbackDir = ""
$published = $false
New-Item -ItemType Directory -Path $stagedDir -Force -ErrorAction Stop | Out-Null
try {
$count = Copy-DirRecursive -Source $Source -Destination $stagedDir
if (Test-Path $CacheDir) {
$rollbackDir = Join-Path $cacheParent ".$cacheName.old-$([Guid]::NewGuid().ToString('N'))"
Move-Item -Path $CacheDir -Destination $rollbackDir -ErrorAction Stop
}
try {
Move-Item -Path $stagedDir -Destination $CacheDir -ErrorAction Stop
$published = $true
} catch {
$publishError = $_
if ($rollbackDir) {
try {
Move-Item -Path $rollbackDir -Destination $CacheDir -ErrorAction Stop
$rollbackDir = ""
} catch {
throw "Skill 缓存发布失败: $publishError;原缓存恢复也失败,恢复目录: $rollbackDir;错误: $_"
}
}
throw $publishError
}
if ($rollbackDir -and (Test-Path $rollbackDir)) {
Remove-Item -Path $rollbackDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path $rollbackDir) {
Write-Say "⚠️ 新缓存已生效,但旧缓存清理失败: $rollbackDir"
}
$rollbackDir = ""
}
return $count
} finally {
if (!$published -and (Test-Path $stagedDir)) {
Remove-Item -Path $stagedDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
# Backup-SkillDir moves $Dir into $HOME\.dws\skill-backups\<stamp>\<name>
# instead of destroying it (non-interactive installs cannot confirm, so
# removals must stay reversible). Missing paths are a no-op success. On any
# backup failure the directory is left in place and $false is returned so
# callers skip that target rather than silently deleting data.
function Backup-SkillDir {
param(
[string]$Dir,
[ref]$BackupPath
)
if ($null -ne $BackupPath) { $BackupPath.Value = "" }
if (!(Test-Path $Dir -PathType Container)) { return $true }
$backupRoot = Join-Path $HOME ".dws\skill-backups"
$stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss")
$name = Split-Path $Dir -Leaf
$target = Join-Path (Join-Path $backupRoot $stamp) $name
$i = 1
while (Test-Path $target) {
$target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name
$i++
if ($i -gt 1000) {
Write-Say "⚠️ 备份目录冲突,保留原目录 $Dir"
return $false
}
}
try {
New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null
Move-SkillPath -Source $Dir -Destination $target
} catch {
Write-Say "⚠️ 备份失败,保留原目录 $Dir"
return $false
}
if ($null -ne $BackupPath) { $BackupPath.Value = $target }
Write-Say " × 已备份并移除 $Dir → $target"
return $true
}
function Restore-MultiSkillSet {
param(
[array]$Published,
[array]$Backups
)
$ok = $true
for ($i = $Published.Count - 1; $i -ge 0; $i--) {
try {
if (Test-Path $Published[$i]) {
Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop
}
} catch {
Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_"
$ok = $false
}
}
for ($i = $Backups.Count - 1; $i -ge 0; $i--) {
$item = $Backups[$i]
try {
if (Test-Path $item.Original) {
throw "恢复目标仍存在"
}
New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null
Move-SkillPath -Source $item.Backup -Destination $item.Original
} catch {
Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_"
$ok = $false
}
}
return $ok
}
function Copy-SkillToDir {
param([string]$SkillSrc, [string]$Dest, [string]$Label)
# Remove existing installation
if (Test-Path $Dest) {
Remove-Item -Path $Dest -Recurse -Force
# Refreshing an existing skill: back it up first; on backup failure keep
# the user's copy and skip this target.
if (!(Backup-SkillDir -Dir $Dest)) {
Write-Say "⚠️ 跳过 $Dest(保留原目录)"
return $false
}
$fileCount = Copy-DirRecursive -Source $SkillSrc -Destination $Dest
@@ -250,17 +479,20 @@ function Copy-SkillToDir {
Write-Say " 📄 $($_.Name)"
}
}
return $true
}
function Copy-SkillToDirSummary {
param([string]$SkillSrc, [string]$Dest, [string]$Label)
if (Test-Path $Dest) {
Remove-Item -Path $Dest -Recurse -Force
if (!(Backup-SkillDir -Dir $Dest)) {
Write-Say "⚠️ 跳过 $Dest(保留原目录)"
return $false
}
$fileCount = Copy-DirRecursive -Source $SkillSrc -Destination $Dest
Write-Say "✅ Skills → $Label ($fileCount files)"
return $true
}
function Resolve-SourceRoot {
@@ -288,8 +520,8 @@ function Write-Banner {
#
# Priority (highest first):
# 1. DWS_SKILL_MODE env var (mono | multi, case-insensitive)
# 2. Interactive prompt when both stdin and stdout are TTYs (default: mono)
# 3. Fallback: mono (non-TTY without env var, e.g. irm | iex)
# 2. Interactive prompt when both stdin and stdout are TTYs (default: multi)
# 3. Fallback: multi (non-TTY without env var, e.g. irm | iex)
function Resolve-SkillMode {
if ($env:DWS_SKILL_MODE) {
$normalized = $env:DWS_SKILL_MODE.ToLower()
@@ -311,33 +543,25 @@ function Resolve-SkillMode {
if ($isInteractive) {
Write-Host ""
Write-Say "Select skill installation mode:"
Write-Say " 1) mono — install one bundled dws skill (stable / recommended)"
Write-Say " 2) multi — split each product into its own skill (run 'dws skill setup --mode multi' afterwards)"
Write-Say " 1) multi (default) — split each product into its own skill (dingtalk-*)"
Write-Say " 2) mono — install one bundled dws skill (legacy)"
$choice = Read-Host " Choice [1]"
switch ($choice) {
"" { $script:SkillMode = "mono" }
"1" { $script:SkillMode = "mono" }
"mono" { $script:SkillMode = "mono" }
"2" { $script:SkillMode = "multi" }
"" { $script:SkillMode = "multi" }
"1" { $script:SkillMode = "multi" }
"multi" { $script:SkillMode = "multi" }
"2" { $script:SkillMode = "mono" }
"mono" { $script:SkillMode = "mono" }
default {
Write-Say "Unrecognized choice '$choice', defaulting to mono."
$script:SkillMode = "mono"
Write-Say "Unrecognized choice '$choice', defaulting to multi."
$script:SkillMode = "multi"
}
}
Write-Say "Skill mode: $SkillMode"
return
}
$script:SkillMode = "mono"
}
function Write-MultiModeNotice {
Write-Say ""
Write-Say "Skill mode: multi — automatic skill install skipped."
Write-Say " To install split skills, run:"
Write-Say " $BinName skill setup --mode multi"
Write-Say " (One skill per product family; requires the dws binary installed above.)"
$script:SkillMode = "multi"
}
# ── Install Binary ───────────────────────────────────────────────────────────
@@ -422,17 +646,30 @@ function Install-SkillsLocal {
$skillSrc = Join-Path (Join-Path $Root "skills") "mono"
$multiSrc = Join-Path (Join-Path $Root "skills") "multi"
if (!(Test-Path $skillSrc)) {
Write-Say "⚠️ Local skills directory not found: $skillSrc"
Write-Say " Skipping skills installation."
return
if ($SkillMode -eq "multi" -and (Test-MultiTreeHasSkills $multiSrc)) {
Write-Say ""
Write-Say "📦 Installing agent skills (multi) from local source: $multiSrc"
if (!(Install-MultiSkillsToHomes -MultiSrc $multiSrc -Root $HOME)) {
throw "multi Skill installation failed"
}
} else {
if ($SkillMode -eq "multi") {
Write-Say "⚠️ multi skill tree not found or empty at $multiSrc; falling back to mono."
}
if (!(Test-Path $skillSrc)) {
Write-Say "⚠️ Local skills directory not found: $skillSrc"
Write-Say " Skipping skills installation."
return
}
Write-Say ""
Write-Say "📦 Installing agent skills from local source: $skillSrc"
if (!(Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME)) {
throw "mono Skill installation failed"
}
}
Write-Say ""
Write-Say "📦 Installing agent skills from local source: $skillSrc"
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
if (Test-Path $multiSrc) {
Cache-MultiSkills -Source $multiSrc
}
@@ -445,28 +682,93 @@ function Install-SkillsLocal {
function Cache-MultiSkills {
param([string]$Source)
if (!(Test-Path $Source)) { return }
# Never let an empty/corrupt multi\ tree wipe a previously good cache.
if (!(Test-MultiTreeHasSkills $Source)) { return }
$cacheDir = Join-Path $HOME ".dws\skills\multi"
if (Test-Path $cacheDir) {
Remove-Item -Path $cacheDir -Recurse -Force
try {
$count = Publish-SkillCache -Source $Source -CacheDir $cacheDir
Write-Say "✅ Cached multi skills → $cacheDir ($count files)"
} catch {
Write-Say "⚠️ Multi Skill 缓存刷新失败,未覆盖原缓存: $cacheDir ($_)"
}
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
$count = Copy-DirRecursive -Source $Source -Destination $cacheDir
Write-Say "✅ Cached multi skills → $cacheDir ($count files)"
}
function Cache-MonoSkills {
param([string]$Source)
if (!(Test-Path $Source)) { return }
# Only refresh when the new bundle actually carries a mono tree — a
# multi-only bundle must never wipe a previously good mono cache.
if (!(Test-Path (Join-Path $Source "SKILL.md"))) { return }
$cacheDir = Join-Path $HOME ".dws\skills\mono"
if (Test-Path $cacheDir) {
Remove-Item -Path $cacheDir -Recurse -Force
try {
Publish-SkillCache -Source $Source -CacheDir $cacheDir | Out-Null
} catch {
Write-Say "⚠️ Mono Skill 缓存刷新失败,未覆盖原缓存: $cacheDir ($_)"
}
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
Copy-DirRecursive -Source $Source -Destination $cacheDir | Out-Null
}
function Install-MonoToBase {
param(
[string]$SkillSrc,
[string]$BaseDir,
[string]$Label
)
if (!(Test-Path $BaseDir)) {
New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null
}
$stageRoot = Join-Path $BaseDir (".dws-mono-set-" + [guid]::NewGuid().ToString("N"))
$stagedSkill = Join-Path $stageRoot $SkillName
$dest = Join-Path $BaseDir $SkillName
$backups = @()
$published = @()
try {
# Stage the complete mono tree before moving any Agent-visible
# directory, including every mutually-exclusive managed multi Skill.
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
Copy-DirRecursive -Source $SkillSrc -Destination $stagedSkill | Out-Null
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add($dest)
foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -ErrorAction SilentlyContinue) {
if ($existing.FullName -eq $stageRoot) { continue }
if (Test-ManagedMultiSkillDir -Dir $existing.FullName) {
$victims.Add($existing.FullName)
}
}
$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 }
}
}
$published += $dest
Move-SkillPath -Source $stagedSkill -Destination $dest
} catch {
$transactionError = $_
if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) {
Write-Say "⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
}
Write-Say "⚠️ mono Skill 集合发布失败,目标已回滚: $BaseDir ($transactionError)"
return $false
} finally {
if (Test-Path $stageRoot) {
Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
$fileCount = (Get-ChildItem -Path $dest -Recurse -File).Count
Write-Say "✅ Skills → $Label ($fileCount files)"
return $true
}
function Install-SkillsToHomes {
@@ -476,6 +778,8 @@ function Install-SkillsToHomes {
)
$installed = 0
$attempted = 0
$failed = 0
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
$agentDir = $AgentDirs[$i]
$baseDir = Join-Path $Root $agentDir
@@ -483,28 +787,187 @@ function Install-SkillsToHomes {
if ($i -gt 0 -and !(Test-Path $parentGate)) {
continue
}
$dest = Join-Path $baseDir $SkillName
$attempted++
if ($Root -eq $HOME) {
$label = "~\$agentDir\$SkillName"
} else {
$label = Join-Path $Root (Join-Path $agentDir $SkillName)
}
if ($installed -eq 0) {
Copy-SkillToDir -SkillSrc $SkillSrc -Dest $dest -Label $label
$copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label
if ($copied) {
$installed++
} else {
Copy-SkillToDirSummary -SkillSrc $SkillSrc -Dest $dest -Label $label
$failed++
}
$installed++
}
if ($installed -eq 0) {
if ($attempted -eq 0) {
$fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName
if ($Root -eq $HOME) {
$flabel = "~\.agents\skills\$SkillName"
} else {
$flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName)
}
Copy-SkillToDir -SkillSrc $SkillSrc -Dest $fallback -Label $flabel
if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) {
$installed++
} else {
$failed++
}
}
if ($installed -eq 0) {
Write-Say "⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"
return $false
}
if ($failed -gt 0) {
Write-Say "⚠️ 有 $failed 个 Agent 目标安装 mono Skill 失败"
return $false
}
Remove-Item -LiteralPath (Join-Path $SkillStateRoot "skills-state.json") -Force -ErrorAction SilentlyContinue
return $true
}
# Test-MultiTreeHasSkills returns $true only when the multi bundle directory
# contains at least one product skill (a subdirectory with a SKILL.md). An
# empty or corrupt multi\ tree must never select the multi branch: installing
# it would delete existing dws\ + dingtalk-* skills and lay down nothing.
function Test-MultiTreeHasSkills {
param([string]$MultiSrc)
if (!(Test-Path $MultiSrc)) { return $false }
foreach ($dir in Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue) {
if (Test-Path (Join-Path $dir.FullName "SKILL.md")) { return $true }
}
return $false
}
# Install the multi skill bundle (one subdirectory per product skill) into all
# agent homes as sibling directories, mirroring `dws skill setup --mode multi`.
# Mutual exclusion: the mono leftover (<home>\dws) and stale DWS-managed Skills
# not present in the new bundle are removed first.
function Install-MultiSkillsToHomes {
param(
[string]$MultiSrc,
[string]$Root = $HOME
)
$installed = 0
$attempted = 0
$failed = 0
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
$agentDir = $AgentDirs[$i]
$baseDir = Join-Path $Root $agentDir
$parentGate = Split-Path $baseDir -Parent
if ($i -gt 0 -and !(Test-Path $parentGate)) {
continue
}
$attempted++
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) {
$installed++
} else {
Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)"
$failed++
}
}
if ($attempted -eq 0) {
if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") {
$installed++
} else {
$failed++
}
}
if ($installed -eq 0) {
Write-Say "⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"
return $false
}
if ($failed -gt 0) {
Write-Say "⚠️ 有 $failed 个 Agent 目标安装 multi Skill 失败"
return $false
}
Write-SkillsState -MultiSrc $MultiSrc
return $true
}
function Install-MultiToBase {
param(
[string]$MultiSrc,
[string]$BaseDir,
[string]$Root,
[string]$AgentDir
)
if (!(Test-Path $BaseDir)) {
New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null
}
$skillDirs = @(Get-ChildItem -Path $MultiSrc -Directory | Where-Object {
Test-Path (Join-Path $_.FullName "SKILL.md")
})
$stageRoot = Join-Path $BaseDir (".dws-multi-set-" + [guid]::NewGuid().ToString("N"))
$backups = @()
$published = @()
try {
# Stage the complete replacement before moving any Agent-visible
# directory. Copy failures therefore leave the old set untouched.
New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null
foreach ($skillDir in $skillDirs) {
Copy-DirRecursive -Source $skillDir.FullName -Destination (Join-Path $stageRoot $skillDir.Name) | Out-Null
}
$victims = [System.Collections.Generic.List[string]]::new()
$victims.Add((Join-Path $BaseDir $SkillName))
# Include stale, proven DWS-managed skills in the same transaction.
foreach ($existing in Get-ChildItem -Path $BaseDir -Directory -ErrorAction SilentlyContinue) {
if ($existing.FullName -eq $stageRoot) { continue }
if ((Test-ManagedMultiSkillDir -Dir $existing.FullName) -and
!(Test-Path (Join-Path (Join-Path $MultiSrc $existing.Name) "SKILL.md"))) {
$victims.Add($existing.FullName)
}
}
if (!(Test-Path (Join-Path (Join-Path $MultiSrc "dws-shared") "SKILL.md"))) {
$victims.Add((Join-Path $BaseDir "dws-shared"))
}
foreach ($skillDir in $skillDirs) {
$victims.Add((Join-Path $BaseDir $skillDir.Name))
}
$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 }
}
}
foreach ($skillDir in $skillDirs) {
$dest = Join-Path $BaseDir $skillDir.Name
$published += $dest
Move-SkillPath -Source (Join-Path $stageRoot $skillDir.Name) -Destination $dest
}
} catch {
$transactionError = $_
if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) {
Write-Say "⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
}
Write-Say "⚠️ multi Skill 集合发布失败,目标已回滚: $BaseDir ($transactionError)"
return $false
} finally {
if (Test-Path $stageRoot) {
Remove-Item -LiteralPath $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
$count = $skillDirs.Count
if ($Root -eq $HOME) {
$label = "~\$AgentDir\"
} else {
$label = Join-Path $Root $AgentDir
}
Write-Say "✅ Skills → $label ($count product skills)"
return $true
}
# ── Install Binary from Source ───────────────────────────────────────────────
@@ -575,22 +1038,37 @@ function Install-Skills {
$skillSrc = Join-Path $extractRoot $SkillName
}
if (!(Test-Path (Join-Path $skillSrc "SKILL.md"))) {
Write-Say "⚠️ Skills not found in release asset. Trying local source..."
$localRoot = Resolve-SourceRoot
if ($localRoot) {
Install-SkillsLocal -Root $localRoot
# Multi first: a release may ship only the multi\ tree without the
# root mono copy, so the mono SKILL.md gate must never block a multi
# install. An empty/corrupt multi\ tree (no *\SKILL.md) falls back to
# mono with a warning — installing it would wipe existing skills and
# lay down nothing.
$multiRoot = Join-Path $extractRoot "multi"
if ($SkillMode -eq "multi" -and (Test-MultiTreeHasSkills $multiRoot)) {
if (!(Install-MultiSkillsToHomes -MultiSrc $multiRoot -Root $HOME)) {
throw "multi Skill installation failed"
}
} else {
if ($SkillMode -eq "multi") {
Write-Say "⚠️ multi skill tree not found or empty in release asset; falling back to mono."
}
if (!(Test-Path (Join-Path $skillSrc "SKILL.md"))) {
Write-Say "⚠️ Skills not found in release asset. Trying local source..."
$localRoot = Resolve-SourceRoot
if ($localRoot) {
Install-SkillsLocal -Root $localRoot
return
}
Write-Say "⚠️ No local source found either. Skipping skills installation."
return
}
Write-Say "⚠️ No local source found either. Skipping skills installation."
return
if (!(Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME)) {
throw "mono Skill installation failed"
}
}
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
# Cache the multi/ tree (and a mono copy) under ~/.dws/skills so that
# subsequent `dws skill setup --mode multi|mono` can find a source.
$multiRoot = Join-Path $extractRoot "multi"
if (Test-Path $multiRoot) {
Cache-MultiSkills -Source $multiRoot
}
@@ -617,27 +1095,15 @@ if (!$NoSkills) {
if ($SourceRoot -and !$SkillsOnly -and ($Version -eq "latest")) {
Install-BinaryFromSource -Root $SourceRoot
if (!$NoSkills) {
if ($SkillMode -eq "multi") {
Write-MultiModeNotice
} else {
Install-SkillsLocal -Root $SourceRoot
}
Install-SkillsLocal -Root $SourceRoot
}
} elseif ($SkillsOnly) {
if ($SkillMode -eq "multi") {
Write-MultiModeNotice
} else {
Install-Skills
}
Install-Skills
} elseif ($NoSkills) {
Install-Binary
} else {
Install-Binary
if ($SkillMode -eq "multi") {
Write-MultiModeNotice
} else {
Install-Skills
}
Install-Skills
}
Write-Host ""
+558 -79
View File
@@ -14,7 +14,7 @@
# DWS_VERSION — version to install (default: latest)
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary)
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else multi)
# DWS_GITEE_REPO — "owner/repo" on Gitee; when set, version + assets resolve
# via the Gitee API instead of GitHub (China mirror)
#
@@ -38,8 +38,10 @@ INSTALL_NAME="${DWS_INSTALL_NAME:-$BIN_NAME}"
VERSION="${DWS_VERSION:-latest}"
NO_SKILLS="${DWS_NO_SKILLS:-0}"
SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}"
SKILL_STATE_ROOT="${DWS_CONFIG_DIR:-$HOME/.dws}"
SKILL_NAME="dws"
SKILL_MODE=""
MANAGED_SKILL_DIGEST_SCOPE="skill-directory-v1"
# ── Helpers ──────────────────────────────────────────────────────────────────
@@ -59,6 +61,234 @@ need_cmd() {
return 0
}
# backup_and_remove_skill_dir <dir>
# Moves <dir> into $HOME/.dws/skill-backups/<stamp>/<name> instead of
# destroying it (non-interactive installs cannot confirm, so removals must
# stay reversible). Missing paths are a no-op success. On any backup failure
# the directory is left in place and a non-zero status is returned so callers
# skip that target rather than silently deleting data.
DWS_LAST_SKILL_BACKUP=""
backup_and_remove_skill_dir() {
_bed_dir="$1"
DWS_LAST_SKILL_BACKUP=""
[ -d "$_bed_dir" ] || return 0
_bed_root="${HOME}/.dws/skill-backups"
_bed_stamp="$(date -u +%Y%m%d-%H%M%S)"
_bed_name="$(basename "$_bed_dir")"
_bed_target="$_bed_root/$_bed_stamp/$_bed_name"
_bed_i=1
while [ -e "$_bed_target" ]; do
_bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name"
_bed_i=$((_bed_i + 1))
if [ "$_bed_i" -gt 1000 ]; then
say " ⚠️ 备份目录冲突,保留原目录 $_bed_dir"
return 1
fi
done
mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || {
say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir"
return 1
}
if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then
DWS_LAST_SKILL_BACKUP="$_bed_target"
say " × 已备份并移除 $_bed_dir → $_bed_target"
return 0
fi
say " ⚠️ 备份失败,保留原目录 $_bed_dir"
return 1
}
# A dingtalk-* prefix alone is not ownership evidence: market/user skills may
# use it too. Ownership comes from the centralized skills-state.json.
is_managed_multi_skill_dir() {
_managed_dir="$1"
_managed_name="$(basename "$_managed_dir")"
is_legacy_official_multi_skill_name "$_managed_name" && return 0
[ -f "$SKILL_STATE_ROOT/skills-state.json" ] || return 1
_managed_json_name="$(json_escape "$_managed_name")"
_managed_compact='"name":"'"$_managed_json_name"'"'
_managed_spaced='"name": "'"$_managed_json_name"'"'
DWS_MANAGED_COMPACT="$_managed_compact" DWS_MANAGED_SPACED="$_managed_spaced" awk '
/^[[:space:]]*"managed_skills"[[:space:]]*:[[:space:]]*\[[[:space:]]*$/ { inside = 1; next }
inside && /^[[:space:]]*\][[:space:]]*,?[[:space:]]*$/ { closed = 1; exit }
inside && (index($0, ENVIRON["DWS_MANAGED_COMPACT"]) || index($0, ENVIRON["DWS_MANAGED_SPACED"])) { found = 1 }
END { exit !(closed && found) }
' "$SKILL_STATE_ROOT/skills-state.json"
}
# Frozen exact names shipped before centralized ownership metadata. Never replace this
# with a dingtalk-* prefix check: user/market Skills may use that prefix.
is_legacy_official_multi_skill_name() {
case "$1" in
dingtalk-agoal|dingtalk-aiapp|dingtalk-aisearch|dingtalk-aitable|dingtalk-attendance|dingtalk-calendar|dingtalk-chat|dingtalk-contact|dingtalk-dev|dingtalk-devapp|dingtalk-devdoc|dingtalk-ding|dingtalk-doc|dingtalk-drive|dingtalk-event|dingtalk-hrbrain|dingtalk-live|dingtalk-mail|dingtalk-markdown|dingtalk-minutes|dingtalk-misc|dingtalk-oa|dingtalk-pat|dingtalk-profile|dingtalk-report|dingtalk-shared|dingtalk-sheet|dingtalk-skill|dingtalk-todo|dingtalk-wiki|dws-shared) return 0 ;;
esac
return 1
}
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
sha256_stdin() {
if need_cmd sha256sum; then
sha256sum | awk '{print $1}'
elif need_cmd shasum; then
shasum -a 256 | awk '{print $1}'
elif need_cmd openssl; then
openssl dgst -sha256 | awk '{print $NF}'
else
return 1
fi
}
digest_skill_dir() {
_digest_dir="$1"
_digest="$({
find "$_digest_dir" -type f -print | LC_ALL=C sort | while IFS= read -r _digest_file; do
_digest_rel="${_digest_file#"$_digest_dir"/}"
printf '%s\0' "$_digest_rel"
cat "$_digest_file"
printf '\0'
done
} | sha256_stdin)" || return 1
printf 'sha256:%s' "$_digest"
}
write_skills_state() {
_state_multi="$1"
_state_source="$2"
_state_root="$SKILL_STATE_ROOT"
mkdir -p "$_state_root" || return 1
_state_tmp="$(mktemp "$_state_root/.skills-state.XXXXXX")" || return 1
_state_version="$(json_escape "$VERSION")"
_state_names=""
for _state_dir in "$_state_multi"/*/; do
[ -f "${_state_dir}SKILL.md" ] || continue
_state_name="$(basename "$_state_dir")"
_state_names="${_state_names}${_state_name}\n"
done
{
printf '{\n "version": "%s",\n' "$_state_version"
printf ' "official_skills": ['
_state_first=1
printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do
[ -n "$_state_name" ] || continue
[ "$_state_first" -eq 1 ] || printf ', '
printf '"%s"' "$(json_escape "$_state_name")"
_state_first=0
done
printf '],\n "updated_skills": ['
_state_first=1
printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do
[ -n "$_state_name" ] || continue
[ "$_state_first" -eq 1 ] || printf ', '
printf '"%s"' "$(json_escape "$_state_name")"
_state_first=0
done
printf '],\n "managed_skills": [\n'
_state_first=1
printf '%b' "$_state_names" | LC_ALL=C sort | while IFS= read -r _state_name; do
[ -n "$_state_name" ] || continue
_state_digest="$(digest_skill_dir "$_state_multi/$_state_name")" || exit 1
[ "$_state_first" -eq 1 ] || printf ',\n'
printf ' {"name":"%s","version":"%s","source":"%s","digest":"%s","digest_scope":"%s"}' "$(json_escape "$_state_name")" "$_state_version" "$_state_source" "$_state_digest" "$MANAGED_SKILL_DIGEST_SCOPE"
_state_first=0
done
printf '\n ],\n "updated_at": "%s"\n}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$_state_tmp" || { rm -f "$_state_tmp"; return 1; }
mv "$_state_tmp" "$_state_root/skills-state.json"
}
# backup_and_record_skill_dir <victim> <manifest>
# Records exact original/backup pairs so a multi-set transaction can restore
# earlier moves when any later backup or publication fails.
backup_and_record_skill_dir() {
_bars_victim="$1"
_bars_manifest="$2"
backup_and_remove_skill_dir "$_bars_victim" || return 1
if [ -n "$DWS_LAST_SKILL_BACKUP" ]; then
if ! printf '%s\n%s\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP" >> "$_bars_manifest"; then
mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || say " ⚠️ 备份记录失败且无法自动恢复: $_bars_victim(备份位于 $DWS_LAST_SKILL_BACKUP)"
return 1
fi
fi
}
# restore_multi_skill_set <published-manifest> <backup-manifest>
# Removes partial new publications, then restores every old directory from
# its exact backup path. Paths containing newlines are outside the supported
# installer path contract; spaces are preserved.
restore_multi_skill_set() {
_rms_published="$1"
_rms_backups="$2"
_rms_ok=1
if [ -f "$_rms_published" ]; then
while IFS= read -r _rms_dest; do
[ -n "$_rms_dest" ] || continue
rm -rf "$_rms_dest" || _rms_ok=0
done < "$_rms_published"
fi
if [ -f "$_rms_backups" ]; then
while IFS= read -r _rms_original && IFS= read -r _rms_backup; do
[ -n "$_rms_backup" ] || continue
if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then
say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)"
_rms_ok=0
fi
done < "$_rms_backups"
fi
[ "$_rms_ok" -eq 1 ]
}
# publish_skill_cache <source> <cache-dir>
# Copies a complete cache into a sibling staging directory, then publishes it
# with rename. Copy/publish failures retain the previous cache; if restoration
# itself fails, the recovery directory is reported and left untouched.
publish_skill_cache() {
_psc_src="$1"
_psc_cache="$2"
_psc_parent="$(dirname "$_psc_cache")"
_psc_name="$(basename "$_psc_cache")"
_psc_stage=""
_psc_old=""
mkdir -p "$_psc_parent" || return 1
_psc_stage="$(mktemp -d "$_psc_parent/.${_psc_name}.tmp.XXXXXX")" || return 1
if ! cp -R "$_psc_src/." "$_psc_stage/" 2>/dev/null && \
! cp -r "$_psc_src/." "$_psc_stage/" 2>/dev/null; then
rm -rf "$_psc_stage"
return 1
fi
if [ -e "$_psc_cache" ]; then
_psc_old="$(mktemp -d "$_psc_parent/.${_psc_name}.old.XXXXXX")" || {
rm -rf "$_psc_stage"
return 1
}
rmdir "$_psc_old" || {
rm -rf "$_psc_stage" "$_psc_old"
return 1
}
if ! mv "$_psc_cache" "$_psc_old"; then
rm -rf "$_psc_stage"
return 1
fi
fi
if mv "$_psc_stage" "$_psc_cache"; then
if [ -n "$_psc_old" ] && ! rm -rf "$_psc_old"; then
say " ⚠️ 新 Skill 缓存已生效,但旧缓存清理失败: $_psc_old"
fi
return 0
fi
rm -rf "$_psc_stage"
if [ -n "$_psc_old" ] && ! mv "$_psc_old" "$_psc_cache"; then
say " ⚠️ Skill 缓存发布失败,原缓存保留在 $_psc_old"
fi
return 1
}
resolve_source_root() {
script_path="$0"
if [ ! -f "$script_path" ]; then
@@ -215,8 +445,8 @@ print_banner() {
#
# Priority (highest first):
# 1. DWS_SKILL_MODE env var (mono | multi, case-insensitive)
# 2. Interactive prompt when both stdin and stdout are TTYs (default: mono)
# 3. Fallback: mono (non-TTY without env var, e.g. curl | sh)
# 2. Interactive prompt when both stdin and stdout are TTYs (default: multi)
# 3. Fallback: multi (non-TTY without env var, e.g. curl | sh)
resolve_skill_mode() {
if [ -n "${DWS_SKILL_MODE:-}" ]; then
raw="$DWS_SKILL_MODE"
@@ -237,31 +467,23 @@ resolve_skill_mode() {
if [ -t 0 ] && [ -t 1 ]; then
printf '\n'
say "Select skill installation mode:"
say " 1) mono — install one bundled dws skill (stable / recommended)"
say " 2) multi — split each product into its own skill (run 'dws skill setup --mode multi' afterwards)"
say " 1) multi (default) — split each product into its own skill (dingtalk-*)"
say " 2) mono — install one bundled dws skill (legacy)"
printf ' Choice [1]: '
read choice || choice=""
case "$choice" in
""|1|mono) SKILL_MODE="mono" ;;
2|multi) SKILL_MODE="multi" ;;
""|1|multi) SKILL_MODE="multi" ;;
2|mono) SKILL_MODE="mono" ;;
*)
say "Unrecognized choice '${choice}', defaulting to mono."
SKILL_MODE="mono"
say "Unrecognized choice '${choice}', defaulting to multi."
SKILL_MODE="multi"
;;
esac
say "Skill mode: ${SKILL_MODE}"
return 0
fi
SKILL_MODE="mono"
}
print_multi_mode_notice() {
say ""
say "Skill mode: multi — automatic skill install skipped."
say " To install split skills, run:"
say " ${BIN_NAME} skill setup --mode multi"
say " (One skill per product family; requires the dws binary installed above.)"
SKILL_MODE="multi"
}
install_binary_from_source() {
@@ -296,17 +518,26 @@ install_skills_local() {
skill_src="${root}/skills/mono"
multi_src="${root}/skills/multi"
if [ ! -d "$skill_src" ]; then
say "⚠️ Local skills directory not found: ${skill_src}"
say " Skipping skills installation."
return 1
if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$multi_src"; then
say ""
say "📦 Installing agent skills (multi) from local source: ${multi_src}"
install_multi_skills_to_homes "$multi_src"
else
if [ "$SKILL_MODE" = "multi" ]; then
say "⚠️ Multi skill tree not found or empty at ${multi_src}; falling back to mono."
fi
if [ ! -d "$skill_src" ]; then
say "⚠️ Local skills directory not found: ${skill_src}"
say " Skipping skills installation."
return 1
fi
say ""
say "📦 Installing agent skills from local source: ${skill_src}"
install_skills_to_homes "$skill_src"
fi
say ""
say "📦 Installing agent skills from local source: ${skill_src}"
install_skills_to_homes "$skill_src"
# Cache multi source for later `dws skill setup --mode multi`.
if [ -d "$multi_src" ]; then
cache_multi_skills "$multi_src"
@@ -326,14 +557,14 @@ cache_multi_skills() {
src="$1"
cache_dir="${HOME}/.dws/skills/multi"
if [ ! -d "$src" ]; then
# Never let an empty/corrupt multi/ tree wipe a previously good cache.
multi_tree_has_skills "$src" || return 0
if ! publish_skill_cache "$src" "$cache_dir"; then
say "⚠️ Multi Skill 缓存刷新失败,未覆盖原缓存: ${cache_dir}"
return 0
fi
rm -rf "$cache_dir"
mkdir -p "$cache_dir"
cp -R "$src/"* "$cache_dir/" 2>/dev/null || cp -r "$src/"* "$cache_dir/" 2>/dev/null || true
file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')"
case "$cache_dir" in
"$HOME"/*) label="~/${cache_dir#$HOME/}" ;;
@@ -349,20 +580,78 @@ cache_mono_skills() {
src="$1"
cache_dir="${HOME}/.dws/skills/mono"
if [ ! -d "$src" ]; then
# Only refresh when the new bundle actually carries a mono tree — a
# multi-only bundle must never wipe a previously good mono cache.
if [ ! -f "$src/SKILL.md" ]; then
return 0
fi
rm -rf "$cache_dir"
mkdir -p "$cache_dir"
cp -R "$src/"* "$cache_dir/" 2>/dev/null || cp -r "$src/"* "$cache_dir/" 2>/dev/null || true
if ! publish_skill_cache "$src" "$cache_dir"; then
say "⚠️ Mono Skill 缓存刷新失败,未覆盖原缓存: ${cache_dir}"
fi
}
# Publish mono and all mutually-exclusive managed multi directories as one
# transaction. The complete dws/ tree is staged before any visible directory
# moves; any later backup or publish failure restores the exact old set.
_install_mono_to_base() {
_mono_src="$1"
_mono_base="$2"
_mono_label="$3"
mkdir -p "$_mono_base" || return 1
_mono_stage="$(mktemp -d "$_mono_base/.dws-mono-set.XXXXXX")" || return 1
_mono_backups="$_mono_stage/.backups"
_mono_published="$_mono_stage/.published"
: > "$_mono_backups" || { rm -rf "$_mono_stage"; return 1; }
: > "$_mono_published" || { rm -rf "$_mono_stage"; return 1; }
mkdir -p "$_mono_stage/$SKILL_NAME" || { rm -rf "$_mono_stage"; return 1; }
if ! cp -R "$_mono_src/." "$_mono_stage/$SKILL_NAME/" 2>/dev/null && ! cp -r "$_mono_src/." "$_mono_stage/$SKILL_NAME/"; then
rm -rf "$_mono_stage"
return 1
fi
if ! backup_and_record_skill_dir "$_mono_base/$SKILL_NAME" "$_mono_backups"; then
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
rm -rf "$_mono_stage"
return 1
fi
for existing in "$_mono_base"/*/; do
[ -d "$existing" ] || continue
is_managed_multi_skill_dir "$existing" || continue
if ! backup_and_record_skill_dir "$existing" "$_mono_backups"; then
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
rm -rf "$_mono_stage"
return 1
fi
done
_mono_dest="$_mono_base/$SKILL_NAME"
printf '%s\n' "$_mono_dest" >> "$_mono_published" || {
restore_multi_skill_set "$_mono_published" "$_mono_backups" || true
rm -rf "$_mono_stage"
return 1
}
if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then
say " ⚠️ mono Skill 集合发布失败,正在恢复原集合: $_mono_dest"
restore_multi_skill_set "$_mono_published" "$_mono_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
rm -rf "$_mono_stage"
return 1
fi
rm -rf "$_mono_stage" || return 1
_mono_count="$(find "$_mono_dest" -type f | wc -l | tr -d ' ')"
say "✅ Skills → ${_mono_label} (${_mono_count} files)"
}
# 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`.
install_skills_to_homes() {
skill_src="$1"
root="${HOME}"
installed=0
attempted=0
failed=0
idx=0
for agent_dir in \
".agents/skills" \
@@ -388,7 +677,7 @@ install_skills_to_homes() {
idx=$((idx + 1))
continue
fi
dest="$base_dir/$SKILL_NAME"
attempted=$((attempted + 1))
case "$root" in
"$HOME")
label="~/$agent_dir/$SKILL_NAME"
@@ -397,15 +686,14 @@ install_skills_to_homes() {
label="$root/$agent_dir/$SKILL_NAME"
;;
esac
if [ "$installed" -eq 0 ]; then
_copy_skill "$skill_src" "$dest" "$label"
if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then
installed=$((installed + 1))
else
_copy_skill_summary "$skill_src" "$dest" "$label"
failed=$((failed + 1))
fi
installed=$((installed + 1))
idx=$((idx + 1))
done
if [ "$installed" -eq 0 ]; then
if [ "$attempted" -eq 0 ]; then
case "$root" in
"$HOME")
flabel="~/.agents/skills/$SKILL_NAME"
@@ -414,8 +702,189 @@ install_skills_to_homes() {
flabel="$root/.agents/skills/$SKILL_NAME"
;;
esac
_copy_skill "$skill_src" "$root/.agents/skills/$SKILL_NAME" "$flabel"
if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
fi
fi
if [ "$installed" -eq 0 ]; then
say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"
return 1
fi
if [ "$failed" -gt 0 ]; then
say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败"
return 1
fi
rm -f "$SKILL_STATE_ROOT/skills-state.json"
}
# multi_tree_has_skills returns 0 only when the given multi bundle directory
# contains at least one product skill (a subdirectory with a SKILL.md). An
# empty or corrupt multi/ tree must never select the multi branch: installing
# it would delete existing dws/ + dingtalk-* skills and lay down nothing.
# (Go bundleSkillNames and install.js multiTreeHasSkills guard the same way.)
multi_tree_has_skills() {
_dir="$1"
[ -d "$_dir" ] || return 1
for _sub in "$_dir"/*/; do
if [ -f "${_sub}SKILL.md" ]; then
return 0
fi
done
return 1
}
# Install the multi skill bundle (one subdirectory per product skill) into all
# agent homes as sibling directories, mirroring `dws skill setup --mode multi`.
# Mutual exclusion: the mono leftover (<home>/dws) and stale DWS-managed Skills
# not present in the new bundle are removed first.
install_multi_skills_to_homes() {
multi_src="$1"
root="${HOME}"
installed=0
attempted=0
failed=0
idx=0
for agent_dir in \
".agents/skills" \
".claude/skills" \
".cursor/skills" \
".qoder/skills" \
".qoderwork/skills" \
".gemini/skills" \
".codex/skills" \
".github/skills" \
".windsurf/skills" \
".augment/skills" \
".cline/skills" \
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
idx=$((idx + 1))
continue
fi
attempted=$((attempted + 1))
if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then
installed=$((installed + 1))
else
failed=$((failed + 1))
say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)"
fi
idx=$((idx + 1))
done
if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then
installed=$((installed + 1))
fi
if [ "$installed" -eq 0 ]; then
say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"
return 1
fi
if [ "$failed" -gt 0 ]; then
say " ⚠️ 有 ${failed} 个 Agent 目标安装失败"
return 1
fi
write_skills_state "$multi_src" "install.sh" || return 1
}
_install_multi_to_base() {
_msrc="$1"
_base="$2"
_root="$3"
_agent_dir="$4"
mkdir -p "$_base" || return 1
# Build the complete replacement set before moving any Agent-visible
# directory. The manifests remain inside the private staging directory.
_ms_stage="$(mktemp -d "$_base/.dws-multi-set.XXXXXX")" || return 1
_ms_backups="$_ms_stage/.backups"
_ms_published="$_ms_stage/.published"
: > "$_ms_backups" || { rm -rf "$_ms_stage"; return 1; }
: > "$_ms_published" || { rm -rf "$_ms_stage"; return 1; }
for skill_dir in "$_msrc"/*/; do
[ -f "${skill_dir}SKILL.md" ] || continue
_name="$(basename "$skill_dir")"
_ms_staged_skill="$_ms_stage/$_name"
mkdir -p "$_ms_staged_skill" || { rm -rf "$_ms_stage"; return 1; }
if ! cp -R "$skill_dir/." "$_ms_staged_skill/" 2>/dev/null && ! cp -r "$skill_dir/." "$_ms_staged_skill/"; then
rm -rf "$_ms_stage"
return 1
fi
done
# Mutual exclusion: back up + remove the mono leftover.
if ! backup_and_record_skill_dir "$_base/$SKILL_NAME" "$_ms_backups"; then
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
fi
# Back up + remove stale, proven DWS-managed skills not in the new bundle.
# Never infer ownership from the dingtalk-* prefix alone.
for existing in "$_base"/*/; do
[ -d "$existing" ] || continue
_name="$(basename "$existing")"
if is_managed_multi_skill_dir "$existing" && [ ! -f "$_msrc/$_name/SKILL.md" ]; then
if ! backup_and_record_skill_dir "$existing" "$_ms_backups"; then
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
fi
fi
done
if [ -d "$_base/dws-shared" ] && [ ! -f "$_msrc/dws-shared/SKILL.md" ]; then
if ! backup_and_record_skill_dir "$_base/dws-shared" "$_ms_backups"; then
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
fi
fi
# Back up all replaced skills as one logical operation. Any failure restores
# every earlier move before this target reports failure.
for skill_dir in "$_msrc"/*/; do
[ -f "${skill_dir}SKILL.md" ] || continue
_name="$(basename "$skill_dir")"
_dest="$_base/$_name"
if ! backup_and_record_skill_dir "$_dest" "$_ms_backups"; then
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
fi
done
_count=0
for skill_dir in "$_msrc"/*/; do
[ -f "${skill_dir}SKILL.md" ] || continue
_name="$(basename "$skill_dir")"
_dest="$_base/$_name"
printf '%s\n' "$_dest" >> "$_ms_published" || {
restore_multi_skill_set "$_ms_published" "$_ms_backups" || true
rm -rf "$_ms_stage"
return 1
}
if ! mv "$_ms_stage/$_name" "$_dest"; then
say " ⚠️ multi Skill 集合发布失败,正在恢复原集合: $_dest"
restore_multi_skill_set "$_ms_published" "$_ms_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径"
rm -rf "$_ms_stage"
return 1
fi
_count=$((_count + 1))
done
rm -rf "$_ms_stage" || return 1
case "$_root" in
"$HOME") _label="~/$_agent_dir/" ;;
*) _label="$_root/$_agent_dir/" ;;
esac
say "✅ Skills → ${_label} (${_count} product skills)"
}
# One-line summary copy (used for 2nd+ agent targets).
@@ -425,11 +894,17 @@ _copy_skill_summary() {
_label="$3"
if [ -d "$_dest" ]; then
rm -rf "$_dest"
backup_and_remove_skill_dir "$_dest" || {
say " ⚠️ 跳过 ${_dest}(保留原目录)"
return 1
}
fi
mkdir -p "$_dest"
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
mkdir -p "$_dest" || return 1
if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then
say " ⚠️ Skill 复制失败,目标未计为安装成功: $_dest"
return 1
fi
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
say "✅ Skills → ${_label} (${file_count} files)"
@@ -442,11 +917,17 @@ _copy_skill() {
_label="$3"
if [ -d "$_dest" ]; then
rm -rf "$_dest"
backup_and_remove_skill_dir "$_dest" || {
say " ⚠️ 跳过 ${_dest}(保留原目录)"
return 1
}
fi
mkdir -p "$_dest"
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
mkdir -p "$_dest" || return 1
if ! cp -R "$_src/"* "$_dest/" 2>/dev/null && ! cp -r "$_src/"* "$_dest/"; then
say " ⚠️ Skill 复制失败,目标未计为安装成功: $_dest"
return 1
fi
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
say "✅ Skills → ${_label} (${file_count} files)"
@@ -593,21 +1074,31 @@ install_skills() {
elif [ -f "$extract_root/$SKILL_NAME/SKILL.md" ]; then
skill_src="$extract_root/$SKILL_NAME"
fi
if [ ! -f "$skill_src/SKILL.md" ]; then
say "⚠️ Skills not found in release asset. Trying local source..."
rm -rf "$tmpdir_skills"
local_root="$(resolve_source_root || true)"
if [ -n "$local_root" ]; then
install_skills_local "$local_root"
return
else
say "⚠️ No local source checkout found either. Skipping skills installation."
return
# Multi first: a release may ship only the multi/ tree without the root
# mono copy, so the mono SKILL.md gate must never block a multi install.
# An empty/corrupt multi/ tree (no */SKILL.md) falls back to mono with a
# warning — installing it would wipe existing skills and lay down nothing.
if [ "$SKILL_MODE" = "multi" ] && multi_tree_has_skills "$extract_root/multi"; then
install_multi_skills_to_homes "$extract_root/multi"
else
if [ "$SKILL_MODE" = "multi" ]; then
say "⚠️ Multi skill tree not found or empty in release asset; falling back to mono."
fi
if [ ! -f "$skill_src/SKILL.md" ]; then
say "⚠️ Skills not found in release asset. Trying local source..."
rm -rf "$tmpdir_skills"
local_root="$(resolve_source_root || true)"
if [ -n "$local_root" ]; then
install_skills_local "$local_root"
return
else
say "⚠️ No local source checkout found either. Skipping skills installation."
return
fi
fi
install_skills_to_homes "$skill_src"
fi
install_skills_to_homes "$skill_src"
# Cache the multi tree (if present in the release asset) so a later
# `dws skill setup --mode multi` can find a source without re-downloading.
if [ -d "$extract_root/multi" ]; then
@@ -642,32 +1133,20 @@ main() {
if [ -n "$source_root" ]; then
install_binary_from_source "$source_root"
if [ "$NO_SKILLS" != "1" ]; then
if [ "$SKILL_MODE" = "multi" ]; then
print_multi_mode_notice
else
install_skills_local "$source_root"
fi
install_skills_local "$source_root"
fi
elif [ "$SKILLS_ONLY" = "1" ]; then
if [ "$SKILL_MODE" = "multi" ]; then
print_multi_mode_notice
local_root="$(resolve_source_root || true)"
if [ -n "$local_root" ]; then
install_skills_local "$local_root"
else
local_root="$(resolve_source_root || true)"
if [ -n "$local_root" ]; then
install_skills_local "$local_root"
else
install_skills
fi
install_skills
fi
elif [ "$NO_SKILLS" = "1" ]; then
install_binary
else
install_binary
if [ "$SKILL_MODE" = "multi" ]; then
print_multi_mode_notice
else
install_skills
fi
install_skills
fi
printf '\n'
+21 -19
View File
@@ -71,23 +71,23 @@ HOME_AGENT_PARENTS="
.openclaw
.hermes
"
HOME_SKILL_TARGETS="
.agents/skills/dws
.claude/skills/dws
.cursor/skills/dws
.qoder/skills/dws
.qoderwork/skills/dws
.gemini/skills/dws
.codex/skills/dws
.github/skills/dws
.windsurf/skills/dws
.augment/skills/dws
.cline/skills/dws
.amp/skills/dws
.kiro/skills/dws
.trae/skills/dws
.openclaw/skills/dws
.hermes/skills/dws
HOME_SKILL_BASES="
.agents/skills
.claude/skills
.cursor/skills
.qoder/skills
.qoderwork/skills
.gemini/skills
.codex/skills
.github/skills
.windsurf/skills
.augment/skills
.cline/skills
.amp/skills
.kiro/skills
.trae/skills
.openclaw/skills
.hermes/skills
"
cleanup() {
if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then
@@ -109,8 +109,10 @@ seed_agent_homes() {
verify_skill_targets() {
home_root="$1"
for target in $HOME_SKILL_TARGETS; do
need_file "$home_root/$target/SKILL.md"
for base in $HOME_SKILL_BASES; do
need_file "$home_root/$base/dingtalk-shared/SKILL.md"
need_file "$home_root/$base/dingtalk-misc/SKILL.md"
[ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws"
done
}
+1 -1
View File
@@ -53,7 +53,7 @@ cli_version: ">=1.0.15"
| `doc` | 45 | `dingtalk-doc` |
| `drive` | 7 | `dingtalk-drive` |
| `mail` | 10 | `dingtalk-mail` |
| `minutes` | 6 | `dingtalk-minutes` |
| `minutes` | 27 | `dingtalk-minutes` |
| `oa` | 7 | `dingtalk-misc` |
| `report` | 2 | `dingtalk-misc` |
| `sheet` | 2 | `dingtalk-misc` |
+8 -3
View File
@@ -2386,7 +2386,7 @@ dws chat message send --group <openConversationId> --msg-type image --media-id "
#### 创建并推送流式卡片 — 向群聊或单聊发送流式卡片消息
群聊传 --group,单聊传 --receiver,二者互斥。
群聊传 --group,单聊传 --receiver,二者互斥。群聊创建时可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人。
**注意:send-card 必须和 update-card 搭配使用。** 创建卡片时无需传入内容,后续通过 update-card 更新内容,最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。
@@ -2395,12 +2395,16 @@ Usage:
dws chat message send-card [flags]
Example:
dws chat message send-card --group <openConversationId>
dws chat message send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId>
dws chat message send-card --group <openConversationId> --at-all
dws chat message send-card --receiver <openDingTalkId>
# 查询群 ID: dws chat search --query "群名"
# 查询人员: dws aisearch person --keyword "姓名" --dimension name
Flags:
--group string 群聊 openConversationId(群聊时必填,与 --receiver 互斥)
--receiver string 单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)
--at-all 群聊创建卡片时 @ 所有人(仅与 --group 一起使用)
--at-open-dingtalk-ids string 群聊创建卡片时 @ 的 openDingTalkId 列表,逗号分隔(仅与 --group 一起使用)
--group string 群聊 openConversationId(群聊时必填,与 --receiver 互斥)
--receiver string 单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)
```
#### 流式更新卡片内容 — 更新已发送的流式卡片内容
@@ -2409,6 +2413,7 @@ Flags:
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。
**最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。**
更新结果不确定时不要再次执行更新;保留返回结果并告知用户。
```
Usage:
dws chat message update-card [flags]
+28 -2
View File
@@ -194,9 +194,9 @@ Example:
dws wiki feed list --workspace <workspaceId> --limit 10 --cursor <nextToken> --format json
Flags:
--workspace string 知识库 ID 或 URL (必填)
--limit int 每页数量 (默认 20,最大 50)
--limit int 每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10
--cursor string 分页游标 (首页留空)
--exclude-file 是否排除文件相关的动态 (默认 false)
--exclude-file 排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作 (默认 false)
```
查询指定知识库的动态,返回谁在什么时间进行了更新、上传、评论等操作。
@@ -205,6 +205,28 @@ Flags:
> **权限要求**:调用者需具备知识库的成员权限,非成员会被拒绝访问。
**动态类型 `type` 枚举(严格按此映射展示,禁止猜测)**:
| type | 含义 |
|------|------|
| 0 | 创建文档 |
| 1 | 更新文档 |
| 2 | 评论文档 |
| 3 | 点赞文档 |
| 4 | 加入团队空间 |
| 5 | 表格选区数据变更 |
| 6 | 更新 office 文件 |
| 7 | 上传普通文件(非媒体文件) |
| 8 | 上传媒体文件(图片/视频) |
| 9 | 上传文件夹 |
| 10 | 上传文件夹 V2 |
| 11 | 加入团队 |
| 12 | 创建知识库 |
> **重要 — 展示规则(必须严格遵守)**:
> 1. **typeLabel 字段**:CLI 已自动将 `type` 数字映射为 `typeLabel` 中文标签(如 `更新文档`、`创建文档`),**直接使用 `typeLabel` 展示即可**,无需自行查表映射。
> 2. **timeFormatted 字段**:已是北京时间字符串(格式如 `2026-07-30 08:00`),直接展示即可,无需任何转换。原始毫秒时间戳保存在 `time` 字段中(数字类型)。
### 列出知识库节点
```
Usage:
@@ -323,6 +345,10 @@ Flags:
- 用户说"知识库动态/最近有什么更新/谁改了什么/知识库活动" → `feed list`(需 `--workspace`)
- 用户说"知识库最近的评论/更新记录/操作日志" → `feed list`(需 `--workspace`)
- 用户说"删除知识库/移除知识库/把知识库删了" → `space delete`(需 `--workspace`)
- 用户说"排除文件/只看创建文档/只看更新文档/只看文档操作/不要上传文件的记录/过滤掉文件动态/只看文档变更" → `feed list --exclude-file`(**必须带 flag,禁止客户端自行过滤**)
> **重要 — `--exclude-file` 使用规则**:
> 当用户意图排除文件类动态(上传文件、更新 office 文件等)或只看文档操作时,**必须在命令中带上 `--exclude-file`**,由后端完成过滤。禁止先拉全量数据再用 Python/jq 等工具在客户端过滤,那样既浪费带宽又可能因分页遗漏数据。
> **跨产品路由说明**:知识库节点的**内容操作**(读取/编辑/块级操作)仍由 `dws doc` 承担:
>- 用户说"读某个知识库里的某篇文档" → 先 `node list` 拿到 nodeId,再走 **`dws doc read --node <nodeId>`**
@@ -13,8 +13,9 @@ Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传
或艾特占位符。
- `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。
创建成功但自动更新失败时,错误会保留真实 `bizId`。不要重复创建;使用该 `bizId` 继续
`+messages-update-card` 或人工处理。
自动更新结果不确定时,不要再次更新或重复创建;保留返回结果并告知用户。若结果中已经
包含 `openTaskId`,可以按用户需要查询一次投递状态;该查询只确认消息投递,不代表卡片
正文已经更新成功。
当前内容仅为 streaming text,不接受 Lark Card JSON、组件树或按钮 callback。
+23 -2
View File
@@ -24,12 +24,33 @@ metadata:
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws minutes +detail` | read | 一条命令聚合取一条妙记(听记)的多项产物(基础信息/摘要/关键词/逐字稿/待办) |
| `dws minutes +action-items` | read | 读取指定或我最新一条听记中已抽取的行动项 |
| `dws minutes +apply-permission` | write | 为当前用户申请语义化的听记访问权限 |
| `dws minutes +detail` | read | 批量聚合听记基础信息、摘要、关键词、完整逐字稿和行动项,支持安全文件输出 |
| `dws minutes +download` | read | 批量取得听记音视频地址并安全下载到本地 |
| `dws minutes +export-pack` | read | 把完整听记产物写入受控目录并生成不含签名 URL 的 manifest |
| `dws minutes +latest` | read | 取我最新的一条妙记(听记)详情 |
| `dws minutes +list-all` | read | 查询我有权限访问的所有听记列表 |
| `dws minutes +list-mine` | read | 查询我创建的听记列表 |
| `dws minutes +list-shared` | read | 查询他人共享给我的听记列表 |
| `dws minutes +mindmap` | write | 创建听记思维导图并轮询到明确成功、失败或超时 |
| `dws minutes +prepare-asr` | write | 读取个人热词、计算差异、按需新增并可显式同步删除后读回验证 |
| `dws minutes +record-pause` | write | 暂停听记录音 |
| `dws minutes +record-resume` | write | 恢复听记录音 |
| `dws minutes +record-start` | write | 发起听记(开始录音) |
| `dws minutes +replace-batch` | write | 对一条妙记(听记)批量执行多组文字替换(原文=>替换) |
| `dws minutes +record-stop` | write | 结束听记录音 |
| `dws minutes +record-wrap-up` | write | 停止实时录音并有界等待听记产物,失败时保留恢复句柄 |
| `dws minutes +replace-batch` | write | 预检并批量执行多组听记文字替换,逐项验证且失败必定非零 |
| `dws minutes +search` | read | 按范围、标题关键词和时间搜索听记,支持安全全量翻页 |
| `dws minutes +share` | write | 按成员逐项授予一个或多个听记权限,输出可审计的部分写入 ledger |
| `dws minutes +speaker-insights` | write | 创建发言人段落总结并轮询结果,保留异步任务恢复句柄 |
| `dws minutes +speaker-replace` | write | 预检逐字稿中的发言人昵称,替换后重新读回验证 |
| `dws minutes +summary` | write | 读取当前纪要、校验图片引用、全量覆盖并读回验证 |
| `dws minutes +transcript` | read | 读取指定或我最新一条听记的完整逐字稿,并交付分页完整性证据 |
| `dws minutes +unshare` | write | 按成员逐项移除一个或多个听记权限,输出可审计的部分写入 ledger |
| `dws minutes +update` | write | 读取现状、预览差异、更新听记标题并读回验证 |
| `dws minutes +upload` | write | 把本地音视频完整上传并创建听记,失败时取消会话 |
| `dws minutes +upload-and-analyze` | write | 本地音视频直传听记并等待分析产物,可选思维导图和发言人洞察 |
<!-- VISIBLE_SHORTCUTS_END -->
## 意图表
@@ -9,7 +9,7 @@
| "搜索技能 / 找技能" | `dws skill search --query "<关键词>" [--source DingtalkMarket\|OrgInternal]` |
| "下载技能包" | `dws skill get --skill-id <skillId>` |
| "安装市场技能" | `dws skill install <skillId> <target>` |
| "安装 DWS mono/multi skills" | `dws skill setup --mode <mono\|multi> --target <target> --yes` |
| "安装 DWS mono/multi skills" | `dws skill setup --mode <mono\|multi> --target <target>` |
## 约束
@@ -73,20 +73,20 @@ Example:
Usage:
dws skill setup [flags]
Example:
dws skill setup --mode mono --yes
dws skill setup --mode multi --target qoder --yes
dws skill setup --mode multi -s aitable -s calendar --target qoder --yes
dws skill setup --mode multi -x live -x devdoc --target qoder --yes
dws skill setup --mode mono
dws skill setup --mode multi --target qoder
dws skill setup --mode multi -s aitable -s calendar --target qoder
dws skill setup --mode multi -x live -x devdoc --target qoder
Flags:
--mode string mono | multi
--target string 目标 Agent,默认 all
--source string 显式 skill 源目录
-s, --skill strings multi 模式只安装指定子 skill
-x, --exclude strings multi 模式排除指定子 skill
--yes 跳过确认
--yes 仅脚本使用:跳过确认(删除仍先备份)
```
`--skill` 与 `--exclude` 互斥。未指定 `--source` 时使用当前二进制内置的 skill 版本。
`--skill` 与 `--exclude` 互斥。未指定 `--source` 时使用当前二进制内置的 skill 版本。setup 会清理对面模式残留与不在 bundle 内的过期 skill;这些目录在确认前逐条列出,删除前先备份到 `~/.dws/skill-backups/`,备份失败的目录保留原样。代用户执行时不要附加 `--yes` 绕过确认。
## 上下文传递
+28 -2
View File
@@ -184,9 +184,9 @@ Example:
dws wiki feed list --workspace <workspaceId> --limit 10 --cursor <nextToken> --format json
Flags:
--workspace string 知识库 ID 或 URL (必填)
--limit int 每页数量 (默认 20,最大 50)
--limit int 每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10
--cursor string 分页游标 (首页留空)
--exclude-file 是否排除文件相关的动态 (默认 false)
--exclude-file 排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作 (默认 false)
```
查询指定知识库的动态,返回谁在什么时间进行了更新、上传、评论等操作。
@@ -195,6 +195,28 @@ Flags:
> **权限要求**:调用者需具备知识库的成员权限,非成员会被拒绝访问。
**动态类型 `type` 枚举(严格按此映射展示,禁止猜测)**:
| type | 含义 |
|------|------|
| 0 | 创建文档 |
| 1 | 更新文档 |
| 2 | 评论文档 |
| 3 | 点赞文档 |
| 4 | 加入团队空间 |
| 5 | 表格选区数据变更 |
| 6 | 更新 office 文件 |
| 7 | 上传普通文件(非媒体文件) |
| 8 | 上传媒体文件(图片/视频) |
| 9 | 上传文件夹 |
| 10 | 上传文件夹 V2 |
| 11 | 加入团队 |
| 12 | 创建知识库 |
> **重要 — 展示规则(必须严格遵守)**:
> 1. **typeLabel 字段**:CLI 已自动将 `type` 数字映射为 `typeLabel` 中文标签(如 `更新文档`、`创建文档`),**直接使用 `typeLabel` 展示即可**,无需自行查表映射。
> 2. **timeFormatted 字段**:已是北京时间字符串(格式如 `2026-07-30 08:00`),直接展示即可,无需任何转换。原始毫秒时间戳保存在 `time` 字段中(数字类型)。
### 列出知识库节点
```
Usage:
@@ -333,6 +355,10 @@ Flags:
- 用户说"知识库动态/最近有什么更新/谁改了什么/知识库活动" → `feed list`(需 `--workspace`)
- 用户说"知识库最近的评论/更新记录/操作日志" → `feed list`(需 `--workspace`)
- 用户说"删除知识库/移除知识库/把知识库删了" → `space delete`(需 `--workspace`)
- 用户说"排除文件/只看创建文档/只看更新文档/只看文档操作/不要上传文件的记录/过滤掉文件动态/只看文档变更" → `feed list --exclude-file`(**必须带 flag,禁止客户端自行过滤**)
> **重要 — `--exclude-file` 使用规则**:
> 当用户意图排除文件类动态(上传文件、更新 office 文件等)或只看文档操作时,**必须在命令中带上 `--exclude-file`**,由后端完成过滤。禁止先拉全量数据再用 Python/jq 等工具在客户端过滤,那样既浪费带宽又可能因分页遗漏数据。
> **跨产品路由说明**:知识库节点的**内容操作**(读取/编辑/块级操作)仍由 `dws doc` 承担:
>- 用户说"读某个知识库里的某篇文档" → 先 `node list` 拿到 nodeId,再走 **`dws doc read --node <nodeId>`**
+103 -1
View File
@@ -44,6 +44,84 @@ func TestEvalDispatchWorkflowUsesRepositoryPermissionAndReviewedSHA(t *testing.T
}
}
func TestEvalDispatchWorkflowCanWritePRConversationComments(t *testing.T) {
t.Parallel()
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "eval-dispatch.yml"))
if err != nil {
t.Fatalf("read eval-dispatch workflow: %v", err)
}
workflow := string(data)
dispatchStart := strings.Index(workflow, "jobs:\n dispatch:")
if dispatchStart < 0 {
t.Fatal("eval-dispatch workflow missing dispatch job")
}
stepsOffset := strings.Index(workflow[dispatchStart:], "\n steps:")
if stepsOffset < 0 {
t.Fatal("eval-dispatch workflow missing dispatch steps")
}
permissions := workflow[dispatchStart : dispatchStart+stepsOffset]
if !containsTrimmedLine(permissions, "pull-requests: write") {
t.Error("dispatch job must grant write permission for PR conversation comments")
}
if containsTrimmedLine(permissions, "pull-requests: read") {
t.Error("dispatch job still limits pull requests to read-only")
}
if containsTrimmedLine(permissions, "issues: write") {
t.Error("dispatch job must keep a single write domain instead of granting issue-wide writes")
}
commentWrites := []struct {
step string
method string
endpoint string
}{
{
step: "Reply usage on parse failure",
method: "POST",
endpoint: "issues/${PR_NUMBER}/comments",
},
{
step: "Create dispatch placeholder",
method: "POST",
endpoint: "issues/${PR_NUMBER}/comments",
},
{
step: "Finalize dispatch marker",
method: "PATCH",
endpoint: "issues/comments/${DISPATCH_COMMENT_ID}",
},
{
step: "Mark dispatch preparation failure",
method: "PATCH",
endpoint: "issues/comments/${DISPATCH_COMMENT_ID}",
},
}
for _, write := range commentWrites {
write := write
t.Run(write.step, func(t *testing.T) {
t.Parallel()
step := evalDispatchWorkflowStep(t, workflow, write.step)
if !strings.Contains(step, "gh api --method "+write.method) {
t.Errorf("step %q must use gh api so GitHub's HTTP error message remains visible", write.step)
}
if !strings.Contains(step, write.endpoint) {
t.Errorf("step %q missing comment endpoint %q", write.step, write.endpoint)
}
for _, forbidden := range []string{"curl --fail", "Authorization: Bearer", "2>/dev/null"} {
if strings.Contains(step, forbidden) {
t.Errorf("step %q hides or manually transports API diagnostics via %q", write.step, forbidden)
}
}
})
}
}
func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) {
t.Parallel()
@@ -102,7 +180,7 @@ func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) {
"ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}",
"ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}",
"<!-- eval-dispatch: ${marker_json} -->",
"-X PATCH",
"gh api --method PATCH",
"issues/comments/${DISPATCH_COMMENT_ID}",
} {
if !strings.Contains(workflow, want) {
@@ -120,6 +198,30 @@ func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) {
}
}
func evalDispatchWorkflowStep(t *testing.T, workflow, name string) string {
t.Helper()
marker := " - name: " + name + "\n"
start := strings.Index(workflow, marker)
if start < 0 {
t.Fatalf("eval-dispatch workflow missing step %q", name)
}
rest := workflow[start+len(marker):]
if end := strings.Index(rest, "\n - name: "); end >= 0 {
return rest[:end]
}
return rest
}
func containsTrimmedLine(text, want string) bool {
for _, line := range strings.Split(text, "\n") {
if strings.TrimSpace(line) == want {
return true
}
}
return false
}
func TestEvalPollValidatePython(t *testing.T) {
t.Parallel()
+488
View File
@@ -0,0 +1,488 @@
#!/usr/bin/env node
/**
* install_js_smoke.mjs — smoke test for build/npm/install.js (npm postinstall).
*
* Runs the REAL build/npm/install.js against a staged fake package:
*
* <tmp>/pkg/
* install.js (copied from build/npm/install.js)
* assets/
* dws-<os>-<arch>.tar.gz (dummy archive holding a fake `dws` binary)
* dws-skills.zip (tiny release-layout fixture built on the fly,
* NOT the real skills/ tree)
*
* Scenarios (each with an isolated fake HOME):
* 1. multi install — dingtalk-* and dws-shared land as sibling
* skills, mono leftover dws/ and stale
* dingtalk-* are removed, and the
* ~/.dws/skills/{multi,mono} caches fill.
* 2. empty multi/ tree — warns and falls back to mono instead of
* crashing postinstall; a previously good multi
* cache is NOT wiped.
* 3. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an
* "invalid DWS_SKILL_MODE" error.
* 4. multi-only zip, mono — mono install is skipped with a warning; the
* staging root is NOT copied into a dws/ dir.
* 5. multi backup failure — preserves mono, writes no multi skill, and
* reports postinstall failure.
* 6. mono backup failure — preserves multi, writes no mono skill, and
* reports postinstall failure.
* 7. mono switch — migrates only centrally owned multi Skills.
* 8. cache copy failure — preserves the previous complete cache.
* 9. multi publish failure — restores the complete previous Skill set.
* 10. multi backup failure — restores every earlier successful backup.
* 11. mono transaction failure — restores every managed multi Skill after
* later backup or mono publish failure.
*
* Requirements: unix host with tar/zip/unzip on PATH (the same tools
* install.js itself shells out to). Skips cleanly on win32.
*
* Usage (standalone; there is intentionally no Go test harness for the npm
* installer — test/scripts/install_script_test.go only execs POSIX sh):
*
* node test/scripts/install_js_smoke.mjs # self-contained, <10s
*/
import assert from "node:assert/strict";
import childProcess from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import url from "node:url";
const PLATFORM_MAP = {
"darwin-x64": "dws-darwin-amd64.tar.gz",
"darwin-arm64": "dws-darwin-arm64.tar.gz",
"linux-x64": "dws-linux-amd64.tar.gz",
"linux-arm64": "dws-linux-arm64.tar.gz",
};
const repoRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..", "..");
const installJsSource = path.join(repoRoot, "build", "npm", "install.js");
const require = createRequire(import.meta.url);
const {
publishCacheAtomically,
publishManagedMonoSkillSetAtomically,
publishManagedMultiSkillSetAtomically,
} = require(installJsSource);
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
if (process.platform === "win32" || !assetName) {
console.log(`SKIP: install.js smoke test needs a unix host with tar/zip/unzip (got ${process.platform}-${process.arch})`);
process.exit(0);
}
for (const tool of ["tar", "zip", "unzip"]) {
try {
childProcess.execFileSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" });
} catch {
console.log(`SKIP: required tool not on PATH: ${tool}`);
process.exit(0);
}
}
function sh(command, args, options = {}) {
childProcess.execFileSync(command, args, { stdio: "ignore", ...options });
}
function writeFile(filePath, content, mode = 0o644) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, { mode });
}
function writeManagedState(home, names) {
const state = {
version: "old",
official_skills: names,
updated_skills: names,
managed_skills: names.map((name) => ({
name,
version: "old",
source: "test",
digest: `sha256:${"0".repeat(64)}`,
digest_scope: "skill-directory-v1",
})),
updated_at: "2026-01-01T00:00:00Z",
};
writeFile(path.join(home, ".dws", "skills-state.json"), `${JSON.stringify(state, null, 2)}\n`);
}
// stagePkg builds a fake npm package whose assets/dws-skills.zip contains
// exactly the given zip entries ({ "relative/path": "content" }) plus any
// listed empty directories. Returns { tmp, pkg, home }.
function stagePkg(zipEntries, emptyDirs = []) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-smoke-"));
const pkg = path.join(tmp, "pkg");
const assets = path.join(pkg, "assets");
fs.mkdirSync(assets, { recursive: true });
fs.copyFileSync(installJsSource, path.join(pkg, "install.js"));
const binStage = path.join(tmp, "bin-stage");
writeFile(path.join(binStage, "dws"), "#!/bin/sh\necho fake-dws\n", 0o755);
sh("tar", ["-czf", path.join(assets, assetName), "-C", binStage, "."]);
const zipStage = path.join(tmp, "zip-stage");
for (const [rel, content] of Object.entries(zipEntries)) {
writeFile(path.join(zipStage, rel), content);
}
for (const dir of emptyDirs) {
fs.mkdirSync(path.join(zipStage, dir), { recursive: true });
}
sh("zip", ["-qr", path.join(assets, "dws-skills.zip"), "."], { cwd: zipStage });
return { tmp, pkg, home: path.join(tmp, "home") };
}
function runInstall(pkg, home, skillMode) {
const env = { ...process.env, HOME: home };
if (skillMode !== undefined) {
env.DWS_SKILL_MODE = skillMode;
} else {
delete env.DWS_SKILL_MODE;
}
return childProcess.spawnSync(process.execPath, [path.join(pkg, "install.js")], {
env,
encoding: "utf8",
});
}
const scenarios = [];
function scenario(name, fn) {
scenarios.push([name, fn]);
}
scenario("multi install lays out sibling skills and caches", () => {
const { tmp, pkg, home } = stagePkg({
"SKILL.md": "# mono root copy\n",
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
"multi/dingtalk-test/references/guide.md": "guide\n",
"multi/dws-shared/SKILL.md": "# dws-shared\n",
});
try {
// Pre-existing state the multi install must reconcile.
writeFile(path.join(home, ".agents", "skills", "dws", "SKILL.md"), "old mono\n");
writeFile(path.join(home, ".agents", "skills", "dingtalk-stale", "SKILL.md"), "stale\n");
writeManagedState(home, ["dingtalk-stale"]);
writeFile(path.join(home, ".agents", "skills", "dingtalk-custom", "SKILL.md"), "market skill\n");
writeFile(path.join(home, ".agents", "skills", "other-skill", "SKILL.md"), "not dws\n");
const res = runInstall(pkg, home, undefined); // default mode = multi
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stdout, /Skill mode: multi/);
const base = path.join(home, ".agents", "skills");
assert.ok(fs.existsSync(path.join(base, "dingtalk-test", "SKILL.md")), "dingtalk-test installed");
assert.ok(fs.existsSync(path.join(base, "dingtalk-test", "references", "guide.md")), "references copied");
assert.ok(fs.existsSync(path.join(base, "dws-shared", "SKILL.md")), "dws-shared installed");
assert.ok(!fs.existsSync(path.join(base, "dws")), "mono leftover removed");
assert.ok(!fs.existsSync(path.join(base, "dingtalk-stale")), "stale skill removed");
assert.equal(fs.readFileSync(path.join(base, "dingtalk-custom", "SKILL.md"), "utf8"), "market skill\n", "unregistered dingtalk-* skill preserved");
const state = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8"));
const provenance = state.managed_skills.find((record) => record.name === "dingtalk-test");
assert.equal(provenance.source, "npm-postinstall");
assert.match(provenance.digest, /^sha256:[0-9a-f]{64}$/);
assert.equal(provenance.digest_scope, "skill-directory-v1");
assert.ok(fs.existsSync(path.join(base, "other-skill", "SKILL.md")), "non-DWS skill preserved");
assert.ok(fs.existsSync(path.join(home, ".dws", "skills", "multi", "dingtalk-test", "SKILL.md")), "multi cache filled");
assert.equal(fs.readFileSync(path.join(home, ".dws", "skills", "mono", "SKILL.md"), "utf8"), "# mono fixture\n", "mono cache from mono/ tree");
assert.ok(fs.existsSync(path.join(pkg, "vendor", "dws")), "binary installed into vendor/");
} 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",
"mono/SKILL.md": "# mono fixture\n",
// Corrupt multi tree: a product subdir without SKILL.md.
"multi/dingtalk-broken/references/guide.md": "orphan\n",
});
try {
writeFile(path.join(home, ".agents", "skills", "dws", "SKILL.md"), "old mono\n");
// A previously good multi cache must survive an empty/corrupt bundle.
writeFile(path.join(home, ".dws", "skills", "multi", "dingtalk-good", "SKILL.md"), "good cache\n");
const res = runInstall(pkg, home, "multi");
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stdout, /falling back to mono/);
const base = path.join(home, ".agents", "skills");
assert.equal(fs.readFileSync(path.join(base, "dws", "SKILL.md"), "utf8"), "# mono fixture\n", "mono installed from mono/ tree");
assert.ok(!fs.existsSync(path.join(base, "dingtalk-broken")), "broken multi skill not installed");
assert.equal(
fs.readFileSync(path.join(home, ".dws", "skills", "multi", "dingtalk-good", "SKILL.md"), "utf8"),
"good cache\n",
"previously good multi cache must not be wiped",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("bogus DWS_SKILL_MODE fails fast with a clear error", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
});
try {
const res = runInstall(pkg, home, "bogus");
assert.notEqual(res.status, 0, "bogus mode must exit non-zero");
assert.match(res.stderr, /invalid DWS_SKILL_MODE/);
assert.ok(!fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-test")), "nothing installed on mode error");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("multi-only zip in mono mode skips skill install instead of copying staging root", () => {
const { tmp, pkg, home } = stagePkg({
// No root SKILL.md and no mono/ tree — a multi-only release layout.
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
"multi/dws-shared/SKILL.md": "# dws-shared\n",
});
try {
const res = runInstall(pkg, home, "mono");
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stdout, /mono skill tree not found.*skipping skill install/s);
const base = path.join(home, ".agents", "skills");
assert.ok(!fs.existsSync(path.join(base, "dws")), "staging root must not be copied into dws/");
assert.ok(!fs.existsSync(path.join(home, ".dws", "skills", "mono")), "mono cache not refreshed without a mono tree");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("multi backup failure preserves mono and reports failure", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
"multi/dws-shared/SKILL.md": "# dws-shared\n",
});
try {
const base = path.join(home, ".agents", "skills");
writeFile(path.join(base, "dws", "SKILL.md"), "old mono\n");
// Poison the backup root: mkdirSync(<file>/<stamp>) must fail.
writeFile(path.join(home, ".dws", "skill-backups"), "not a directory\n");
const res = runInstall(pkg, home, "multi");
assert.notEqual(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stderr, /未安装任何 multi Skill/);
assert.equal(fs.readFileSync(path.join(base, "dws", "SKILL.md"), "utf8"), "old mono\n");
assert.ok(!fs.existsSync(path.join(base, "dingtalk-test")), "product skill not installed after cleanup failure");
assert.ok(!fs.existsSync(path.join(base, "dws-shared")), "shared skill not installed after cleanup failure");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("mono backup failure preserves multi and reports failure", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
});
try {
const base = path.join(home, ".agents", "skills");
writeFile(path.join(base, "dingtalk-test", "SKILL.md"), "old multi\n");
writeManagedState(home, ["dingtalk-test"]);
writeFile(path.join(home, ".dws", "skill-backups"), "not a directory\n");
const res = runInstall(pkg, home, "mono");
assert.notEqual(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stderr, /未安装任何 mono Skill/);
assert.equal(fs.readFileSync(path.join(base, "dingtalk-test", "SKILL.md"), "utf8"), "old multi\n");
assert.ok(!fs.existsSync(path.join(base, "dws")), "mono not installed after cleanup failure");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("mono switch migrates exact pre-state official skills", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
});
try {
const base = path.join(home, ".agents", "skills");
writeFile(path.join(base, "dingtalk-aitable", "SKILL.md"), "legacy official\n");
writeFile(path.join(base, "dingtalk-custom", "SKILL.md"), "market skill\n");
writeManagedState(home, ["dingtalk-aitable"]);
const res = runInstall(pkg, home, "mono");
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.ok(!fs.existsSync(path.join(base, "dingtalk-aitable")), "pre-state official skill removed");
assert.equal(fs.readFileSync(path.join(base, "dingtalk-custom", "SKILL.md"), "utf8"), "market skill\n");
assert.ok(fs.existsSync(path.join(base, "dws", "SKILL.md")), "mono installed");
assert.ok(!fs.existsSync(path.join(home, ".dws", "skills-state.json")), "mono clears centralized multi state");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("cache copy failure preserves the previous complete cache", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-cache-"));
const source = path.join(tmp, "source");
const cache = path.join(tmp, "skills", "multi");
try {
writeFile(path.join(source, "dingtalk-new", "SKILL.md"), "new cache\n");
writeFile(path.join(cache, "dingtalk-old", "SKILL.md"), "old cache\n");
assert.throws(
() =>
publishCacheAtomically(source, cache, (_src, staged) => {
writeFile(path.join(staged, "partial", "SKILL.md"), "partial\n");
throw new Error("injected cache copy failure");
}),
/injected cache copy failure/,
);
assert.equal(fs.readFileSync(path.join(cache, "dingtalk-old", "SKILL.md"), "utf8"), "old cache\n");
assert.ok(!fs.existsSync(path.join(cache, "dingtalk-new")), "failed refresh must not publish new cache");
assert.ok(
!fs.readdirSync(path.dirname(cache)).some((name) => name.startsWith(".multi.tmp-")),
"failed refresh must clean its staging directory",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("multi set publish failure restores the complete previous set", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-"));
const home = path.join(tmp, "home");
const source = path.join(tmp, "multi");
const base = path.join(home, ".agents", "skills");
const first = path.join(base, "dingtalk-first");
const second = path.join(base, "dingtalk-second");
try {
writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n");
writeFile(path.join(source, "dingtalk-second", "SKILL.md"), "new second\n");
writeFile(path.join(first, "SKILL.md"), "old first\n");
writeFile(path.join(second, "SKILL.md"), "old second\n");
const originalRename = fs.renameSync;
assert.throws(
() =>
publishManagedMultiSkillSetAtomically(
home,
source,
base,
["dingtalk-first", "dingtalk-second"],
[first, second],
{
renameFn(src, dest) {
if (src.includes(".dws-multi-set.tmp-") && path.basename(src) === "dingtalk-second") {
throw new Error("injected second publish failure");
}
originalRename(src, dest);
},
},
),
/injected second publish failure/,
);
assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old first\n");
assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old second\n");
assert.ok(
!fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")),
"failed publish must clean the multi-set staging directory",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("multi set backup failure restores earlier backups", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-backup-"));
const home = path.join(tmp, "home");
const source = path.join(tmp, "multi");
const base = path.join(home, ".agents", "skills");
const first = path.join(base, "dingtalk-first");
const second = path.join(base, "dingtalk-second");
try {
writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n");
writeFile(path.join(source, "dingtalk-second", "SKILL.md"), "new second\n");
writeFile(path.join(first, "SKILL.md"), "old first\n");
writeFile(path.join(second, "SKILL.md"), "old second\n");
const originalRename = fs.renameSync;
assert.throws(
() =>
publishManagedMultiSkillSetAtomically(
home,
source,
base,
["dingtalk-first", "dingtalk-second"],
[first, second],
{
renameFn(src, dest) {
if (src === second) {
throw new Error("injected second backup failure");
}
originalRename(src, dest);
},
},
),
/failed to back up Skill directory/,
);
assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old first\n");
assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old second\n");
assert.ok(!fs.existsSync(path.join(base, "dingtalk-first", "new-only")));
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
for (const failureKind of ["backup", "publish"]) {
scenario(`mono set ${failureKind} failure restores the complete previous set`, () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-mono-set-"));
const home = path.join(tmp, "home");
const source = path.join(tmp, "mono");
const base = path.join(home, ".agents", "skills");
const first = path.join(base, "dingtalk-first");
const second = path.join(base, "dingtalk-second");
const dest = path.join(base, "dws");
try {
writeFile(path.join(source, "SKILL.md"), "new mono\n");
writeFile(path.join(first, "SKILL.md"), "old first\n");
writeFile(path.join(second, "SKILL.md"), "old second\n");
const originalRename = fs.renameSync;
assert.throws(
() =>
publishManagedMonoSkillSetAtomically(home, source, base, [dest, first, second], {
renameFn(src, target) {
if (failureKind === "backup" && src === second) {
throw new Error("injected second backup failure");
}
if (
failureKind === "publish" &&
src.includes(".dws-mono-set.tmp-") &&
path.basename(src) === "dws"
) {
throw new Error("injected mono publish failure");
}
originalRename(src, target);
},
}),
/injected|failed to back up/,
);
assert.equal(fs.readFileSync(path.join(first, "SKILL.md"), "utf8"), "old first\n");
assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old second\n");
assert.ok(!fs.existsSync(dest), "failed mono transaction must not expose dws/");
assert.ok(
!fs.readdirSync(base).some((name) => name.startsWith(".dws-mono-set.tmp-")),
"failed mono transaction must clean its staging directory",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
}
for (const [name, fn] of scenarios) {
process.stdout.write(`• ${name} ... `);
fn();
process.stdout.write("ok\n");
}
console.log(`OK — ${scenarios.length} install.js smoke scenarios passed`);
File diff suppressed because it is too large Load Diff
+30 -17
View File
@@ -17,22 +17,22 @@ import (
)
var expectedPackagedSkillTargets = []string{
".agents/skills/dws",
".claude/skills/dws",
".cursor/skills/dws",
".qoder/skills/dws",
".qoderwork/skills/dws",
".gemini/skills/dws",
".codex/skills/dws",
".github/skills/dws",
".windsurf/skills/dws",
".augment/skills/dws",
".cline/skills/dws",
".amp/skills/dws",
".kiro/skills/dws",
".trae/skills/dws",
".openclaw/skills/dws",
".hermes/skills/dws",
".agents/skills/dingtalk-shared",
".claude/skills/dingtalk-shared",
".cursor/skills/dingtalk-shared",
".qoder/skills/dingtalk-shared",
".qoderwork/skills/dingtalk-shared",
".gemini/skills/dingtalk-shared",
".codex/skills/dingtalk-shared",
".github/skills/dingtalk-shared",
".windsurf/skills/dingtalk-shared",
".augment/skills/dingtalk-shared",
".cline/skills/dingtalk-shared",
".amp/skills/dingtalk-shared",
".kiro/skills/dingtalk-shared",
".trae/skills/dingtalk-shared",
".openclaw/skills/dingtalk-shared",
".hermes/skills/dingtalk-shared",
}
var expectedReleaseAdmissionContexts = []string{
@@ -68,6 +68,19 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) {
if strings.Contains(script, `strings "$vendor_bin"`) || strings.Contains(script, `strings "$prefix/bin/dws"`) {
t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line")
}
for _, want := range []string{
"HOME_SKILL_BASES=",
`$base/dingtalk-shared/SKILL.md`,
`$base/dingtalk-misc/SKILL.md`,
"unexpected mono Skill layout",
} {
if !strings.Contains(script, want) {
t.Errorf("package-manager verifier is missing multi-layout contract %q", want)
}
}
if strings.Contains(script, "HOME_SKILL_TARGETS=") {
t.Fatal("package-manager verifier still declares the legacy mono target contract")
}
}
func seedDistArchive(t *testing.T, path string) {
@@ -277,7 +290,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
}
npmInstallText := string(npmInstallData)
for _, target := range expectedPackagedSkillTargets {
agentDir := strings.TrimSuffix(target, "/dws")
agentDir := strings.TrimSuffix(target, "/dingtalk-shared")
if !strings.Contains(npmInstallText, agentDir) {
t.Fatalf("npm install.js missing %q:\n%s", agentDir, npmInstallText)
}