From 5ce7cb61b0d5aec47e7379be68a27f10827e6f86 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?=
<47820304+PeterGuy326@users.noreply.github.com>
Date: Tue, 26 May 2026 09:17:37 +0800
Subject: [PATCH] feat(skill): dws skill setup with mono/multi modes (#332)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增 `dws skill setup` 交互式安装命令,支持单产品(mono)和多产品(multi,标记为试验版)两种 skill 布局,并与开源 dws v1.0.30 命令对齐。
主要变更:
- 骨架:skills/ 拆为 skills/mono/ 和 skills/multi/(29 个产品 skill),git rename 保留历史
- 命令:新增 `skill setup` cobra 子命令,支持 --mode、--target、--source、--yes,huh 交互
- multi 模式支持 -s/--skill、-x/--exclude 按产品选择
- 覆盖全部 14 个 agent home 目录(与 install.sh 一致)
- 发布:install.sh / install.ps1 / post-goreleaser.sh 改读 skills/mono;dws-skills.zip 同时包含 mono 和 multi 内容;~/.dws 本地缓存
- 内容对齐:删除 8 个仅内部使用的产品 skill、dingtalk-a2a;修正 mono/multi 共 15+ 处命令引用以匹配 OSS dws v1.0.30;chat skill 从 dws-wukong 回移更新
- 漂移清理:物理删除 skill 文档中 OSS 未暴露的命令,true-fail 命令转为 caveat;--help diff 100% 覆盖审计
- 测试:新增 Layer A 静态命令调度验证 + Layer B 端到端 mock 调度验证
- 同步 main:drive 9 个命令同步到 mono/multi skill
---
README.md | 74 +-
README_zh.md | 74 +-
build/npm/install.js | 39 +-
go.mod | 26 +
go.sum | 53 +
internal/app/skill_command.go | 74 +-
internal/app/skill_command_test.go | 31 +-
internal/app/skill_setup.go | 647 ++++++
internal/app/skill_setup_test.go | 559 +++++
scripts/install-skills.sh | 23 +-
scripts/install.ps1 | 137 +-
scripts/install.sh | 157 +-
scripts/release/post-goreleaser.sh | 23 +-
skills/{ => mono}/LICENSE | 0
skills/{ => mono}/NOTICE | 0
skills/{ => mono}/SKILL.md | 3 +
skills/{ => mono}/references/error-codes.md | 0
skills/{ => mono}/references/field-rules.md | 0
.../{ => mono}/references/global-reference.md | 0
skills/{ => mono}/references/intent-guide.md | 0
.../{ => mono}/references/products/aitable.md | 0
.../references/products/attendance.md | 0
.../references/products/calendar.md | 0
.../references/products/chat-emoji-list.md | 209 ++
skills/mono/references/products/chat.md | 1420 +++++++++++++
.../{ => mono}/references/products/contact.md | 0
.../{ => mono}/references/products/devdoc.md | 0
skills/{ => mono}/references/products/ding.md | 0
skills/{ => mono}/references/products/doc.md | 0
.../{ => mono}/references/products/drive.md | 65 +-
skills/{ => mono}/references/products/mail.md | 2 +-
.../{ => mono}/references/products/minutes.md | 0
skills/{ => mono}/references/products/oa.md | 22 +-
.../{ => mono}/references/products/report.md | 4 +-
skills/mono/references/products/sheet.md | 235 +++
.../{ => mono}/references/products/simple.md | 8 +-
skills/{ => mono}/references/products/todo.md | 0
skills/{ => mono}/references/products/wiki.md | 12 +-
.../{ => mono}/references/recovery-guide.md | 0
skills/{ => mono}/references/url-patterns.md | 2 +-
.../scripts/attendance_my_record.py | 0
.../scripts/attendance_team_shift.py | 0
skills/{ => mono}/scripts/bulk_add_fields.py | 0
.../scripts/calendar_free_slot_finder.py | 0
.../scripts/calendar_schedule_meeting.py | 0
.../scripts/calendar_today_agenda.py | 0
skills/mono/scripts/chat_export_messages.py | 193 ++
skills/mono/scripts/chat_history_with_user.py | 194 ++
.../scripts/contact_dept_members.py | 0
skills/mono/scripts/doc_create_and_write.py | 181 ++
skills/{ => mono}/scripts/import_records.py | 0
skills/mono/scripts/minutes_extract_todos.py | 124 ++
skills/mono/scripts/minutes_recent_summary.py | 118 ++
.../{ => mono}/scripts/report_inbox_today.py | 0
.../{ => mono}/scripts/todo_batch_create.py | 0
.../{ => mono}/scripts/todo_daily_summary.py | 0
.../{ => mono}/scripts/todo_overdue_check.py | 0
.../{ => mono}/scripts/upload_attachment.py | 0
skills/multi/dingtalk-aiapp/SKILL.md | 35 +
.../multi/dingtalk-aiapp/references/aiapp.md | 420 ++++
.../scripts/aiapp_create_and_poll.py | 138 ++
skills/multi/dingtalk-aisearch/SKILL.md | 48 +
.../dingtalk-aisearch/references/aisearch.md | 74 +
skills/multi/dingtalk-aitable/SKILL.md | 62 +
.../references/06-data-analytics.md | 11 +
.../dingtalk-aitable/references/aitable.md | 203 ++
.../references/aitable/aitable-attachment.md | 47 +
.../aitable/aitable-best-practices.md | 46 +
.../references/aitable/aitable-cell-value.md | 342 ++++
.../aitable/aitable-dashboard-chart.md | 43 +
.../aitable/aitable-data-analysis-sop.md | 130 ++
.../aitable/aitable-error-recovery.md | 128 ++
.../aitable/aitable-export-import.md | 57 +
.../aitable/aitable-field-properties.md | 238 +++
.../references/aitable/aitable-field.md | 100 +
.../references/aitable/aitable-filter-sort.md | 85 +
.../aitable/aitable-formula-guide.md | 224 ++
.../aitable/aitable-record-create.md | 45 +
.../aitable/aitable-record-delete.md | 20 +
.../aitable/aitable-record-query.md | 74 +
.../aitable/aitable-record-update.md | 42 +
.../references/field-rules.md | 128 ++
.../scripts/aitable_export_via_task.py | 210 ++
.../scripts/aitable_import_via_task.py | 173 ++
.../scripts/bulk_add_fields.py | 250 +++
.../scripts/import_records.py | 328 +++
.../scripts/upload_attachment.py | 190 ++
skills/multi/dingtalk-attendance/SKILL.md | 46 +
.../references/attendance.md | 112 +
.../scripts/attendance_my_record.py | 91 +
.../scripts/attendance_team_shift.py | 89 +
skills/multi/dingtalk-calendar/SKILL.md | 54 +
.../references/03-meeting.md | 56 +
.../dingtalk-calendar/references/calendar.md | 515 +++++
.../scripts/calendar_free_slot_finder.py | 199 ++
.../scripts/calendar_schedule_meeting.py | 233 +++
.../scripts/calendar_today_agenda.py | 139 ++
skills/multi/dingtalk-chat/SKILL.md | 46 +
.../dingtalk-chat/references/01-messaging.md | 15 +
.../references/chat-emoji-list.md | 209 ++
skills/multi/dingtalk-chat/references/chat.md | 1421 +++++++++++++
.../dingtalk-chat/scripts/bot_broadcast.py | 112 +
.../scripts/chat_export_messages.py | 193 ++
.../scripts/chat_history_with_user.py | 194 ++
.../dingtalk-chat/scripts/extract_media_id.py | 87 +
skills/multi/dingtalk-contact/SKILL.md | 48 +
.../references/08-directory.md | 50 +
.../dingtalk-contact/references/contact.md | 276 +++
.../scripts/contact_dept_members.py | 130 ++
skills/multi/dingtalk-devdoc/SKILL.md | 36 +
.../dingtalk-devdoc/references/devdoc.md | 23 +
skills/multi/dingtalk-ding/SKILL.md | 38 +
skills/multi/dingtalk-ding/references/ding.md | 63 +
skills/multi/dingtalk-doc/SKILL.md | 69 +
.../dingtalk-doc/references/04-document.md | 71 +
skills/multi/dingtalk-doc/references/doc.md | 910 +++++++++
.../dingtalk-doc/references/url-patterns.md | 127 ++
.../scripts/doc_create_and_write.py | 181 ++
skills/multi/dingtalk-drive/SKILL.md | 52 +
.../multi/dingtalk-drive/references/drive.md | 278 +++
.../dingtalk-drive/scripts/drive_tree_list.py | 130 ++
skills/multi/dingtalk-live/SKILL.md | 30 +
skills/multi/dingtalk-live/references/live.md | 13 +
skills/multi/dingtalk-mail/SKILL.md | 44 +
.../multi/dingtalk-mail/references/09-mail.md | 35 +
skills/multi/dingtalk-mail/references/mail.md | 230 +++
.../scripts/mail_send_with_cc.py | 122 ++
.../scripts/mail_unread_summary.py | 114 ++
skills/multi/dingtalk-minutes/SKILL.md | 52 +
.../dingtalk-minutes/references/07-minutes.md | 11 +
.../dingtalk-minutes/references/minutes.md | 1808 +++++++++++++++++
.../scripts/minutes_extract_todos.py | 124 ++
.../scripts/minutes_list_parse.py | 65 +
.../scripts/minutes_recent_summary.py | 118 ++
skills/multi/dingtalk-oa/SKILL.md | 44 +
skills/multi/dingtalk-oa/references/oa.md | 192 ++
.../dingtalk-oa/scripts/oa_batch_approve.py | 158 ++
.../dingtalk-oa/scripts/oa_pending_review.py | 132 ++
skills/multi/dingtalk-report/SKILL.md | 47 +
.../references/05-reporting.md | 29 +
.../dingtalk-report/references/report.md | 395 ++++
.../scripts/report_received_today.py | 128 ++
skills/multi/dingtalk-sheet/SKILL.md | 59 +
.../multi/dingtalk-sheet/references/sheet.md | 1261 ++++++++++++
.../dingtalk-sheet/references/url-patterns.md | 127 ++
skills/multi/dingtalk-skill/SKILL.md | 40 +
.../multi/dingtalk-skill/references/skill.md | 110 +
skills/multi/dingtalk-todo/SKILL.md | 56 +
.../multi/dingtalk-todo/references/02-task.md | 19 +
skills/multi/dingtalk-todo/references/todo.md | 354 ++++
.../scripts/todo_batch_create.py | 159 ++
.../scripts/todo_daily_summary.py | 178 ++
.../scripts/todo_overdue_check.py | 132 ++
skills/multi/dingtalk-wiki/SKILL.md | 46 +
skills/multi/dingtalk-wiki/references/wiki.md | 199 ++
skills/references/products/chat.md | 771 -------
skills/references/products/sheet.md | 304 ---
skills/references/products/workbench.md | 41 -
test/scripts/install_script_test.go | 185 +-
test/scripts/package_script_test.go | 90 +-
test/skill_e2e/skill_e2e_test.go | 412 ++++
test/skill_static/skill_static_test.go | 344 ++++
162 files changed, 22471 insertions(+), 1200 deletions(-)
create mode 100644 internal/app/skill_setup.go
create mode 100644 internal/app/skill_setup_test.go
rename skills/{ => mono}/LICENSE (100%)
rename skills/{ => mono}/NOTICE (100%)
rename skills/{ => mono}/SKILL.md (96%)
rename skills/{ => mono}/references/error-codes.md (100%)
rename skills/{ => mono}/references/field-rules.md (100%)
rename skills/{ => mono}/references/global-reference.md (100%)
rename skills/{ => mono}/references/intent-guide.md (100%)
rename skills/{ => mono}/references/products/aitable.md (100%)
rename skills/{ => mono}/references/products/attendance.md (100%)
rename skills/{ => mono}/references/products/calendar.md (100%)
create mode 100644 skills/mono/references/products/chat-emoji-list.md
create mode 100644 skills/mono/references/products/chat.md
rename skills/{ => mono}/references/products/contact.md (100%)
rename skills/{ => mono}/references/products/devdoc.md (100%)
rename skills/{ => mono}/references/products/ding.md (100%)
rename skills/{ => mono}/references/products/doc.md (100%)
rename skills/{ => mono}/references/products/drive.md (69%)
rename skills/{ => mono}/references/products/mail.md (97%)
rename skills/{ => mono}/references/products/minutes.md (100%)
rename skills/{ => mono}/references/products/oa.md (89%)
rename skills/{ => mono}/references/products/report.md (97%)
create mode 100644 skills/mono/references/products/sheet.md
rename skills/{ => mono}/references/products/simple.md (92%)
rename skills/{ => mono}/references/products/todo.md (100%)
rename skills/{ => mono}/references/products/wiki.md (92%)
rename skills/{ => mono}/references/recovery-guide.md (100%)
rename skills/{ => mono}/references/url-patterns.md (97%)
rename skills/{ => mono}/scripts/attendance_my_record.py (100%)
rename skills/{ => mono}/scripts/attendance_team_shift.py (100%)
rename skills/{ => mono}/scripts/bulk_add_fields.py (100%)
rename skills/{ => mono}/scripts/calendar_free_slot_finder.py (100%)
rename skills/{ => mono}/scripts/calendar_schedule_meeting.py (100%)
rename skills/{ => mono}/scripts/calendar_today_agenda.py (100%)
create mode 100644 skills/mono/scripts/chat_export_messages.py
create mode 100644 skills/mono/scripts/chat_history_with_user.py
rename skills/{ => mono}/scripts/contact_dept_members.py (100%)
create mode 100644 skills/mono/scripts/doc_create_and_write.py
rename skills/{ => mono}/scripts/import_records.py (100%)
create mode 100644 skills/mono/scripts/minutes_extract_todos.py
create mode 100644 skills/mono/scripts/minutes_recent_summary.py
rename skills/{ => mono}/scripts/report_inbox_today.py (100%)
rename skills/{ => mono}/scripts/todo_batch_create.py (100%)
rename skills/{ => mono}/scripts/todo_daily_summary.py (100%)
rename skills/{ => mono}/scripts/todo_overdue_check.py (100%)
rename skills/{ => mono}/scripts/upload_attachment.py (100%)
create mode 100644 skills/multi/dingtalk-aiapp/SKILL.md
create mode 100644 skills/multi/dingtalk-aiapp/references/aiapp.md
create mode 100644 skills/multi/dingtalk-aiapp/scripts/aiapp_create_and_poll.py
create mode 100644 skills/multi/dingtalk-aisearch/SKILL.md
create mode 100644 skills/multi/dingtalk-aisearch/references/aisearch.md
create mode 100644 skills/multi/dingtalk-aitable/SKILL.md
create mode 100644 skills/multi/dingtalk-aitable/references/06-data-analytics.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-attachment.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-best-practices.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-cell-value.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-data-analysis-sop.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-error-recovery.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-export-import.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-field-properties.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-field.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-filter-sort.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-formula-guide.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-record-create.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-record-delete.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-record-query.md
create mode 100644 skills/multi/dingtalk-aitable/references/aitable/aitable-record-update.md
create mode 100644 skills/multi/dingtalk-aitable/references/field-rules.md
create mode 100644 skills/multi/dingtalk-aitable/scripts/aitable_export_via_task.py
create mode 100644 skills/multi/dingtalk-aitable/scripts/aitable_import_via_task.py
create mode 100644 skills/multi/dingtalk-aitable/scripts/bulk_add_fields.py
create mode 100644 skills/multi/dingtalk-aitable/scripts/import_records.py
create mode 100644 skills/multi/dingtalk-aitable/scripts/upload_attachment.py
create mode 100644 skills/multi/dingtalk-attendance/SKILL.md
create mode 100644 skills/multi/dingtalk-attendance/references/attendance.md
create mode 100644 skills/multi/dingtalk-attendance/scripts/attendance_my_record.py
create mode 100644 skills/multi/dingtalk-attendance/scripts/attendance_team_shift.py
create mode 100644 skills/multi/dingtalk-calendar/SKILL.md
create mode 100644 skills/multi/dingtalk-calendar/references/03-meeting.md
create mode 100644 skills/multi/dingtalk-calendar/references/calendar.md
create mode 100644 skills/multi/dingtalk-calendar/scripts/calendar_free_slot_finder.py
create mode 100644 skills/multi/dingtalk-calendar/scripts/calendar_schedule_meeting.py
create mode 100644 skills/multi/dingtalk-calendar/scripts/calendar_today_agenda.py
create mode 100644 skills/multi/dingtalk-chat/SKILL.md
create mode 100644 skills/multi/dingtalk-chat/references/01-messaging.md
create mode 100644 skills/multi/dingtalk-chat/references/chat-emoji-list.md
create mode 100644 skills/multi/dingtalk-chat/references/chat.md
create mode 100644 skills/multi/dingtalk-chat/scripts/bot_broadcast.py
create mode 100644 skills/multi/dingtalk-chat/scripts/chat_export_messages.py
create mode 100644 skills/multi/dingtalk-chat/scripts/chat_history_with_user.py
create mode 100644 skills/multi/dingtalk-chat/scripts/extract_media_id.py
create mode 100644 skills/multi/dingtalk-contact/SKILL.md
create mode 100644 skills/multi/dingtalk-contact/references/08-directory.md
create mode 100644 skills/multi/dingtalk-contact/references/contact.md
create mode 100644 skills/multi/dingtalk-contact/scripts/contact_dept_members.py
create mode 100644 skills/multi/dingtalk-devdoc/SKILL.md
create mode 100644 skills/multi/dingtalk-devdoc/references/devdoc.md
create mode 100644 skills/multi/dingtalk-ding/SKILL.md
create mode 100644 skills/multi/dingtalk-ding/references/ding.md
create mode 100644 skills/multi/dingtalk-doc/SKILL.md
create mode 100644 skills/multi/dingtalk-doc/references/04-document.md
create mode 100644 skills/multi/dingtalk-doc/references/doc.md
create mode 100644 skills/multi/dingtalk-doc/references/url-patterns.md
create mode 100644 skills/multi/dingtalk-doc/scripts/doc_create_and_write.py
create mode 100644 skills/multi/dingtalk-drive/SKILL.md
create mode 100644 skills/multi/dingtalk-drive/references/drive.md
create mode 100644 skills/multi/dingtalk-drive/scripts/drive_tree_list.py
create mode 100644 skills/multi/dingtalk-live/SKILL.md
create mode 100644 skills/multi/dingtalk-live/references/live.md
create mode 100644 skills/multi/dingtalk-mail/SKILL.md
create mode 100644 skills/multi/dingtalk-mail/references/09-mail.md
create mode 100644 skills/multi/dingtalk-mail/references/mail.md
create mode 100644 skills/multi/dingtalk-mail/scripts/mail_send_with_cc.py
create mode 100644 skills/multi/dingtalk-mail/scripts/mail_unread_summary.py
create mode 100644 skills/multi/dingtalk-minutes/SKILL.md
create mode 100644 skills/multi/dingtalk-minutes/references/07-minutes.md
create mode 100644 skills/multi/dingtalk-minutes/references/minutes.md
create mode 100644 skills/multi/dingtalk-minutes/scripts/minutes_extract_todos.py
create mode 100644 skills/multi/dingtalk-minutes/scripts/minutes_list_parse.py
create mode 100644 skills/multi/dingtalk-minutes/scripts/minutes_recent_summary.py
create mode 100644 skills/multi/dingtalk-oa/SKILL.md
create mode 100644 skills/multi/dingtalk-oa/references/oa.md
create mode 100644 skills/multi/dingtalk-oa/scripts/oa_batch_approve.py
create mode 100644 skills/multi/dingtalk-oa/scripts/oa_pending_review.py
create mode 100644 skills/multi/dingtalk-report/SKILL.md
create mode 100644 skills/multi/dingtalk-report/references/05-reporting.md
create mode 100644 skills/multi/dingtalk-report/references/report.md
create mode 100644 skills/multi/dingtalk-report/scripts/report_received_today.py
create mode 100644 skills/multi/dingtalk-sheet/SKILL.md
create mode 100644 skills/multi/dingtalk-sheet/references/sheet.md
create mode 100644 skills/multi/dingtalk-sheet/references/url-patterns.md
create mode 100644 skills/multi/dingtalk-skill/SKILL.md
create mode 100644 skills/multi/dingtalk-skill/references/skill.md
create mode 100644 skills/multi/dingtalk-todo/SKILL.md
create mode 100644 skills/multi/dingtalk-todo/references/02-task.md
create mode 100644 skills/multi/dingtalk-todo/references/todo.md
create mode 100644 skills/multi/dingtalk-todo/scripts/todo_batch_create.py
create mode 100644 skills/multi/dingtalk-todo/scripts/todo_daily_summary.py
create mode 100644 skills/multi/dingtalk-todo/scripts/todo_overdue_check.py
create mode 100644 skills/multi/dingtalk-wiki/SKILL.md
create mode 100644 skills/multi/dingtalk-wiki/references/wiki.md
delete mode 100644 skills/references/products/chat.md
delete mode 100644 skills/references/products/sheet.md
delete mode 100644 skills/references/products/workbench.md
create mode 100644 test/skill_e2e/skill_e2e_test.go
create mode 100644 test/skill_static/skill_static_test.go
diff --git a/README.md b/README.md
index 371143fe..de1b8f5e 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,27 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
+
+Skill mode: mono vs multi
+
+The installer ships skills in one of two layouts. CLI commands (`dws aitable ...`, `dws calendar ...`) are identical in both modes — only the agent-side skill layout differs.
+
+| Mode | What gets installed | Best for |
+|------|----------------------|----------|
+| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
+| **multi** 🧪 **EXPERIMENTAL** | 20 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
+
+> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 20 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `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.
+
+
+
Other install methods
@@ -230,26 +251,56 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
### Agent Skills
-The repo ships a complete Agent Skill system (`skills/`). After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
+The repo ships a complete Agent Skill system under `skills/`, now 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/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
+
+After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
```bash
-# Install skills into current project
+# Install skills into current project (defaults to mono)
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).
-**What's included:**
+**Switching or re-installing with `dws skill setup`:**
+
+```bash
+# 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
+
+# Install multi skills to a single agent home
+dws skill setup --mode multi --target cursor --yes
+
+# Point at a local source tree (e.g. a fork or work-in-progress)
+DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
+```
+
+| Flag | Values | Description |
+|------|--------|-------------|
+| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
+| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
+| `--source` | path | Local source directory (overrides bundled skills) |
+| `--yes` | — | Skip confirmation prompts |
+
+Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=`.
+
+**What's included (mono layout):**
| Component | Path | Description |
|-----------|------|-------------|
-| Master Skill | `SKILL.md` | Intent routing, decision tree, safety rules, error handling |
-| Product references | `references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
-| Intent guide | `references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
-| Global reference | `references/global-reference.md` | Auth, output formats, global flags |
-| Error codes | `references/error-codes.md` | Error codes + debugging workflows |
-| Recovery guide | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
-| Ready-made scripts | `scripts/*.py` | 13 batch operation scripts (see below) |
+| Master Skill | `skills/mono/SKILL.md` | Intent routing, decision tree, safety rules, error handling |
+| Product references | `skills/mono/references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
+| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
+| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
+| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
+| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
+| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
Ready-made scripts — 13 Python scripts for common multi-step workflows
@@ -430,7 +481,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
Coming soon
-`conference` (video meetings)
+- `conference` (video meetings)
+- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
diff --git a/README_zh.md b/README_zh.md
index 1ef2b472..5d0d861e 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -63,6 +63,27 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
+
+Skill 模式:mono 与 multi
+
+安装时可以选择两种 skill 组织方式。两种模式下 CLI 命令完全一样(`dws aitable ...` / `dws calendar ...`),区别只在 Agent 那边读到的 skill 文档结构。
+
+| 模式 | 安装内容 | 适合场景 |
+|------|----------|----------|
+| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
+| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
+
+> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `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`),随时重跑都行。
+
+
+
其他安装方式
@@ -230,26 +251,56 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
### Agent Skills
-仓库内置完整的 Agent Skill 体系(`skills/`),安装后 Claude Code / Cursor 等 AI 工具可通过自然语言直接操作钉钉:
+仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
+
+- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
+- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 20 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
+
+安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
```bash
-# 安装 skills 到当前项目
+# 安装 skills 到当前项目(默认 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`(当前项目)。
-**包含内容:**
+**用 `dws skill setup` 切换或重装:**
+
+```bash
+# 交互式:提示选模式 + 目标 Agent
+dws skill setup
+
+# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
+dws skill setup --mode mono --target all --yes
+
+# 只装到某一个 Agent home
+dws skill setup --mode multi --target cursor --yes
+
+# 指定本地源目录(比如 fork 或正在改的版本)
+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` | — | 跳过确认提示 |
+
+环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
+
+**包含内容(mono 布局):**
| 组件 | 路径 | 说明 |
|------|------|------|
-| 主 Skill | `SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
-| 产品参考 | `references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
-| 意图指南 | `references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
-| 全局参考 | `references/global-reference.md` | 认证、输出格式、全局 flag |
-| 错误码 | `references/error-codes.md` | 错误码 + 调试流程 |
-| Recovery 指南 | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
-| 现成脚本 | `scripts/*.py` | 13 个批量操作脚本(见下方) |
+| 主 Skill | `skills/mono/SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
+| 产品参考 | `skills/mono/references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
+| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
+| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
+| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
+| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
+| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
现成脚本 — 13 个 Python 脚本,覆盖常见多步工作流
@@ -430,7 +481,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
即将推出
-`conference`(视频会议)
+- `conference`(视频会议)
+- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
diff --git a/build/npm/install.js b/build/npm/install.js
index 2861256e..7935c27e 100755
--- a/build/npm/install.js
+++ b/build/npm/install.js
@@ -136,11 +136,36 @@ function installSkillsToHomes(skillRoot) {
}
}
+// 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.
+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);
+
+ const multiSource = path.join(extractedSkillsRoot, "multi");
+ if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
+ const multiCache = path.join(cacheBase, "multi");
+ fs.rmSync(multiCache, { recursive: true, force: true });
+ copyChildren(multiSource, multiCache);
+ }
+}
+
function main() {
const packageRoot = __dirname;
const assetsDir = path.join(packageRoot, "assets");
const vendorDir = path.join(packageRoot, "vendor");
- const skillDir = path.join(packageRoot, "share", "skills", "dws");
+ // Extract dws-skills.zip into a staging directory so we can split mono/
+ // (installed to agent homes) from multi/ (cached for later setup use).
+ const skillsStaging = path.join(packageRoot, "share", "skills");
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
if (!assetName) {
throw new Error(`unsupported platform: ${process.platform}/${process.arch}`);
@@ -156,8 +181,16 @@ function main() {
}
extractArchive(archivePath, vendorDir);
- extractSkills(skillsPath, skillDir);
- installSkillsToHomes(skillDir);
+ extractSkills(skillsPath, skillsStaging);
+
+ // For backward compatibility, the zip root carries a copy of mono content
+ // (SKILL.md + references/ + scripts/). Prefer the explicit mono/ subdir
+ // when present; fall back to the staging root otherwise.
+ const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
+ ? path.join(skillsStaging, "mono")
+ : skillsStaging;
+ installSkillsToHomes(monoRoot);
+ cacheUserSkills(skillsStaging);
}
main();
diff --git a/go.mod b/go.mod
index d3fd55e5..f9963dc2 100644
--- a/go.mod
+++ b/go.mod
@@ -14,11 +14,37 @@ require (
)
require (
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/catppuccin/go v0.3.0 // indirect
+ github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
+ github.com/charmbracelet/bubbletea v1.3.6 // indirect
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+ github.com/charmbracelet/huh v1.0.0 // indirect
+ github.com/charmbracelet/lipgloss v1.1.0 // indirect
+ github.com/charmbracelet/x/ansi v0.9.3 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
+ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sync v0.20.0 // indirect
)
require (
diff --git a/go.sum b/go.sum
index 4e538ff1..a141d285 100644
--- a/go.sum
+++ b/go.sum
@@ -1,8 +1,40 @@
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
+github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
+github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
+github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
+github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
+github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
+github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
@@ -15,13 +47,29 @@ github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -31,11 +79,16 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go
index 8d1342d0..53e0decb 100644
--- a/internal/app/skill_command.go
+++ b/internal/app/skill_command.go
@@ -24,6 +24,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "sort"
"strings"
"time"
@@ -85,24 +86,80 @@ type CliSkillDTO struct {
// agentSkillPaths maps target names to their relative skill installation paths.
// These paths are relative to the user's home directory.
+//
+// Source of truth for both `dws skill install ` and
+// `dws skill setup --target `. Every entry in skillSetupAgentHomes
+// (skill_setup.go) MUST have a matching path value here — enforced by
+// TestAgentSkillPathsCoversSetupHomes.
var agentSkillPaths = map[string]string{
+ // `agents` is the generic-agent sentinel: install scripts and `setup`
+ // special-case ~/.agents/skills as a no-checks-required fallback so a
+ // fresh machine without any IDE/agent registry still gets skills.
+ "agents": ".agents/skills",
"qoder": ".qoder/skills",
"claude": ".claude/skills",
"cursor": ".cursor/skills",
"codex": ".codex/skills",
"opencode": filepath.Join(".config", "opencode", "skills"),
+ // IDE / agent registries also probed by `dws skill setup --target all`.
+ "gemini": ".gemini/skills",
+ "github": ".github/skills",
+ "windsurf": ".windsurf/skills",
+ "augment": ".augment/skills",
+ "cline": ".cline/skills",
+ "amp": ".amp/skills",
+ "kiro": ".kiro/skills",
+ "trae": ".trae/skills",
+ "openclaw": ".openclaw/skills",
+ "hermes": ".hermes/skills",
}
-// supportedTargets returns a comma-separated list of supported targets.
+// supportedTargets returns a sorted, comma-separated list of supported
+// targets. Sorted so help text and error messages stay stable across runs
+// (Go map iteration order is intentionally randomized).
func supportedTargets() string {
targets := make([]string, 0, len(agentSkillPaths)+1)
for target := range agentSkillPaths {
targets = append(targets, target)
}
+ sort.Strings(targets)
targets = append(targets, ".")
return strings.Join(targets, ", ")
}
+// longestAgentTargetName returns the character count of the longest target
+// name in agentSkillPaths. Used by --help formatting to keep the "." entry
+// vertically aligned with named targets.
+func longestAgentTargetName() int {
+ n := 0
+ for name := range agentSkillPaths {
+ if len(name) > n {
+ n = len(name)
+ }
+ }
+ return n
+}
+
+// formatAgentSkillPathsForHelp renders agentSkillPaths as an aligned
+// " -> ~/" block, sorted by name, for use in --help output.
+// Keeps `dws skill install --help` in sync with the map without hand-edits.
+func formatAgentSkillPathsForHelp() string {
+ names := make([]string, 0, len(agentSkillPaths))
+ maxWidth := 0
+ for n := range agentSkillPaths {
+ names = append(names, n)
+ if len(n) > maxWidth {
+ maxWidth = len(n)
+ }
+ }
+ sort.Strings(names)
+ var b strings.Builder
+ for _, n := range names {
+ fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n])
+ }
+ return b.String()
+}
+
func buildSkillCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "skill",
@@ -122,6 +179,7 @@ func buildSkillCommand() *cobra.Command {
newSkillSearchCommand(),
newSkillFindHintCommand(),
newSkillAddHintCommand(),
+ newSkillSetupCommand(),
)
return cmd
}
@@ -179,17 +237,15 @@ func newSkillInstallCommand() *cobra.Command {
target 安装目标(必填),支持: %s
安装路径:
- qoder -> ~/.qoder/skills/
- claude -> ~/.claude/skills/
- cursor -> ~/.cursor/skills/
- codex -> ~/.codex/skills/
- opencode -> ~/.config/opencode/skills/
- . -> 当前目录
+%s .%s -> 当前目录
示例:
- dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/
dws skill install skill-123 claude # 安装到 ~/.claude/skills/
- dws skill install skill-123 . # 安装到当前目录`, supportedTargets()),
+ dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/
+ dws skill install skill-123 . # 安装到当前目录`,
+ supportedTargets(),
+ formatAgentSkillPathsForHelp(),
+ strings.Repeat(" ", longestAgentTargetName()-1)),
Args: cobra.ExactArgs(2),
DisableAutoGenTag: true,
RunE: runSkillAdd,
diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go
index 4f1c6b55..9daf8c4e 100644
--- a/internal/app/skill_command_test.go
+++ b/internal/app/skill_command_test.go
@@ -452,8 +452,14 @@ func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) {
func TestSupportedTargets(t *testing.T) {
targets := supportedTargets()
- // Should contain all predefined targets
- expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."}
+ // Should contain all predefined targets — including the agents/* sentinel
+ // and the IDE/agent registries we share with skillSetupAgentHomes.
+ expectedTargets := []string{
+ "agents", "claude", "cursor", "codex", "opencode", "qoder",
+ "gemini", "github", "windsurf", "augment", "cline",
+ "amp", "kiro", "trae", "openclaw", "hermes",
+ ".",
+ }
for _, expected := range expectedTargets {
if !strings.Contains(targets, expected) {
t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets)
@@ -461,6 +467,27 @@ func TestSupportedTargets(t *testing.T) {
}
}
+// TestAgentSkillPathsCoversSetupHomes guards against drift between
+// agentSkillPaths (used by `dws skill install` and `dws skill setup
+// --target `) and skillSetupAgentHomes (used by `dws skill setup
+// --target all` to detect candidate agent homes).
+//
+// Every path in skillSetupAgentHomes MUST be reachable via at least one
+// entry in agentSkillPaths — otherwise `--target all` would silently
+// install into agent homes that the user cannot address by name.
+func TestAgentSkillPathsCoversSetupHomes(t *testing.T) {
+ paths := make(map[string]bool, len(agentSkillPaths))
+ for _, p := range agentSkillPaths {
+ paths[p] = true
+ }
+ for _, home := range skillSetupAgentHomes {
+ if !paths[home] {
+ t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+
+ "add it to agentSkillPaths so users can address it via --target ", home)
+ }
+ }
+}
+
func TestAgentSkillPathsCrossPlatform(t *testing.T) {
// Verify that paths use platform-appropriate separators
for target, path := range agentSkillPaths {
diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go
new file mode 100644
index 00000000..4a43f195
--- /dev/null
+++ b/internal/app/skill_setup.go
@@ -0,0 +1,647 @@
+package app
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/huh"
+ "github.com/spf13/cobra"
+)
+
+// skillSetupAgentHomes is the ordered list of agent home subdirectories
+// where dws skills get installed. Mirrors install.sh / install.ps1 /
+// build/npm/install.js so that `dws skill setup` and the install scripts
+// agree on the install footprint.
+var skillSetupAgentHomes = []string{
+ ".agents/skills",
+ ".claude/skills",
+ ".cursor/skills",
+ ".gemini/skills",
+ ".codex/skills",
+ ".github/skills",
+ ".windsurf/skills",
+ ".augment/skills",
+ ".cline/skills",
+ ".amp/skills",
+ ".kiro/skills",
+ ".trae/skills",
+ ".openclaw/skills",
+ ".hermes/skills",
+}
+
+const (
+ skillSetupModeMono = "mono"
+ skillSetupModeMulti = "multi"
+)
+
+func newSkillSetupCommand() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "setup",
+ Short: "安装 dws 自身 skill 到 Agent 目录",
+ Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
+
+支持两种模式:
+ mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
+ multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
+ 尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
+ 生产前请评估,问题请提 issue 反馈
+
+multi 模式支持按产品挑选:
+ -s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
+ -x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
+ 未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
+
+不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。`,
+ Example: ` dws skill setup # 交互式
+ dws skill setup --mode mono --yes # 非交互装 mono
+ dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
+ dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
+ dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个
+ dws skill setup --source /path/to/repo # 显式指定 skill 源`,
+ DisableAutoGenTag: true,
+ RunE: runSkillSetup,
+ }
+ cmd.Flags().String("mode", "", "skill 模式:mono | multi(不指定则交互询问)")
+ cmd.Flags().String("target", "all", "目标 Agent:all | "+supportedTargets())
+ cmd.Flags().String("source", "", "skill 源目录(默认自动查找二进制旁边或当前目录)")
+ cmd.Flags().Bool("yes", false, "跳过所有确认提示")
+ cmd.Flags().StringSliceP("skill", "s", nil, "multi 模式:仅安装指定子 skill(可重复,接受短名 aitable 或全名 dingtalk-aitable)")
+ cmd.Flags().StringSliceP("exclude", "x", nil, "multi 模式:从全装中剔除指定子 skill(可重复,与 --skill 互斥)")
+ return cmd
+}
+
+func runSkillSetup(cmd *cobra.Command, _ []string) error {
+ mode, _ := cmd.Flags().GetString("mode")
+ target, _ := cmd.Flags().GetString("target")
+ source, _ := cmd.Flags().GetString("source")
+ autoYes, _ := cmd.Flags().GetBool("yes")
+ includeRaw, _ := cmd.Flags().GetStringSlice("skill")
+ excludeRaw, _ := cmd.Flags().GetStringSlice("exclude")
+
+ out := cmd.OutOrStdout()
+ errOut := cmd.ErrOrStderr()
+
+ mode, err := resolveSkillSetupMode(mode, autoYes, out)
+ if err != nil {
+ return err
+ }
+
+ if mode == skillSetupModeMono && (len(includeRaw) > 0 || len(excludeRaw) > 0) {
+ return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
+ }
+
+ skillSrc, err := resolveSkillSetupSource(source, mode)
+ if err != nil {
+ return err
+ }
+
+ dests, err := resolveSkillSetupTargets(target, mode)
+ if err != nil {
+ return err
+ }
+
+ // multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
+ var multiSkillNames []string
+ if mode == skillSetupModeMulti {
+ allMultiSkillNames, listErr := listMultiSkillNames(skillSrc)
+ if listErr != nil {
+ return listErr
+ }
+ if len(allMultiSkillNames) == 0 {
+ return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc)
+ }
+ filtered, filterErr := filterMultiSkillNames(allMultiSkillNames, includeRaw, excludeRaw)
+ if filterErr != nil {
+ return filterErr
+ }
+ multiSkillNames = filtered
+ }
+
+ if !autoYes {
+ ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ fmt.Fprintln(out, "已取消。")
+ return nil
+ }
+ } else if mode == skillSetupModeMulti {
+ fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
+ }
+
+ var installed, skipped int
+ switch mode {
+ case skillSetupModeMono:
+ installed, skipped, err = installSkillToHomes(skillSrc, dests, out, errOut)
+ case skillSetupModeMulti:
+ installed, skipped, err = installMultiSkillToHomes(skillSrc, multiSkillNames, dests, out, errOut)
+ default:
+ return fmt.Errorf("内部错误:未知 mode %q", mode)
+ }
+ if err != nil {
+ return err
+ }
+
+ fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
+ return nil
+}
+
+// multiSkillPrefix is the canonical prefix for every per-product skill
+// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar).
+const multiSkillPrefix = "dingtalk-"
+
+// normalizeMultiSkillName accepts either the short form (aitable) or the
+// full form (dingtalk-aitable) and returns the canonical full form.
+// Empty input returns "". Comparison is case-insensitive.
+func normalizeMultiSkillName(name string) string {
+ n := strings.ToLower(strings.TrimSpace(name))
+ if n == "" {
+ return ""
+ }
+ if strings.HasPrefix(n, multiSkillPrefix) {
+ return n
+ }
+ return multiSkillPrefix + n
+}
+
+// filterMultiSkillNames narrows `all` by include / exclude lists.
+// Semantics mirror lark-cli's `npx skills add -s lark-calendar`:
+//
+// - include + exclude are mutually exclusive (both → error)
+// - names accept short or full form; normalized before matching
+// - unknown names → error, with the available list inlined for discovery
+// - both lists empty → return `all` (install everything)
+// - exclude that drops every name → error (avoid silent no-op install)
+//
+// The caller is responsible for additive installation: install only the
+// returned names, leaving any other already-installed dingtalk-* siblings
+// untouched (handled by installMultiSkillToHomes which does not enumerate
+// the destination).
+func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
+ if len(include) > 0 && len(exclude) > 0 {
+ return nil, fmt.Errorf("--skill 与 --exclude 不能同时使用")
+ }
+
+ available := make(map[string]struct{}, len(all))
+ for _, n := range all {
+ available[n] = struct{}{}
+ }
+
+ validate := func(raw []string, flagName string) ([]string, error) {
+ var normalized []string
+ var unknown []string
+ seen := make(map[string]bool)
+ for _, r := range raw {
+ n := normalizeMultiSkillName(r)
+ if n == "" {
+ continue
+ }
+ if _, ok := available[n]; !ok {
+ unknown = append(unknown, r)
+ continue
+ }
+ if !seen[n] {
+ seen[n] = true
+ normalized = append(normalized, n)
+ }
+ }
+ if len(unknown) > 0 {
+ return nil, fmt.Errorf("%s 中的以下名称在 multi 源中找不到:%s\n可用列表(共 %d 个):%s",
+ flagName, strings.Join(unknown, ", "), len(all), strings.Join(all, ", "))
+ }
+ return normalized, nil
+ }
+
+ if len(include) > 0 {
+ names, err := validate(include, "--skill")
+ if err != nil {
+ return nil, err
+ }
+ sort.Strings(names)
+ return names, nil
+ }
+ if len(exclude) > 0 {
+ excluded, err := validate(exclude, "--exclude")
+ if err != nil {
+ return nil, err
+ }
+ excludedSet := make(map[string]bool, len(excluded))
+ for _, n := range excluded {
+ excludedSet[n] = true
+ }
+ var out []string
+ for _, n := range all {
+ if !excludedSet[n] {
+ out = append(out, n)
+ }
+ }
+ if len(out) == 0 {
+ return nil, fmt.Errorf("--exclude 把全部 %d 个子 skill 都剔除了,没有可装的", len(all))
+ }
+ return out, nil
+ }
+ return all, nil
+}
+
+// listMultiSkillNames returns sorted names of subdirectories under src that
+// contain a SKILL.md file (i.e. valid multi-mode skill bundles).
+func listMultiSkillNames(src string) ([]string, error) {
+ entries, err := os.ReadDir(src)
+ if err != nil {
+ return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err)
+ }
+ var names []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ if _, err := os.Stat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil {
+ names = append(names, e.Name())
+ }
+ }
+ sort.Strings(names)
+ return names, nil
+}
+
+// resolveSkillSetupMode resolves the mode either from the flag or via an
+// interactive prompt. If no TTY is available and no mode was given, returns
+// an error rather than silently picking a default.
+func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, error) {
+ mode = strings.ToLower(strings.TrimSpace(mode))
+ switch mode {
+ case skillSetupModeMono, skillSetupModeMulti:
+ return mode, nil
+ case "":
+ // fall through to interactive prompt
+ default:
+ return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode)
+ }
+
+ if autoYes || !isInteractiveTerminal() {
+ fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
+ return skillSetupModeMono, nil
+ }
+
+ var choice string
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewSelect[string]().
+ Title("选择 dws skill 安装模式").
+ Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
+ Options(
+ huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
+ huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
+ ).
+ Value(&choice),
+ ),
+ )
+ if err := form.Run(); err != nil {
+ return "", fmt.Errorf("交互式选择中止: %w", err)
+ }
+ return choice, nil
+}
+
+// resolveSkillSetupSource finds the local skill source directory for the
+// given mode. PR 1 supports only mono; multi is reserved for a later PR
+// and currently returns an error before reaching this function.
+func resolveSkillSetupSource(explicit, mode string) (string, error) {
+ subdir := mode // "mono" or "multi"
+
+ candidates := skillSourceCandidates(explicit, subdir)
+ for _, c := range candidates {
+ if isSkillSourceRoot(c, mode) {
+ return c, nil
+ }
+ }
+
+ hint := strings.Join(candidates, "\n - ")
+ return "", fmt.Errorf("未找到 %s 模式的 skill 源目录,已尝试:\n - %s\n\n请用 --source 显式指定包含 skills/%s 的仓库根目录", mode, hint, mode)
+}
+
+// skillSourceCandidates returns the ordered list of paths to probe for a
+// skill source root, given an optional explicit override and the mode
+// subdir (mono or multi).
+func skillSourceCandidates(explicit, subdir string) []string {
+ var roots []string
+ if explicit != "" {
+ // allow either repo root or already-resolved skills/ dir
+ roots = append(roots, explicit, filepath.Join(explicit, "skills", subdir))
+ }
+ if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" {
+ roots = append(roots, env, filepath.Join(env, "skills", subdir))
+ }
+ if exe, err := os.Executable(); err == nil {
+ exeDir := filepath.Dir(exe)
+ roots = append(roots,
+ filepath.Join(exeDir, "skills", subdir),
+ filepath.Join(exeDir, "..", "skills", subdir),
+ filepath.Join(exeDir, "..", "share", "skills", "dws"),
+ )
+ }
+ if wd, err := os.Getwd(); err == nil {
+ roots = append(roots, filepath.Join(wd, "skills", subdir))
+ }
+ // User-level cache populated by install.sh / install.ps1 / npm install.js
+ // from the dws-skills.zip release asset. Lets `dws skill setup` find a
+ // source even when the user has no source checkout on disk.
+ if home, err := os.UserHomeDir(); err == nil {
+ roots = append(roots, filepath.Join(home, ".dws", "skills", subdir))
+ }
+ return roots
+}
+
+func isSkillSourceRoot(path, mode string) bool {
+ if path == "" {
+ return false
+ }
+ switch mode {
+ case skillSetupModeMono:
+ fi, err := os.Stat(filepath.Join(path, "SKILL.md"))
+ return err == nil && !fi.IsDir()
+ case skillSetupModeMulti:
+ entries, err := os.ReadDir(path)
+ if err != nil {
+ return false
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ if _, err := os.Stat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil {
+ return true
+ }
+ }
+ }
+ return false
+ }
+ return false
+}
+
+// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
+// If target == "all", returns every agent home whose parent directory exists.
+// Otherwise returns the single matching home (whether or not it currently exists).
+//
+// 末段约定:
+// - mono → /dws (单 skill,整个 src 拷成一个 dws 目录)
+// - multi → (安装时把 src 下每个子目录拷成兄弟 skill)
+func resolveSkillSetupTargets(target, mode string) ([]string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, fmt.Errorf("无法解析用户 HOME: %w", err)
+ }
+
+ target = strings.ToLower(strings.TrimSpace(target))
+ if target == "" || target == "all" {
+ return detectExistingAgentHomes(home, mode), nil
+ }
+
+ rel, ok := agentSkillPaths[target]
+ if !ok {
+ return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
+ }
+ return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
+}
+
+// agentHomeForMode appends the mode-specific tail segment to an agent home base.
+func agentHomeForMode(base, mode string) string {
+ if mode == skillSetupModeMulti {
+ return base
+ }
+ return filepath.Join(base, "dws")
+}
+
+func detectExistingAgentHomes(home, mode string) []string {
+ var out []string
+ for i, rel := range skillSetupAgentHomes {
+ base := filepath.Join(home, rel)
+ parent := filepath.Dir(base)
+ if i > 0 {
+ if _, err := os.Stat(parent); errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ }
+ out = append(out, agentHomeForMode(base, mode))
+ }
+ if len(out) == 0 {
+ out = append(out, agentHomeForMode(filepath.Join(home, ".agents", "skills"), mode))
+ }
+ return out
+}
+
+func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
+ if mode == skillSetupModeMulti {
+ fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
+ fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
+ fmt.Fprintln(out, " · 20 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
+ fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
+ fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
+ fmt.Fprintln(out, " 稳定版请用 --mode mono")
+ fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
+ }
+ fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
+ if mode == skillSetupModeMulti {
+ fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 //):\n", len(multiSkillNames))
+ for _, n := range multiSkillNames {
+ fmt.Fprintf(out, " · %s\n", n)
+ }
+ }
+ fmt.Fprintln(out, " destinations:")
+ for _, d := range dests {
+ fmt.Fprintf(out, " - %s\n", d)
+ }
+ // 列出互斥清理:装 mode 前要把对面 mode 的残留删掉
+ fmt.Fprintln(out, " 互斥清理(确认后才执行):")
+ for _, d := range dests {
+ for _, victim := range mutualExclusionVictims(d, mode) {
+ fmt.Fprintf(out, " × 将删除 %s\n", victim)
+ }
+ }
+
+ if !isInteractiveTerminal() {
+ return true, nil
+ }
+
+ var confirm bool
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("确认安装?").
+ Affirmative("继续").
+ Negative("取消").
+ Value(&confirm),
+ ),
+ )
+ if err := form.Run(); err != nil {
+ return false, fmt.Errorf("确认中止: %w", err)
+ }
+ return confirm, nil
+}
+
+// mutualExclusionVictims returns the paths that should be removed before
+// installing into dest under the given mode, to prevent leftover files from
+// the opposite mode from co-existing.
+//
+// - mono dest is /dws → multi 残留是 /dingtalk-*
+// - multi dest is → mono 残留是 /dws
+func mutualExclusionVictims(dest, mode string) []string {
+ switch mode {
+ case skillSetupModeMono:
+ // dest = /dws → agent-home = parent
+ agentHome := filepath.Dir(dest)
+ entries, err := os.ReadDir(agentHome)
+ if err != nil {
+ return nil
+ }
+ var victims []string
+ for _, e := range entries {
+ if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") {
+ victims = append(victims, filepath.Join(agentHome, e.Name()))
+ }
+ }
+ sort.Strings(victims)
+ return victims
+ case skillSetupModeMulti:
+ // dest = → mono 残留是 dest/dws
+ monoPath := filepath.Join(dest, "dws")
+ if _, err := os.Stat(monoPath); err == nil {
+ return []string{monoPath}
+ }
+ return nil
+ }
+ return nil
+}
+
+// cleanupMutualExclusion best-effort removes the opposite-mode leftovers.
+// Failures emit a warning to errOut but never abort the install.
+func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
+ for _, victim := range mutualExclusionVictims(dest, mode) {
+ if err := os.RemoveAll(victim); err != nil {
+ fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
+ continue
+ }
+ fmt.Fprintf(out, " × 已清理对面模式残留 %s\n", victim)
+ }
+}
+
+func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
+ sort.Strings(dests)
+ for _, dest := range dests {
+ // 先做互斥清理:装 mono 前先把同级 dingtalk-* 子目录全部干掉
+ cleanupMutualExclusion(dest, skillSetupModeMono, out, errOut)
+
+ if err := os.RemoveAll(dest); err != nil {
+ fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", dest, err)
+ skipped++
+ continue
+ }
+ if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
+ fmt.Fprintf(errOut, " ✗ 父目录创建失败 %s: %v\n", dest, err)
+ skipped++
+ continue
+ }
+ if err := copyDir(src, dest); err != nil {
+ fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", dest, err)
+ skipped++
+ continue
+ }
+ fmt.Fprintf(out, " ✓ %s\n", dest)
+ installed++
+ }
+ return installed, skipped, nil
+}
+
+// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
+// dest as a sibling skill directory. installed/skipped is counted per
+// (agent-home × sub-skill) pair so the user sees granular progress.
+func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
+ sort.Strings(dests)
+ for _, dest := range dests {
+ // 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
+ cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
+
+ if err := os.MkdirAll(dest, 0o755); err != nil {
+ fmt.Fprintf(errOut, " ✗ Agent 目录创建失败 %s: %v\n", dest, err)
+ skipped += len(skillNames)
+ continue
+ }
+
+ for _, name := range skillNames {
+ subSrc := filepath.Join(src, name)
+ subDest := filepath.Join(dest, name)
+ if err := os.RemoveAll(subDest); err != nil {
+ fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", subDest, err)
+ skipped++
+ continue
+ }
+ if err := copyDir(subSrc, subDest); err != nil {
+ fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", subDest, err)
+ skipped++
+ continue
+ }
+ fmt.Fprintf(out, " ✓ %s\n", subDest)
+ installed++
+ }
+ }
+ return installed, skipped, nil
+}
+
+func copyDir(src, dst string) error {
+ return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ rel, err := filepath.Rel(src, path)
+ if err != nil {
+ return err
+ }
+ target := filepath.Join(dst, rel)
+
+ if info.IsDir() {
+ return os.MkdirAll(target, info.Mode())
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ // resolve symlink target and copy the underlying file
+ resolved, err := os.Readlink(path)
+ if err != nil {
+ return err
+ }
+ if !filepath.IsAbs(resolved) {
+ resolved = filepath.Join(filepath.Dir(path), resolved)
+ }
+ return copyFileContent(resolved, target, info.Mode())
+ }
+ return copyFileContent(path, target, info.Mode())
+ })
+}
+
+func copyFileContent(src, dst string, mode os.FileMode) error {
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ return err
+ }
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ _, err = io.Copy(out, in)
+ return err
+}
+
+func isInteractiveTerminal() bool {
+ fi, err := os.Stdin.Stat()
+ if err != nil {
+ return false
+ }
+ return (fi.Mode() & os.ModeCharDevice) != 0
+}
diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go
new file mode 100644
index 00000000..6d28c9f9
--- /dev/null
+++ b/internal/app/skill_setup_test.go
@@ -0,0 +1,559 @@
+package app
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestSkillSetupCommandRegistered(t *testing.T) {
+ root := buildSkillCommand()
+ var found bool
+ for _, sub := range root.Commands() {
+ if sub.Name() == "setup" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("dws skill setup not registered as subcommand")
+ }
+}
+
+func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
+ got, err := resolveSkillSetupMode("mono", true, &bytes.Buffer{})
+ if err != nil || got != skillSetupModeMono {
+ t.Fatalf("expected mono no-error, got %q err=%v", got, err)
+ }
+ got, err = resolveSkillSetupMode("MULTI", true, &bytes.Buffer{})
+ if err != nil || got != skillSetupModeMulti {
+ t.Fatalf("expected multi case-insensitive, got %q err=%v", got, err)
+ }
+ if _, err = resolveSkillSetupMode("hybrid", true, &bytes.Buffer{}); err == nil {
+ t.Fatalf("expected error on invalid mode")
+ }
+}
+
+func TestResolveSkillSetupModeNonInteractiveDefaultsMono(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 !strings.Contains(buf.String(), "mono") {
+ t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
+ }
+}
+
+func TestResolveSkillSetupSourceFindsMonoRoot(t *testing.T) {
+ tmp := t.TempDir()
+ monoDir := filepath.Join(tmp, "skills", "mono")
+ if err := os.MkdirAll(monoDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(monoDir, "SKILL.md"), []byte("# test"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := resolveSkillSetupSource(tmp, skillSetupModeMono)
+ if err != nil {
+ t.Fatalf("expected to find mono source, got err=%v", err)
+ }
+ if got != monoDir {
+ t.Fatalf("expected %s, got %s", monoDir, got)
+ }
+}
+
+func TestResolveSkillSetupSourceErrorWhenMissing(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("DWS_SKILL_SOURCE", "")
+ // Isolate HOME so the ~/.dws/skills// fallback (added by the release
+ // pipeline cache work) does not pick up real cached content on the
+ // developer machine.
+ t.Setenv("HOME", t.TempDir())
+ _, err := resolveSkillSetupSource(tmp, skillSetupModeMono)
+ if err == nil {
+ t.Fatalf("expected error when source missing")
+ }
+ if !strings.Contains(err.Error(), "未找到") {
+ t.Fatalf("expected 未找到 message, got %v", err)
+ }
+}
+
+func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
+ got, err := resolveSkillSetupTargets("claude", skillSetupModeMono)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("expected 1 dest, got %d", len(got))
+ }
+ if !strings.Contains(got[0], ".claude/skills/dws") {
+ t.Fatalf("expected .claude/skills/dws path, got %s", got[0])
+ }
+}
+
+func TestResolveSkillSetupTargetsUnknown(t *testing.T) {
+ if _, err := resolveSkillSetupTargets("nonsense", skillSetupModeMono); err == nil {
+ t.Fatalf("expected error for unknown target")
+ }
+}
+
+func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
+ got, err := resolveSkillSetupTargets("claude", skillSetupModeMulti)
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("expected 1 dest, got %d", len(got))
+ }
+ if strings.HasSuffix(got[0], "/dws") {
+ t.Fatalf("multi target must not end with /dws, got %s", got[0])
+ }
+ if !strings.HasSuffix(got[0], ".claude/skills") {
+ t.Fatalf("expected suffix .claude/skills, got %s", got[0])
+ }
+}
+
+func TestInstallSkillToHomesEndToEnd(t *testing.T) {
+ src := t.TempDir()
+ if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("# test"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(src, "references"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "references", "x.md"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ dst1 := filepath.Join(t.TempDir(), "a", "dws")
+ dst2 := filepath.Join(t.TempDir(), "b", "dws")
+
+ var stdout, stderr bytes.Buffer
+ installed, skipped, err := installSkillToHomes(src, []string{dst1, dst2}, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("install err: %v", err)
+ }
+ if installed != 2 || skipped != 0 {
+ t.Fatalf("expected installed=2 skipped=0, got %d/%d", installed, skipped)
+ }
+ for _, d := range []string{dst1, dst2} {
+ if _, err := os.Stat(filepath.Join(d, "SKILL.md")); err != nil {
+ t.Fatalf("missing SKILL.md in %s: %v", d, err)
+ }
+ if _, err := os.Stat(filepath.Join(d, "references", "x.md")); err != nil {
+ t.Fatalf("missing references/x.md in %s: %v", d, err)
+ }
+ }
+}
+
+// writeMultiSkillSource builds a fake skills/multi/ layout containing N
+// dingtalk-* subdirs, each with a SKILL.md and one references/.md
+// file. Returns the absolute skill source root.
+func writeMultiSkillSource(t *testing.T, names []string) string {
+ t.Helper()
+ root := t.TempDir()
+ for _, n := range names {
+ sub := filepath.Join(root, n)
+ if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return root
+}
+
+func TestInstallMultiSkillToHomes(t *testing.T) {
+ names := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"}
+ src := writeMultiSkillSource(t, names)
+
+ got, err := listMultiSkillNames(src)
+ if err != nil {
+ t.Fatalf("listMultiSkillNames err: %v", err)
+ }
+ if len(got) != len(names) {
+ t.Fatalf("expected %d skills, got %d (%v)", len(names), len(got), got)
+ }
+
+ dst1 := filepath.Join(t.TempDir(), ".claude", "skills")
+ dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
+
+ var stdout, stderr bytes.Buffer
+ installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("installMultiSkillToHomes err: %v", err)
+ }
+ if installed != len(names)*2 || skipped != 0 {
+ t.Fatalf("expected installed=%d skipped=0, got %d/%d (stderr=%q)", len(names)*2, installed, skipped, stderr.String())
+ }
+ for _, d := range []string{dst1, dst2} {
+ for _, n := range names {
+ sub := filepath.Join(d, n)
+ if _, err := os.Stat(filepath.Join(sub, "SKILL.md")); err != nil {
+ t.Fatalf("missing %s/SKILL.md: %v", sub, err)
+ }
+ if _, err := os.Stat(filepath.Join(sub, "references", n+".md")); err != nil {
+ t.Fatalf("missing %s/references/%s.md: %v", sub, n, err)
+ }
+ }
+ // dws/ should NOT exist (multi mode is pure siblings)
+ if _, err := os.Stat(filepath.Join(d, "dws")); err == nil {
+ t.Fatalf("unexpected dws/ subdir in multi-mode install at %s", d)
+ }
+ }
+}
+
+func TestSkillSetupMutualExclusion(t *testing.T) {
+ names := []string{"dingtalk-aitable", "dingtalk-calendar"}
+ src := writeMultiSkillSource(t, names)
+
+ // Simulate a pre-existing mono install under /dws/
+ agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
+ monoLeftover := filepath.Join(agentHome, "dws")
+ if err := os.MkdirAll(filepath.Join(monoLeftover, "references"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Sanity: leftover exists before
+ if _, err := os.Stat(monoLeftover); err != nil {
+ t.Fatalf("setup: mono leftover should exist before, err=%v", err)
+ }
+
+ // Confirm mutualExclusionVictims sees the leftover
+ victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
+ 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)
+ if err != nil {
+ t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
+ }
+ if installed != len(names) || skipped != 0 {
+ t.Fatalf("expected installed=%d skipped=0, got %d/%d", len(names), installed, skipped)
+ }
+
+ // mono leftover should be gone
+ if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) {
+ t.Fatalf("expected mono leftover removed, stat err=%v", err)
+ }
+ // multi skills should be in place
+ for _, n := range names {
+ if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
+ t.Fatalf("missing %s/%s/SKILL.md: %v", agentHome, n, err)
+ }
+ }
+ // the cleanup line should appear in stdout (best-effort observability)
+ if !strings.Contains(stdout.String(), "已清理对面模式残留") {
+ t.Fatalf("expected cleanup log line, got stdout=%q", stdout.String())
+ }
+
+ // Now test the reverse: pre-existing multi → installing mono cleans dingtalk-*
+ monoSrc := t.TempDir()
+ if err := os.WriteFile(filepath.Join(monoSrc, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ monoDest := filepath.Join(agentHome, "dws")
+ stdout.Reset()
+ stderr.Reset()
+ installed2, skipped2, err := installSkillToHomes(monoSrc, []string{monoDest}, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("mono install err: %v", err)
+ }
+ if installed2 != 1 || skipped2 != 0 {
+ t.Fatalf("expected mono installed=1 skipped=0, got %d/%d", installed2, skipped2)
+ }
+ // All dingtalk-* siblings should be gone after mono install
+ for _, n := range names {
+ if _, err := os.Stat(filepath.Join(agentHome, n)); !os.IsNotExist(err) {
+ t.Fatalf("expected %s removed by mutual exclusion, stat err=%v", n, err)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(monoDest, "SKILL.md")); err != nil {
+ t.Fatalf("mono SKILL.md missing: %v", err)
+ }
+ if !strings.Contains(stdout.String(), "已清理对面模式残留") {
+ t.Fatalf("expected cleanup log line on mono install, got stdout=%q", stdout.String())
+ }
+}
+
+// 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
+// machine without --source.
+func TestSkillSourceCandidatesIncludesUserCache(t *testing.T) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ t.Fatalf("UserHomeDir error = %v", err)
+ }
+
+ for _, subdir := range []string{"mono", "multi"} {
+ got := skillSourceCandidates("", subdir)
+ want := filepath.Join(home, ".dws", "skills", subdir)
+ found := false
+ for _, c := range got {
+ if c == want {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("skillSourceCandidates(%q) missing %q; got %v", subdir, want, got)
+ }
+ }
+}
+
+// TestResolveSkillSetupSourceFallsBackToUserCache verifies that when no
+// --source / DWS_SKILL_SOURCE / source checkout is available, the resolver
+// successfully discovers ~/.dws/skills/multi/ as the source.
+func TestResolveSkillSetupSourceFallsBackToUserCache(t *testing.T) {
+ fakeHome := t.TempDir()
+ t.Setenv("HOME", fakeHome)
+ t.Setenv("DWS_SKILL_SOURCE", "")
+
+ cacheRoot := filepath.Join(fakeHome, ".dws", "skills", "multi")
+ for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} {
+ if err := os.MkdirAll(filepath.Join(cacheRoot, n), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(cacheRoot, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // Run resolver from a tempdir that has no skills/ on disk, simulating a
+ // fresh user machine without a source checkout.
+ scratch := t.TempDir()
+ t.Chdir(scratch)
+
+ got, err := resolveSkillSetupSource("", skillSetupModeMulti)
+ if err != nil {
+ t.Fatalf("expected user-cache fallback to succeed, got err=%v", err)
+ }
+ if got != cacheRoot {
+ t.Fatalf("expected %s, got %s", cacheRoot, got)
+ }
+}
+
+func TestNormalizeMultiSkillName(t *testing.T) {
+ cases := []struct {
+ in, want string
+ }{
+ {"aitable", "dingtalk-aitable"},
+ {"dingtalk-aitable", "dingtalk-aitable"},
+ {" Calendar ", "dingtalk-calendar"},
+ {"DINGTALK-DOC", "dingtalk-doc"},
+ {"", ""},
+ {" ", ""},
+ }
+ for _, c := range cases {
+ if got := normalizeMultiSkillName(c.in); got != c.want {
+ t.Errorf("normalizeMultiSkillName(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestFilterMultiSkillNames(t *testing.T) {
+ all := []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc", "dingtalk-live"}
+
+ t.Run("no filter returns all", func(t *testing.T) {
+ got, err := filterMultiSkillNames(all, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != len(all) {
+ t.Fatalf("expected %d, got %v", len(all), got)
+ }
+ })
+
+ t.Run("include short names", func(t *testing.T) {
+ got, err := filterMultiSkillNames(all, []string{"aitable", "calendar"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(got, ",") != "dingtalk-aitable,dingtalk-calendar" {
+ t.Fatalf("got %v", got)
+ }
+ })
+
+ t.Run("include full names", func(t *testing.T) {
+ got, err := filterMultiSkillNames(all, []string{"dingtalk-doc"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0] != "dingtalk-doc" {
+ t.Fatalf("got %v", got)
+ }
+ })
+
+ t.Run("include dedups", func(t *testing.T) {
+ got, err := filterMultiSkillNames(all, []string{"aitable", "dingtalk-aitable", "AITABLE"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0] != "dingtalk-aitable" {
+ t.Fatalf("got %v", got)
+ }
+ })
+
+ t.Run("include unknown errors with available list", func(t *testing.T) {
+ _, err := filterMultiSkillNames(all, []string{"aitable", "bogus"}, nil)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ msg := err.Error()
+ if !strings.Contains(msg, "bogus") {
+ t.Errorf("error should mention bad name, got: %s", msg)
+ }
+ if !strings.Contains(msg, "dingtalk-calendar") {
+ t.Errorf("error should list available names, got: %s", msg)
+ }
+ })
+
+ t.Run("exclude short names", func(t *testing.T) {
+ got, err := filterMultiSkillNames(all, nil, []string{"live", "doc"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(got, ",") != "dingtalk-aitable,dingtalk-calendar" {
+ t.Fatalf("got %v", got)
+ }
+ })
+
+ t.Run("exclude unknown errors", func(t *testing.T) {
+ _, err := filterMultiSkillNames(all, nil, []string{"bogus"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ })
+
+ t.Run("exclude all errors", func(t *testing.T) {
+ _, err := filterMultiSkillNames(all, nil, []string{"aitable", "calendar", "doc", "live"})
+ if err == nil {
+ t.Fatal("expected error when exclude drops everything")
+ }
+ if !strings.Contains(err.Error(), "全部") {
+ t.Errorf("expected 全部 in error, got: %s", err.Error())
+ }
+ })
+
+ t.Run("include + exclude mutually exclusive", func(t *testing.T) {
+ _, err := filterMultiSkillNames(all, []string{"aitable"}, []string{"doc"})
+ if err == nil {
+ t.Fatal("expected error when both given")
+ }
+ })
+}
+
+// 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, matches
+// lark-cli `npx skills add -s lark-calendar`).
+func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
+ src := writeMultiSkillSource(t, []string{
+ "dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc",
+ })
+ agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
+
+ // Pretend the user already installed two dingtalk-* skills earlier.
+ preExisting := []string{"dingtalk-chat", "dingtalk-todo"}
+ for _, n := range preExisting {
+ 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)
+ }
+ }
+
+ // User now runs `... --mode multi -s aitable -s calendar`.
+ filtered, err := filterMultiSkillNames(
+ []string{"dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc"},
+ []string{"aitable", "calendar"},
+ nil,
+ )
+ if err != nil {
+ t.Fatalf("filter err: %v", err)
+ }
+
+ var stdout, stderr bytes.Buffer
+ installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
+ if err != nil {
+ t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
+ }
+ if installed != 2 || skipped != 0 {
+ t.Fatalf("expected installed=2 skipped=0, got %d/%d", installed, skipped)
+ }
+
+ // Asked-for skills should be in place.
+ for _, n := range []string{"dingtalk-aitable", "dingtalk-calendar"} {
+ if _, err := os.Stat(filepath.Join(agentHome, n, "SKILL.md")); err != nil {
+ t.Errorf("missing newly-installed %s: %v", n, err)
+ }
+ }
+ // Unselected source skill must NOT be installed.
+ if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-doc")); !os.IsNotExist(err) {
+ t.Errorf("dingtalk-doc was not requested but appeared (stat err=%v)", err)
+ }
+ // Pre-existing sibling skills must be UNTOUCHED — additive semantics.
+ for _, n := range preExisting {
+ body, err := os.ReadFile(filepath.Join(agentHome, n, "SKILL.md"))
+ if err != nil {
+ t.Errorf("pre-existing %s was wiped (err=%v)", n, err)
+ continue
+ }
+ if !strings.HasPrefix(string(body), "OLD ") {
+ t.Errorf("pre-existing %s content changed: got %q", n, string(body))
+ }
+ }
+}
+
+// TestRunSkillSetupRejectsSkillFlagInMonoMode verifies that the new
+// -s/--skill and -x/--exclude flags are gated on --mode multi.
+func TestRunSkillSetupRejectsSkillFlagInMonoMode(t *testing.T) {
+ cmd := newSkillSetupCommand()
+ cmd.SetArgs([]string{"--mode", "mono", "--yes", "--skill", "aitable"})
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("expected error for --skill in mono mode")
+ }
+ if !strings.Contains(err.Error(), "multi") {
+ t.Fatalf("error should mention multi gating, got: %v", err)
+ }
+}
+
+func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
+ tmp := t.TempDir()
+ multiDir := filepath.Join(tmp, "skills", "multi")
+ for _, n := range []string{"dingtalk-aitable", "dingtalk-doc"} {
+ if err := os.MkdirAll(filepath.Join(multiDir, n), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(multiDir, n, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ got, err := resolveSkillSetupSource(tmp, skillSetupModeMulti)
+ if err != nil {
+ t.Fatalf("expected to find multi source, got err=%v", err)
+ }
+ if got != multiDir {
+ t.Fatalf("expected %s, got %s", multiDir, got)
+ }
+}
diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh
index e5077eee..745a498f 100755
--- a/scripts/install-skills.sh
+++ b/scripts/install-skills.sh
@@ -18,6 +18,7 @@ REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
VERSION="${DWS_VERSION:-latest}"
SKILL_NAME="dws"
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
+DWS_CACHE_ROOT="${DWS_CACHE_ROOT:-$HOME/.dws}"
# ── Helpers ──────────────────────────────────────────────────────────────────
@@ -170,8 +171,11 @@ main() {
curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip"
extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted"
+ # Prefer the explicit mono/ subtree; fall back to legacy nested or zip root.
SKILL_SRC="$TMPDIR_WORK/extracted"
- if [ -f "$TMPDIR_WORK/extracted/${SKILL_NAME}/SKILL.md" ]; then
+ if [ -d "$TMPDIR_WORK/extracted/mono" ] && [ -f "$TMPDIR_WORK/extracted/mono/SKILL.md" ]; then
+ SKILL_SRC="$TMPDIR_WORK/extracted/mono"
+ elif [ -f "$TMPDIR_WORK/extracted/${SKILL_NAME}/SKILL.md" ]; then
SKILL_SRC="$TMPDIR_WORK/extracted/${SKILL_NAME}"
fi
@@ -184,6 +188,23 @@ main() {
printf ' Installing under root: %s\n' "$ROOT"
install_skills_to_root "$SKILL_SRC" "$ROOT"
+ # 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
+ 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"
+ 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'
printf ' • SKILL.md — Main skill with product overview and intent routing\n'
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 8c84269b..5d1cbb1c 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -17,6 +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)
#
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
@@ -29,6 +30,7 @@ $Version = if ($env:DWS_VERSION) { $env:DWS_VERSION } else { "latest" }
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
$SkillsOnly = $env:DWS_SKILLS_ONLY -eq "1"
$SkillName = "dws"
+$SkillMode = ""
# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS).
$AgentDirs = @(
@@ -219,6 +221,67 @@ function Write-Banner {
Write-Host ""
}
+# ── Skill Mode Resolution ────────────────────────────────────────────────────
+#
+# 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)
+function Resolve-SkillMode {
+ if ($env:DWS_SKILL_MODE) {
+ $normalized = $env:DWS_SKILL_MODE.ToLower()
+ if ($normalized -eq "mono" -or $normalized -eq "multi") {
+ $script:SkillMode = $normalized
+ Write-Say "Skill mode: $SkillMode (from DWS_SKILL_MODE)"
+ return
+ }
+ Write-Err "Invalid DWS_SKILL_MODE='$($env:DWS_SKILL_MODE)'. Use 'mono' or 'multi'."
+ }
+
+ $isInteractive = $false
+ try {
+ $isInteractive = ([Console]::IsInputRedirected -eq $false) -and ([Console]::IsOutputRedirected -eq $false)
+ } catch {
+ $isInteractive = $false
+ }
+
+ 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 🧪 EXPERIMENTAL — split each product into its own skill (preview; run 'dws skill setup --mode multi' afterwards)"
+ Write-Say " ⚠ multi is not yet stable — interface, naming and cross-skill references may change"
+ $choice = Read-Host " Choice [1]"
+ switch ($choice) {
+ "" { $script:SkillMode = "mono" }
+ "1" { $script:SkillMode = "mono" }
+ "mono" { $script:SkillMode = "mono" }
+ "2" { $script:SkillMode = "multi" }
+ "multi" { $script:SkillMode = "multi" }
+ default {
+ Write-Say "Unrecognized choice '$choice', defaulting to mono."
+ $script:SkillMode = "mono"
+ }
+ }
+ Write-Say "Skill mode: $SkillMode"
+ return
+ }
+
+ $script:SkillMode = "mono"
+}
+
+function Write-MultiModeNotice {
+ Write-Say ""
+ Write-Say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped."
+ Write-Say " ⚠ multi is not yet stable. 20 product-scoped skills pass dispatch verifier,"
+ Write-Say " but interface, naming and cross-skill references may change in future releases."
+ Write-Say " For production / shared environments, use mono mode (--mode mono)."
+ Write-Say ""
+ 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.)"
+}
+
# ── Install Binary ───────────────────────────────────────────────────────────
function Install-Binary {
@@ -297,7 +360,8 @@ function Install-Binary {
function Install-SkillsLocal {
param([string]$Root)
- $skillSrc = Join-Path $Root "skills"
+ $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"
@@ -309,6 +373,41 @@ function Install-SkillsLocal {
Write-Say "📦 Installing agent skills from local source: $skillSrc"
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
+
+ if (Test-Path $multiSrc) {
+ Cache-MultiSkills -Source $multiSrc
+ }
+ Cache-MonoSkills -Source $skillSrc
+}
+
+# Cache-MultiSkills mirrors install.sh cache_multi_skills: copies the multi/
+# tree to ~/.dws/skills/multi/ so `dws skill setup --mode multi` can find a
+# source without needing the source checkout or a re-download.
+function Cache-MultiSkills {
+ param([string]$Source)
+
+ if (!(Test-Path $Source)) { return }
+
+ $cacheDir = Join-Path $HOME ".dws\skills\multi"
+ if (Test-Path $cacheDir) {
+ Remove-Item -Path $cacheDir -Recurse -Force
+ }
+ 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 }
+
+ $cacheDir = Join-Path $HOME ".dws\skills\mono"
+ if (Test-Path $cacheDir) {
+ Remove-Item -Path $cacheDir -Recurse -Force
+ }
+ New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
+ Copy-DirRecursive -Source $Source -Destination $cacheDir | Out-Null
}
function Install-SkillsToHomes {
@@ -407,8 +506,12 @@ function Install-Skills {
$extractRoot = Join-Path $tmpDir "skills"
Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force
+ # Prefer the explicit mono/ subtree; fall back to legacy nested or zip root.
$skillSrc = $extractRoot
- if (Test-Path (Join-Path $extractRoot "$SkillName\SKILL.md")) {
+ $monoRoot = Join-Path $extractRoot "mono"
+ if ((Test-Path $monoRoot) -and (Test-Path (Join-Path $monoRoot "SKILL.md"))) {
+ $skillSrc = $monoRoot
+ } elseif (Test-Path (Join-Path $extractRoot "$SkillName\SKILL.md")) {
$skillSrc = Join-Path $extractRoot $SkillName
}
@@ -424,6 +527,14 @@ function Install-Skills {
}
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
+ }
+ Cache-MonoSkills -Source $skillSrc
} finally {
Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
@@ -435,18 +546,34 @@ $SourceRoot = Resolve-SourceRoot
Write-Banner
+if (!$NoSkills) {
+ Resolve-SkillMode
+}
+
if ($SourceRoot -and !$SkillsOnly -and ($Version -eq "latest")) {
Install-BinaryFromSource -Root $SourceRoot
if (!$NoSkills) {
- Install-SkillsLocal -Root $SourceRoot
+ if ($SkillMode -eq "multi") {
+ Write-MultiModeNotice
+ } else {
+ Install-SkillsLocal -Root $SourceRoot
+ }
}
} elseif ($SkillsOnly) {
- Install-Skills
+ if ($SkillMode -eq "multi") {
+ Write-MultiModeNotice
+ } else {
+ Install-Skills
+ }
} elseif ($NoSkills) {
Install-Binary
} else {
Install-Binary
- Install-Skills
+ if ($SkillMode -eq "multi") {
+ Write-MultiModeNotice
+ } else {
+ Install-Skills
+ }
}
Write-Host ""
diff --git a/scripts/install.sh b/scripts/install.sh
index 1c9b2f47..87512e95 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -14,6 +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)
#
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
@@ -27,6 +28,7 @@ VERSION="${DWS_VERSION:-latest}"
NO_SKILLS="${DWS_NO_SKILLS:-0}"
SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}"
SKILL_NAME="dws"
+SKILL_MODE=""
# ── Helpers ──────────────────────────────────────────────────────────────────
@@ -137,6 +139,64 @@ print_banner() {
printf '\n'
}
+# ── Skill Mode Resolution ────────────────────────────────────────────────────
+#
+# 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)
+resolve_skill_mode() {
+ if [ -n "${DWS_SKILL_MODE:-}" ]; then
+ raw="$DWS_SKILL_MODE"
+ # Lower-case without bash-specific ${var,,}; tr is POSIX.
+ normalized="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
+ case "$normalized" in
+ mono|multi)
+ SKILL_MODE="$normalized"
+ say "Skill mode: ${SKILL_MODE} (from DWS_SKILL_MODE)"
+ return 0
+ ;;
+ *)
+ err "Invalid DWS_SKILL_MODE='${raw}'. Use 'mono' or 'multi'."
+ ;;
+ esac
+ fi
+
+ 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 🧪 EXPERIMENTAL — split each product into its own skill (preview; run 'dws skill setup --mode multi' afterwards)"
+ say " ⚠ multi is not yet stable — interface, naming and cross-skill references may change"
+ printf ' Choice [1]: '
+ read choice || choice=""
+ case "$choice" in
+ ""|1|mono) SKILL_MODE="mono" ;;
+ 2|multi) SKILL_MODE="multi" ;;
+ *)
+ say "Unrecognized choice '${choice}', defaulting to mono."
+ SKILL_MODE="mono"
+ ;;
+ esac
+ say "Skill mode: ${SKILL_MODE}"
+ return 0
+ fi
+
+ SKILL_MODE="mono"
+}
+
+print_multi_mode_notice() {
+ say ""
+ say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped."
+ say " ⚠ multi is not yet stable. 20 product-scoped skills pass dispatch verifier,"
+ say " but interface, naming and cross-skill references may change in future releases."
+ say " For production / shared environments, use mono mode (--mode mono)."
+ say ""
+ 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.)"
+}
+
install_binary_from_source() {
root="$1"
@@ -166,7 +226,8 @@ install_binary_from_source() {
install_skills_local() {
root="$1"
- skill_src="${root}/skills"
+ skill_src="${root}/skills/mono"
+ multi_src="${root}/skills/multi"
if [ ! -d "$skill_src" ]; then
say "⚠️ Local skills directory not found: ${skill_src}"
@@ -179,9 +240,57 @@ install_skills_local() {
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"
+ fi
+
+ # Also cache a mono copy so `dws skill setup --mode mono` has a fallback
+ # under ~/.dws/skills/mono when invoked without --source.
+ cache_mono_skills "$skill_src"
+
return 0
}
+# cache_multi_skills copies the multi/ tree (per-product skills) into
+# ~/.dws/skills/multi/ so that `dws skill setup --mode multi` can find a
+# source without needing the source checkout or a re-download.
+cache_multi_skills() {
+ src="$1"
+ cache_dir="${HOME}/.dws/skills/multi"
+
+ if [ ! -d "$src" ]; 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
+
+ file_count="$(find "$cache_dir" -type f | wc -l | tr -d ' ')"
+ case "$cache_dir" in
+ "$HOME"/*) label="~/${cache_dir#$HOME/}" ;;
+ *) label="$cache_dir" ;;
+ esac
+ say "✅ Cached multi skills → ${label} (${file_count} files)"
+}
+
+# cache_mono_skills mirrors cache_multi_skills for the mono tree. Keeping the
+# two modes symmetrical means `dws skill setup` can fall back to ~/.dws/skills
+# regardless of which mode the user picks later.
+cache_mono_skills() {
+ src="$1"
+ cache_dir="${HOME}/.dws/skills/mono"
+
+ if [ ! -d "$src" ]; 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
+}
+
# Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
install_skills_to_homes() {
skill_src="$1"
@@ -403,8 +512,14 @@ install_skills() {
err "Cannot extract release skill archive and no local source checkout found."
fi
+ # New release layout puts mono content both at the zip root (for backward
+ # compatibility with older installers) and under ./mono/, with multi/ as a
+ # sibling. Prefer ./mono/ when present so we never miss SKILL.md, then fall
+ # back to the legacy nested $SKILL_NAME/ shape, then the zip root.
skill_src="$extract_root"
- if [ -f "$extract_root/$SKILL_NAME/SKILL.md" ]; then
+ if [ -d "$extract_root/mono" ] && [ -f "$extract_root/mono/SKILL.md" ]; then
+ skill_src="$extract_root/mono"
+ elif [ -f "$extract_root/$SKILL_NAME/SKILL.md" ]; then
skill_src="$extract_root/$SKILL_NAME"
fi
if [ ! -f "$skill_src/SKILL.md" ]; then
@@ -422,6 +537,15 @@ install_skills() {
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
+ cache_multi_skills "$extract_root/multi"
+ fi
+
+ # And cache mono too for symmetry with --mode mono fallbacks.
+ cache_mono_skills "$skill_src"
+
rm -rf "$tmpdir_skills"
}
@@ -435,23 +559,40 @@ main() {
print_banner
+ # Resolve skill mode only when we are actually going to touch skills.
+ if [ "$NO_SKILLS" != "1" ]; then
+ resolve_skill_mode
+ fi
+
if [ -n "$source_root" ]; then
install_binary_from_source "$source_root"
if [ "$NO_SKILLS" != "1" ]; then
- install_skills_local "$source_root"
+ if [ "$SKILL_MODE" = "multi" ]; then
+ print_multi_mode_notice
+ else
+ install_skills_local "$source_root"
+ fi
fi
elif [ "$SKILLS_ONLY" = "1" ]; then
- local_root="$(resolve_source_root || true)"
- if [ -n "$local_root" ]; then
- install_skills_local "$local_root"
+ if [ "$SKILL_MODE" = "multi" ]; then
+ print_multi_mode_notice
else
- install_skills
+ local_root="$(resolve_source_root || true)"
+ if [ -n "$local_root" ]; then
+ install_skills_local "$local_root"
+ else
+ install_skills
+ fi
fi
elif [ "$NO_SKILLS" = "1" ]; then
install_binary
else
install_binary
- install_skills
+ if [ "$SKILL_MODE" = "multi" ]; then
+ print_multi_mode_notice
+ else
+ install_skills
+ fi
fi
printf '\n'
diff --git a/scripts/release/post-goreleaser.sh b/scripts/release/post-goreleaser.sh
index 433466f9..798047d5 100755
--- a/scripts/release/post-goreleaser.sh
+++ b/scripts/release/post-goreleaser.sh
@@ -168,10 +168,31 @@ stage_homebrew_formula() {
create_skills_zip() {
skills_zip="$DIST_DIR/dws-skills.zip"
rm -f "$skills_zip"
+
+ staging="$(mktemp -d)"
+ # Layout inside dws-skills.zip:
+ # /SKILL.md + references/ + scripts/ ← copy of mono/, kept at root
+ # for backward compatibility
+ # with older install scripts
+ # that look for SKILL.md at
+ # the zip root.
+ # /mono/ ← explicit mono source tree
+ # /multi/ ← multi source tree (one
+ # subdir per product skill)
+ cp -R "$ROOT/skills/mono/." "$staging/"
+ mkdir -p "$staging/mono"
+ cp -R "$ROOT/skills/mono/." "$staging/mono/"
+ mkdir -p "$staging/multi"
+ if [ -d "$ROOT/skills/multi" ]; then
+ cp -R "$ROOT/skills/multi/." "$staging/multi/"
+ fi
+
(
- cd "$ROOT/skills"
+ cd "$staging"
env -u LC_ALL -u LC_CTYPE LANG=C LC_ALL=C LC_CTYPE=C zip -qr "$skills_zip" .
)
+
+ rm -rf "$staging"
}
# ---------- darwin ad-hoc signing ----------
diff --git a/skills/LICENSE b/skills/mono/LICENSE
similarity index 100%
rename from skills/LICENSE
rename to skills/mono/LICENSE
diff --git a/skills/NOTICE b/skills/mono/NOTICE
similarity index 100%
rename from skills/NOTICE
rename to skills/mono/NOTICE
diff --git a/skills/SKILL.md b/skills/mono/SKILL.md
similarity index 96%
rename from skills/SKILL.md
rename to skills/mono/SKILL.md
index 3a323976..43f33ac8 100644
--- a/skills/SKILL.md
+++ b/skills/mono/SKILL.md
@@ -8,6 +8,9 @@ cli_version: ">=1.0.15"
通过 `dws` 命令管理钉钉产品能力。
+
+> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。
+
## 严格禁止 (NEVER DO)
- 不要使用 dws 命令以外的方式操作(禁止 curl、HTTP API、浏览器)
- 不要编造 UUID、ID 等标识符,必须从命令返回中提取
diff --git a/skills/references/error-codes.md b/skills/mono/references/error-codes.md
similarity index 100%
rename from skills/references/error-codes.md
rename to skills/mono/references/error-codes.md
diff --git a/skills/references/field-rules.md b/skills/mono/references/field-rules.md
similarity index 100%
rename from skills/references/field-rules.md
rename to skills/mono/references/field-rules.md
diff --git a/skills/references/global-reference.md b/skills/mono/references/global-reference.md
similarity index 100%
rename from skills/references/global-reference.md
rename to skills/mono/references/global-reference.md
diff --git a/skills/references/intent-guide.md b/skills/mono/references/intent-guide.md
similarity index 100%
rename from skills/references/intent-guide.md
rename to skills/mono/references/intent-guide.md
diff --git a/skills/references/products/aitable.md b/skills/mono/references/products/aitable.md
similarity index 100%
rename from skills/references/products/aitable.md
rename to skills/mono/references/products/aitable.md
diff --git a/skills/references/products/attendance.md b/skills/mono/references/products/attendance.md
similarity index 100%
rename from skills/references/products/attendance.md
rename to skills/mono/references/products/attendance.md
diff --git a/skills/references/products/calendar.md b/skills/mono/references/products/calendar.md
similarity index 100%
rename from skills/references/products/calendar.md
rename to skills/mono/references/products/calendar.md
diff --git a/skills/mono/references/products/chat-emoji-list.md b/skills/mono/references/products/chat-emoji-list.md
new file mode 100644
index 00000000..338710fd
--- /dev/null
+++ b/skills/mono/references/products/chat-emoji-list.md
@@ -0,0 +1,209 @@
+# 钉钉默认表情列表(emoji 回应可用)
+
+> 本文件列出钉钉支持的所有用户可见默认表情(showType=1),共 199 个。
+>
+> 使用规则:
+> - 用户描述的表情命中下表中的 `name` → 使用 `chat message add-emoji --emoji ` 贴 emoji 回应
+> - 用户描述的表情未命中下表 → 先 `chat message create-text-emotion` 创建文字表情获取 emotionId,再 `chat message add-text-emotion` 贴文字表情回应
+
+| # | emotionId | name | en_US |
+|---|-----------|------|-------|
+| 1 | emotion_001 | 微笑 | Smile |
+| 2 | emotion_099 | 可爱 | Lovely |
+| 3 | emotion_002 | 憨笑 | Wow |
+| 4 | emotion_003 | 色 | Yum |
+| 5 | emotion_004 | 发呆 | Dazed |
+| 6 | emotion_005 | 老板 | Boss |
+| 7 | emotion_036 | 傻笑 | Oops |
+| 8 | emotion_006 | 流泪 | Sob |
+| 9 | emotion_007 | 害羞 | Shy |
+| 10 | emotion_008 | 闭嘴 | Silence |
+| 11 | emotion_009 | 睡 | Sleepy |
+| 12 | emotion_010 | 大哭 | Cry |
+| 13 | emotion_011 | 尴尬 | Awkward |
+| 14 | emotion_080 | 感谢 | Thanks |
+| 15 | emotion_204 | 拒绝 | SayNo |
+| 16 | emotion_078 | 赞 | Like |
+| 17 | emotion_024 | 鼓掌 | Clap |
+| 18 | emotion_105 | 打招呼 | Hi |
+| 19 | emotion_159 | 666 | 666 |
+| 20 | emotion_079 | 抱拳 | Salute |
+| 21 | emotion_025 | 握手 | Shake |
+| 22 | emotion_023 | OK | OK |
+| 23 | emotion_033 | 胜利 | Peace |
+| 24 | emotion_142 | 向左 | Left |
+| 25 | emotion_143 | 向右 | Right |
+| 26 | emotion_144 | 向上 | Up |
+| 27 | emotion_145 | 向下 | Down |
+| 28 | emotion_185 | 来呀 | Come |
+| 29 | emotion_155 | 一点点 | ALittle |
+| 30 | emotion_179 | 捏住 | Pinch |
+| 31 | emotion_140 | 比心 | FingerHeart |
+| 32 | emotion_106 | 送花花 | Flower |
+| 33 | emotion_178 | 加油干 | MakeEffort |
+| 34 | emotion_013 | 调皮 | Tongueout |
+| 35 | emotion_014 | 大笑 | Laugh |
+| 36 | emotion_015 | 惊讶 | Scowl |
+| 37 | emotion_016 | 流汗 | Sweat |
+| 38 | emotion_017 | 奋斗 | Fight |
+| 39 | emotion_018 | 口罩 | Mask |
+| 40 | emotion_019 | 生病 | Sick |
+| 41 | emotion_020 | 吐 | Barf |
+| 42 | emotion_021 | 难过 | Bummed |
+| 43 | emotion_022 | 抓狂 | Crazy |
+| 44 | emotion_026 | 右哼哼 | Humph |
+| 45 | emotion_027 | 太阳 | Sunny |
+| 46 | emotion_028 | 月亮 | Moon |
+| 47 | emotion_029 | 强 | Thumbsup |
+| 48 | emotion_030 | 弱 | Thumbsdown |
+| 49 | emotion_031 | 彩带 | Tada |
+| 50 | emotion_032 | 蛋糕 | Cake |
+| 51 | emotion_034 | 骷髅 | Skull |
+| 52 | emotion_035 | 撇嘴 | Pout |
+| 53 | emotion_037 | 鄙视 | Dislike |
+| 54 | emotion_038 | 嘘 | Shhh |
+| 55 | emotion_040 | 思考 | Hmm… |
+| 56 | emotion_041 | 亲亲 | Kiss |
+| 57 | emotion_042 | 无奈 | Disappointed |
+| 58 | emotion_043 | 感冒 | Pollution |
+| 59 | emotion_044 | 对不起 | Sorry |
+| 60 | emotion_045 | 再见 | Wave |
+| 61 | emotion_046 | 投降 | GiveUp |
+| 62 | emotion_047 | 哼 | Grumpy |
+| 63 | emotion_048 | 欠扁 | FaceSlap |
+| 64 | emotion_049 | 拜托 | Please |
+| 65 | emotion_050 | 可怜 | Aww… |
+| 66 | emotion_051 | 舒服 | Relax |
+| 67 | emotion_052 | 爱意 | Romantic |
+| 68 | emotion_054 | 财迷 | MoneyMoney |
+| 69 | emotion_055 | 迷惑 | Puzzled |
+| 70 | emotion_056 | 委屈 | Worried |
+| 71 | emotion_057 | 灵感 | Idea |
+| 72 | emotion_058 | 天使 | Angel |
+| 73 | emotion_059 | 鬼脸 | SillyFace |
+| 74 | emotion_060 | 凄凉 | Phew |
+| 75 | emotion_061 | 郁闷 | Tired |
+| 76 | emotion_063 | 坏笑 | Trick |
+| 77 | emotion_064 | 算账 | SoMuch |
+| 78 | emotion_206 | PK | PK |
+| 79 | emotion_066 | 忍者 | Sneaky |
+| 80 | emotion_039 | 衰 | Grr |
+| 81 | emotion_067 | 炸弹 | Uh-Oh |
+| 82 | emotion_081 | 笑哭 | LaughAndCry |
+| 83 | emotion_082 | 嘿嘿 | Smirk |
+| 84 | emotion_083 | 捂脸哭 | Facepalm |
+| 85 | emotion_084 | 抠鼻 | NosePick |
+| 86 | emotion_085 | 流鼻血 | BloodyNose |
+| 87 | emotion_090 | 呲牙 | Grin |
+| 88 | emotion_091 | 吃瓜 | EatingMelon |
+| 89 | emotion_092 | 彩虹 | Rainbow |
+| 90 | emotion_098 | 耶 | Yeah |
+| 91 | emotion_012 | 发怒 | Steamed |
+| 92 | emotion_100 | 捂眼睛 | CannotLook |
+| 93 | emotion_101 | 推眼镜 | PushGlasses |
+| 94 | emotion_102 | 暗中观察 | Peep |
+| 95 | emotion_103 | 脑暴 | Brainstorming |
+| 96 | emotion_112 | 冷笑 | Distressed |
+| 97 | emotion_208 | 热 | HotFace |
+| 98 | emotion_113 | 开心 | Happy |
+| 99 | emotion_114 | 惊喜 | Surprised |
+| 100 | emotion_115 | 回头 | LookBack |
+| 101 | emotion_116 | 白眼 | RollEyes |
+| 102 | emotion_117 | 一团乱麻 | Overwhelmed |
+| 103 | emotion_149 | 黑眼圈 | DarkCircle |
+| 104 | emotion_225 | 裂开 | Broken |
+| 105 | emotion_156 | 恭喜 | Congrats |
+| 106 | emotion_160 | 费解 | Confuse |
+| 107 | emotion_167 | 收到 | RogerThat |
+| 108 | emotion_186 | 快来 | ComeOn |
+| 109 | emotion_086 | 敲打 | Hammer |
+| 110 | emotion_176 | 捧脸 | HoldFace |
+| 111 | emotion_194 | Get | Get |
+| 112 | emotion_207 | 客服 | CustomerService |
+| 113 | emotion_210 | AR | AR |
+| 114 | emotion_221 | 小蜜蜂 | Bee |
+| 115 | emotion_211 | 虎虎生威 | MajesticTiger |
+| 116 | emotion_222 | 兔飞猛进 | Rabbit |
+| 117 | emotion_226 | 龙头老大 | Dragon Face |
+| 118 | emotion_236 | 蛇来运转 | Good luck |
+| 119 | emotion_238 | 马上来财 | Wealth is coming |
+| 120 | emotion_093 | 专注 | Concentrate |
+| 121 | emotion_166 | 忙疯了 | CrazyBusy |
+| 122 | emotion_187 | 等一等 | Wait |
+| 123 | emotion_227 | 一脸苦笑 | Wry Smile |
+| 124 | emotion_228 | 王之蔑视 | Unamused |
+| 125 | emotion_229 | 洪荒之力 | Amazing |
+| 126 | emotion_234 | 向左看 | Thinking |
+| 127 | emotion_235 | 向右看 | Pondering |
+| 128 | emotion_230 | YYDS | YYDS |
+| 129 | emotion_231 | 这边请 | ThisWayPlease |
+| 130 | emotion_232 | 弹射下班 | OffDuty |
+| 131 | emotion_233 | 退退退 | Back |
+| 132 | emotion_188 | 在吗 | Hello? |
+| 133 | emotion_189 | 让人头大 | Hard |
+| 134 | emotion_089 | 摊手 | Smugshrug |
+| 135 | emotion_088 | 抱抱 | Hug |
+| 136 | emotion_158 | 举手 | RaiseHand |
+| 137 | emotion_177 | 开车 | Driving |
+| 138 | emotion_191 | 抱大腿 | Follow |
+| 139 | emotion_087 | 跪了 | YouWin |
+| 140 | emotion_162 | 鞠躬 | Bow |
+| 141 | emotion_180 | 选我 | PickMe |
+| 142 | emotion_209 | 元气满满 | FullOfVitality |
+| 143 | emotion_168 | 会议 | Meeting |
+| 144 | emotion_095 | 猫咪 | Kitty |
+| 145 | emotion_094 | 二哈 | Doggy |
+| 146 | emotion_097 | 狗子 | Puppy |
+| 147 | emotion_111 | 三多 | SanDuo |
+| 148 | emotion_153 | 承让 | LetMeWin |
+| 149 | emotion_154 | 撒花 | Celebration |
+| 150 | emotion_070 | 礼物 | Present |
+| 151 | emotion_104 | 生日快乐 | Birthday |
+| 152 | emotion_071 | 爱心 | Love |
+| 153 | emotion_072 | 心碎 | BrokenHeart |
+| 154 | emotion_073 | 嘴唇 | Lips |
+| 155 | emotion_074 | 鲜花 | Rose |
+| 156 | emotion_075 | 残花 | Wilted |
+| 157 | emotion_077 | 干杯 | Cheers |
+| 158 | emotion_151 | 咖啡 | Coffee |
+| 159 | emotion_152 | 奶茶 | MilkTea |
+| 160 | emotion_202 | 茶 | Tea |
+| 161 | emotion_218 | OKR | OKR |
+| 162 | emotion_109 | KPI | KPI |
+| 163 | emotion_108 | 100分 | 100 |
+| 164 | emotion_110 | 对勾 | Check |
+| 165 | emotion_192 | 打叉 | Wrong |
+| 166 | emotion_174 | 气泡 | Bubble |
+| 167 | emotion_157 | 加一 | PlusOne |
+| 168 | emotion_193 | Done | Done |
+| 169 | emotion_146 | 钉子 | Staple |
+| 170 | emotion_076 | 出差 | BusinessTrip |
+| 171 | emotion_181 | 高铁 | HighSpeedTrain |
+| 172 | emotion_184 | 火箭 | Rocket |
+| 173 | emotion_068 | 邮件 | Mail |
+| 174 | emotion_163 | 文档 | Document |
+| 175 | emotion_164 | 演示 | Presentation |
+| 176 | emotion_165 | 表格 | Sheet |
+| 177 | emotion_213 | 废纸篓 | Wastebasket |
+| 178 | emotion_237 | 手机 | MobilePhone |
+| 179 | emotion_203 | 时间 | Time |
+| 180 | emotion_217 | 静音 | mute |
+| 181 | emotion_201 | 公文包 | Briefcase |
+| 182 | emotion_214 | 地球 | Earth |
+| 183 | emotion_215 | 碳减排 | CarbonReduction |
+| 184 | emotion_216 | 回收标志 | RecyclingSymbol |
+| 185 | emotion_205 | 幼苗 | Seedling |
+| 186 | emotion_096 | 红包 | RedPacket |
+| 187 | emotion_150 | 锦鲤 | LuckyDog |
+| 188 | emotion_148 | 福 | Luck |
+| 189 | emotion_198 | 灯笼 | Lantern |
+| 190 | emotion_199 | 爆竹 | Firecrackers |
+| 191 | emotion_197 | 烟花 | Fireworks |
+| 192 | emotion_195 | 恭喜发财 | Prosperity |
+| 193 | emotion_161 | 月饼 | MoonCake |
+| 194 | emotion_173 | 鸡腿 | ChickenLeg |
+| 195 | emotion_169 | 休假 | Vacation |
+| 196 | emotion_175 | 火 | Hot |
+| 197 | emotion_223 | 点赞 | LikeHeartAndTripleSix |
+| 198 | emotion_196 | 平安健康 | Peace&Health |
+| 199 | emotion_200 | 定胜 | Victory |
diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md
new file mode 100644
index 00000000..8729ef3b
--- /dev/null
+++ b/skills/mono/references/products/chat.md
@@ -0,0 +1,1420 @@
+# 会话与群聊 (chat) 命令参考
+
+## 命令总览
+
+### group (群组管理)
+
+| 子命令 | 用途 |
+|-------|------|
+| `group create` | 创建内部群 |
+| `group members list` | 查看群成员列表 |
+| `group members add` | 添加群成员 |
+| `group members remove` | 移除群成员(⚠️ 危险操作) |
+| `group members add-bot` | 添加机器人到群 |
+| `group rename` | 修改群名称 |
+| `group get-by-group-id` | 按数字群号获取会话信息(群号转 openConversationId) |
+| `group transfer-owner` | 转让群主 |
+| `group invite-url` | 获取群邀请链接 |
+| `group quit` | 退出群聊 |
+| `group update-icon` | 更新群头像 |
+| `group update-settings` | 更新群设置 |
+| `group set-admin` | 设置/取消群管理员 |
+| `group member-role` | 群自定义角色(add / list / update / remove / set-user / remove-user / query-user) |
+
+### message (会话消息管理)
+
+| 子命令 | 用途 |
+|-------|------|
+| `message send` | 以当前用户身份发消息(群聊或单聊) |
+| `message recall-by-bot` | 撤回机器人发出的消息(开源 dws v1.0.30 不提供撤回普通用户消息的命令)|
+| `message send-by-bot` | 机器人发消息(群聊或批量单聊) |
+| `message recall-by-bot` | 机器人撤回消息 |
+| `message send-by-webhook` | 自定义机器人 Webhook 发消息 |
+| `message send-card` / `update-card` | 创建并推送流式卡片,配合更新内容 |
+| `message reply` | 引用回复消息(单聊/群聊均可) |
+| `message forward` | 转发单条消息(源/目标会话均支持单聊/群聊) |
+| `message list` | 拉取群聊或单聊会话消息 |
+| `message list-all` | 按时间范围拉取当前用户所有会话消息 |
+| `message list-topic-replies` | 拉取群话题回复消息列表 |
+| `message list-by-sender` | 搜索指定发送者的消息 |
+| `message list-mentions` | 拉取 @我 的消息 |
+| `message list-focused` | 拉取特别关注人的消息 |
+| `message list-unread-conversations` | 获取未读会话列表 |
+| `message list-by-ids` | 按消息 ID 批量获取消息 |
+| `message search` | 按关键词搜索消息 |
+| `message search-advanced` | 多维度搜索消息(推荐首选,严格超集) |
+| `message query-send-status` | 查询消息发送状态(按 openTaskId) |
+| `message query-read-status` | 查询消息已读/未读状态 |
+| `message add-emoji` / `remove-emoji` | 给消息添加/移除 emoji 表情回应 |
+| `message add-text-emotion` / `remove-text-emotion` | 添加/移除文字表情回应 |
+| `message create-text-emotion` | 创建文字表情模板(返回 emotionId 供 add-text-emotion 使用) |
+
+### chat 顶级命令
+
+| 子命令 | 用途 |
+|-------|------|
+| `search` | 根据关键词搜索群聊 |
+| `search-common` | 搜索共同群 |
+| `conversation-info` | 获取会话基础信息(含 newCSpaceIdIM) |
+| `list-top-conversations` | 拉取置顶会话列表 |
+| `set-top` | 设置/取消会话置顶 |
+| `mute` | 会话消息免打扰开关 |
+| `group-mute` | 群全员禁言开关 |
+| `group-mute-member` | 群成员禁言开关 |
+| `list-categories` | 列出用户自定义会话分类 |
+| `list-conversations` | 按分类拉取会话列表 |
+
+### bot (机器人管理)
+
+| 子命令 | 用途 |
+|-------|------|
+| `bot search` | 搜索我创建的机器人 |
+
+---
+
+## group create — 创建内部群
+
+当前登录用户自动成为群主。
+
+```
+Usage:
+ dws chat group create [flags]
+Example:
+ dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3
+Flags:
+ --users string 成员 userId 列表,用户本身会自动加入,无需包含,逗号分隔,不超过20个 (必填)
+ --name string 群名称 (必填)
+```
+
+---
+
+## group members list — 查看群成员列表
+
+分页查询指定群聊的成员。
+
+```
+Usage:
+ dws chat group members list [flags]
+Example:
+ dws chat group members list --id
+Flags:
+ --cursor string 分页游标,首次从 0 开始
+ --id string 群 ID / openconversation_id (必填)
+```
+
+> ⚠️ 注意:v1.0.17 起 list 是显式子命令;旧用法 `dws chat group members --id ...` 已不再支持,请改用 `dws chat group members list --id ...`。
+
+---
+
+## group members add — 添加群成员
+
+向指定群聊添加成员,需传入群 ID 与用户 ID 列表。
+
+```
+Usage:
+ dws chat group members add [flags]
+Example:
+ dws chat group members add --id --users userId1,userId2
+Flags:
+ --id string 群 ID / openconversation_id (必填)
+ --users string 要添加的用户 userId 列表,逗号分隔 (必填)
+```
+
+---
+
+## group members remove — 移除群成员
+
+> ⚠️ 危险操作:执行前必须向用户确认,同意后才加 `--yes`。
+
+```
+Usage:
+ dws chat group members remove [flags]
+Example:
+ dws chat group members remove --id --users userId1,userId2
+Flags:
+ --id string 群 ID / openconversation_id (必填)
+ --users string 要移除的用户 userId 列表,逗号分隔 (必填)
+```
+
+---
+
+## group members add-bot — 添加机器人到群
+
+将自定义机器人添加到当前用户有管理权限的群聊中,如果没有权限则会报错。
+
+```
+Usage:
+ dws chat group members add-bot [flags]
+Example:
+ dws chat group members add-bot --robot-code --id
+Flags:
+ --id string 群聊 openConversationId (必填)
+ --robot-code string 机器人 Code (必填)
+```
+
+---
+
+## group rename — 修改群名称
+
+```
+Usage:
+ dws chat group rename [flags]
+Example:
+ dws chat group rename --id --name "新群名"
+Flags:
+ --id string 群 ID / openconversation_id (必填)
+ --name string 修改后的群名称 (必填)
+```
+
+---
+
+## search — 搜索群会话
+
+根据关键词搜索群聊会话,分页返回匹配的群列表。
+
+```
+Usage:
+ dws chat search [flags]
+Example:
+ dws chat search --query "项目冲刺"
+ dws chat search --query "项目冲刺" --limit 20 --cursor 0
+Flags:
+ --keyword string 搜索关键词 (必填,旧版别名: --query)
+ --limit int 每页返回数量(默认 20)
+ --cursor string 分页游标(默认 "0",翻页传 nextCursor)
+
+注意:
+ - 旧版命令使用 `--query`,新版统一改为 `--keyword`;`--query` 仍可作为别名使用
+ - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
+```
+
+---
+
+## search-common — 搜索共同群
+
+根据昵称列表搜索共同群聊。--nicks 指定要搜索的人员昵称(逗号分隔,必填)。--match-mode 控制匹配模式:AND 表示所有人都在群里,OR 表示任一人在群里(默认 AND)。
+
+```
+Usage:
+ dws chat search-common [flags]
+Example:
+ dws chat search-common --nicks "风雷,山乔" --limit 20 --cursor 0
+ dws chat search-common --nicks "天鸡,乐函" --match-mode OR --limit 20 --cursor 0
+ dws chat search-common --nicks "风雷,山乔,天鸡" --limit 10 --cursor
+Flags:
+ --nicks string 要搜索的昵称列表,逗号分隔 (必填)
+ --match-mode string 匹配模式:AND=所有人都在群里,OR=任一人在群里(默认 AND)
+ --limit int 每页返回数量(默认 20)
+ --cursor string 分页游标(默认 "0",翻页传 nextCursor)
+
+注意:
+ - --nicks 传人员昵称(花名),逗号分隔,如 "风雷,山乔"
+ - --match-mode AND 表示群里必须包含所有指定的人;OR 表示包含任意一人即可
+ - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
+```
+
+---
+
+## message send — 以当前用户身份发消息
+
+--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。`--title` 是消息标题,**群聊与单聊都必填**(API 强制要求;缺失时服务端返回误导性的 "发群服务窗会话消息失败",CLI 现在前置校验直接报错)。
+--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
+--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
+
+```
+Usage:
+ dws chat message send [flags] []
+Example:
+ dws chat message send --group --title "周报" --text "请提交本周日报"
+ dws chat message send --user --title "提醒" --text "请查收"
+ dws chat message send --open-dingtalk-id --title "提醒" --text "请查收"
+ dws chat message send --group --title "通知" "hello"
+ dws chat message send --group --title "周报提醒" --text "请大家本周五前提交周报"
+ dws chat message send --group --title "通知" --at-all "<@all> 请大家注意"
+ dws chat message send --group --title "通知" --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
+ dws chat message send --group --title "图片" --media-id
+ dws chat message send --open-dingtalk-id --title "图片" --media-id
+Flags:
+ --text string 消息内容(推荐使用,也可用位置参数)
+ --group string 群聊 openconversation_id(群聊时必填)
+ --user string 接收人 userId(单聊时与 --open-dingtalk-id 二选一)
+ --open-dingtalk-id string 接收人 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
+ --title string 消息标题(必填)
+ --at-all @所有人(仅群聊时生效,可选,默认 false)
+ --at-users string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选)
+ --at-mobiles string @指定成员的手机号列表,逗号分隔(仅群聊时生效,可选)
+ --media-id string 图片 mediaId(通过 dt_media_upload 工具上传获得,需从返回链接中去除 _宽_高.格式 后缀并加上 @ 前缀),指定后发送图片消息,不需要传文本内容
+ --msg-type string 消息类型(可选,如 text/markdown/image/file;通常由 --text/--media-id/--dentry-id 自动推断)
+ --dentry-id string 钉盘文件 dentryId(发送钉盘文件消息时使用,需配合 --space-id)
+ --space-id string 钉盘空间 spaceId(与 --dentry-id 配合使用)
+ --file-name string 文件消息的文件名
+ --file-size string 文件消息的文件大小(字节)
+ --file-type string 文件消息的文件类型(如 pdf / docx / xlsx 等)
+
+注意:
+ - --text 和位置参数二选一,--text 优先
+ - --title 必填(群聊与单聊都必填,API 强制要求)
+ - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
+ - --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
+ - --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
+ - --media-id 指定图片 mediaId 时自动发送图片消息(msgType=image),不需要传 --text;图片单聊仅支持 --open-dingtalk-id,不支持 --user
+ - 发送钉盘文件消息:传 --dentry-id + --space-id(必要时配合 --file-name / --file-size / --file-type),msg-type 自动推断为 file
+```
+
+---
+
+## message list — 拉取会话消息内容
+
+拉取指定群聊或单聊的会话消息内容。
+
+--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。
+
+```
+Usage:
+ dws chat message list [flags]
+Example:
+ # 拉取群聊中某个时间点之后的消息
+ dws chat message list --group --time "2025-03-01 00:00:00"
+ # 拉取单聊消息(通过 userId)
+ dws chat message list-direct --user --time "2025-03-01 00:00:00" --limit 50
+ # 拉取单聊消息(通过 openDingTalkId)
+ dws chat message list-direct --open-dingtalk-id --time "2025-03-01 00:00:00" --limit 50
+ # 拉取某个时间点之前的消息(向过去翻页)
+ dws chat message list --group --time "2025-03-01 00:00:00" --forward=false
+Flags:
+ --forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
+ --group string 群聊 openconversation_id(群聊时必填)
+ --limit int 返回数量,不传则不限制
+ --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(不传则默认拉取最新消息)
+ --user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
+ --open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
+
+注意:
+ - --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
+ - --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
+ - 如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息,需要调用 dws chat message list-topic-replies 拉取话题的回复内容列表,openConvThreadId 作为 --topic-id 参数
+```
+
+### 分页翻页说明(重要)
+
+`message list` 的翻页方式与 `message list-all` **完全不同**,请勿混淆:
+
+| 命令 | 翻页参数 | 翻页值来源 | 值格式 |
+|------|---------|-----------|--------|
+| `message list` | `--time` | 上一页结果中**最后一条消息的 `createTime` 字段** | `yyyy-MM-dd HH:mm:ss`(如 `"2025-03-01 14:30:00"`) |
+| `message list-all` | `--cursor` | 上一页响应中的 `nextCursor` 字段 | 字符串(如 `"abc123token"`) |
+
+**翻页步骤(message list):**
+
+1. **首次请求**:指定起始时间
+ ```bash
+ dws chat message list --group --time "2025-03-01 00:00:00" --limit 50 --format json
+ ```
+2. **检查响应**:查看 `hasMore` 字段
+ - `hasMore: false` → 没有更多消息,翻页结束
+ - `hasMore: true` → 还有更多消息,继续下一步
+3. **获取翻页时间**:取返回结果中**最后一条消息**的 `createTime` 字段值(如 `"2025-03-01 14:30:00"`)
+4. **下一页请求**:将该 `createTime` 作为 `--time` 传入
+ ```bash
+ dws chat message list --group --time "2025-03-01 14:30:00" --limit 50 --format json
+ ```
+5. 重复步骤 2-4 直到 `hasMore: false`
+
+> ⚠️ **常见错误**:
+> - **不要把 `nextCursor` 传给 `--time`**:响应中的 `nextCursor` 字段(纯数字时间戳如 `1776684611219`)**不是给 `--time` 用的**。`--time` 只接受 `yyyy-MM-dd HH:mm:ss` 格式。将 `nextCursor` 传给 `--time` 会导致返回相同页面,陷入死循环。`nextCursor` 仅用于 `message list-all` 的 `--cursor` 参数。
+> - **不要把 `nextCursor` 传给 `--forward`**:`--forward` 只接受 `true`(拉给定时间之后的消息)或 `false`(拉给定时间之前的消息),不是时间戳或游标参数。
+
+---
+
+## message list-all — 拉取指定时间范围内当前用户的所有会话消息
+
+分页拉取当前登录用户在指定时间范围内的所有会话消息。
+
+--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 "0",后续从响应中的 nextCursor 获取)。
+
+```
+Usage:
+ dws chat message list-all [flags]
+Example:
+ dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50
+ dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token"
+Flags:
+ --start string 起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
+ --end string 结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)
+ --limit int 每页返回数量(默认 50)
+ --cursor string 分页游标(首页传 "0",后续从响应中的 nextCursor 获取)
+
+注意:
+ - 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
+ - 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
+ - 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
+ - 时间格式统一为 yyyy-MM-dd HH:mm:ss
+```
+
+---
+
+## message list-topic-replies — 拉取群话题回复消息列表
+
+查询指定群聊中某条话题消息的全部回复。--group 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。
+
+```
+Usage:
+ dws chat message list-topic-replies [flags]
+Example:
+ dws chat message list-topic-replies --group --topic-id
+ dws chat message list-topic-replies --group --topic-id --time "2025-03-01 00:00:00" --limit 20
+Flags:
+ --group string 群会话 openconversationId (必填)
+ --topic-id string 话题 ID,由 dws chat message list 返回 (必填)
+ --time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(可选)
+ --limit int 返回数量(默认 50)
+ --forward true=从老往新,false=从新往老(默认 false)
+```
+
+---
+
+## message list-by-sender — 拉取指定发送者的消息
+
+搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。
+
+```
+Usage:
+ dws chat message list-by-sender [flags]
+Example:
+ dws chat message list-by-sender --sender-user-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
+ dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
+Flags:
+ --sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一)
+ --sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一)
+ --start string 开始时间,ISO-8601 格式 (必填)
+ --end string 结束时间,ISO-8601 格式 (必填)
+ --limit int 每页返回数量(默认 50)
+ --cursor string 分页游标(默认 "0",翻页传 nextCursor)
+
+注意:
+ - --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一
+ - 不需要指定单聊/群聊,MCP 返回结果自带会话类型标识
+ - 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00" 等
+ - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
+```
+
+---
+
+## message list-mentions — 拉取 @我 的消息
+
+搜索时间范围内 @我 的消息,可选指定群聊。
+
+```
+Usage:
+ dws chat message list-mentions [flags]
+Example:
+ dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
+ dws chat message list-mentions --group --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
+Flags:
+ --group string 群聊 openconversation_id(可选,不传则查全部)
+ --start string 开始时间,ISO-8601 格式 (必填)
+ --end string 结束时间,ISO-8601 格式 (必填)
+ --limit int 每页返回数量(默认 50)
+ --cursor string 分页游标(默认 "0",翻页传 nextCursor)
+
+注意:
+ - --group 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊
+ - --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
+ - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
+```
+
+---
+
+## message list-focused — 拉取特别关注人的消息
+
+拉取当前用户特别关注人的消息。
+
+```
+Usage:
+ dws chat message list-focused [flags]
+Example:
+ dws chat message list-focused --limit 50
+ dws chat message list-focused --limit 20 --cursor
+Flags:
+ --limit int 每页返回数量(默认 50)
+ --cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor)
+
+注意:
+ - 首次调用不传 --cursor 或传 0,后续翻页传 nextCursor
+```
+
+---
+
+## message list-unread-conversations — 获取未读会话列表
+
+获取当前用户有未读消息的会话信息。可选通过 `--count` 限制返回条数。
+
+```
+Usage:
+ dws chat message list-unread-conversations [flags]
+Example:
+ dws chat message list-unread-conversations
+ dws chat message list-unread-conversations --count 20
+Flags:
+ --count int 返回未读会话条数(可选)
+```
+
+---
+
+## message search — 按关键词搜索消息
+
+在当前用户的会话中按关键词搜索消息。--keyword 必填,可选 --group 限定搜索某个会话。
+
+```
+Usage:
+ dws chat message search [flags]
+Example:
+ dws chat message search --keyword "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0
+ dws chat message search --keyword "codereview" --group --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
+Flags:
+ --keyword string 搜索关键词 (必填)
+ --group string 群聊 openconversation_id(可选,不传则搜索所有会话)
+ --start string 开始时间,ISO-8601 格式 (必填)
+ --end string 结束时间,ISO-8601 格式 (必填)
+ --limit int 每页返回数量(默认 100)
+ --cursor string 分页游标(默认 "0",翻页传 nextCursor)
+
+注意:
+ - --group 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话
+ - --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
+ - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
+```
+
+---
+
+## conversation-info — 获取会话基础信息
+
+按会话 ID 获取单聊或群聊的基础元数据(名称、类型、成员数等)。在对会话执行操作前用来确认上下文。
+
+```
+Usage:
+ dws chat conversation-info [flags]
+Example:
+ dws chat conversation-info --group
+ dws chat conversation-info --open-dingtalk-id
+Flags:
+ --group string 群聊会话 ID openConversationId(与 --open-dingtalk-id 二选一)
+ --open-dingtalk-id string 用户 openDingTalkId(单聊时与 --group 二选一)
+
+注意:
+ - --group(群聊 openConversationId)和 --open-dingtalk-id(单聊用户 openDingTalkId)二选一
+```
+
+---
+
+## list-top-conversations — 拉取置顶会话列表
+
+拉取当前用户的置顶会话列表。
+
+```
+Usage:
+ dws chat list-top-conversations [flags]
+Example:
+ dws chat list-top-conversations --limit 1000
+ dws chat list-top-conversations --limit 1000 --cursor
+Flags:
+ --limit int 每页返回数量(默认 1000)
+ --cursor int 分页游标(首次不传或传 0,翻页传 nextCursor)
+
+注意:
+ - 用户询问"置顶会话"时,直接调用此命令返回置顶会话列表即可
+ - 用户询问"置顶消息"时,需两步:先调用此命令拉取置顶会话列表获取各会话的 openConversationId,再用 `chat message list --group ` 分别拉取每个会话内的消息
+ - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
+```
+
+---
+
+## bot search — 搜索我的机器人
+
+```
+Usage:
+ dws chat bot search [flags]
+Example:
+ dws chat bot search --page 1
+ dws chat bot search --page 1 --size 10 --name "日报"
+Flags:
+ --name string 按名称搜索
+ --page int 页码,从1开始 (默认 1)
+ --size int 每页条数 (默认 50),别名: --limit
+```
+
+---
+
+## message send-by-bot — 机器人发消息
+
+群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。
+
+```
+Usage:
+ dws chat message send-by-bot [flags]
+Example:
+ dws chat message send-by-bot --robot-code --group --title "日报" --text "## 今日完成..."
+ dws chat message send-by-bot --robot-code --users userId1,userId2 --title "提醒" --text "请提交周报"
+Flags:
+ --group string 群聊 openConversationId(群聊时必填)
+ --robot-code string 机器人 Code (必填)
+ --text string 消息内容 Markdown (必填)
+ --title string 消息标题 (必填)
+ --users string 用户 userId 列表,逗号分隔,最多20个(单聊时必填)
+
+注意:
+ - --group 与 --users 互斥,必须且只能指定其一
+ - --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
+```
+
+---
+
+## message recall-by-bot — 机器人撤回消息
+
+群聊:传 --group 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。
+
+```
+Usage:
+ dws chat message recall-by-bot [flags]
+Example:
+ dws chat message recall-by-bot --robot-code --group --keys
+ dws chat message recall-by-bot --robot-code --keys key1,key2
+Flags:
+ --group string 群聊 openConversationId(群聊撤回时必填)
+ --keys string 消息 processQueryKey 列表,逗号分隔 (必填)
+ --robot-code string 机器人 Code (必填)
+```
+
+---
+
+## message send-by-webhook — 自定义机器人 Webhook 发消息
+
+@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。
+
+```
+Usage:
+ dws chat message send-by-webhook [flags]
+Example:
+ dws chat message send-by-webhook --token --title "告警" --text "CPU 超 90%" --at-all
+ dws chat message send-by-webhook --token --title "test" --text "hi @118785" --at-users 118785
+Flags:
+ --at-all @ 所有人
+ --at-mobiles string @ 指定手机号,逗号分隔
+ --at-users string @ 指定用户,逗号分隔(需在 text 中包含 @userId)
+ --text string 消息内容 (必填)
+ --title string 消息标题 (必填)
+ --token string Webhook Token (必填)
+```
+
+---
+
+---
+
+## group get-by-group-id — 按数字群号获取会话信息
+
+当用户只提供数字群号而非 openConversationId 时,用此命令把群号转换为 openConversationId,再调用其他需要 openConversationId 的命令。
+
+```
+Usage:
+ dws chat group get-by-group-id [flags]
+Example:
+ dws chat group get-by-group-id --group-id 12345678
+Flags:
+ --group-id string 群号 (必填,数字字符串)
+```
+
+---
+
+## group transfer-owner — 转让群主
+
+将群主身份转让给群内其他成员。--new-owner 传新群主的 openDingTalkId。
+
+```
+Usage:
+ dws chat group transfer-owner [flags]
+Example:
+ dws chat group transfer-owner --group --new-owner
+ # 查询群 ID: dws chat search --query "群名"
+ # 查询 openDingTalkId: dws contact user search --query "姓名"
+Flags:
+ --group string 群聊 openConversationId (必填)
+ --new-owner string 新群主 openDingTalkId (必填)
+```
+
+---
+
+## group invite-url — 获取群邀请链接
+
+获取指定群聊的邀请加入链接。可选 --expires-seconds 指定链接有效期(秒),0 表示永久有效,不传则使用服务端默认值。
+
+```
+Usage:
+ dws chat group invite-url [flags]
+Example:
+ dws chat group invite-url --group
+ dws chat group invite-url --group --expires-seconds 86400
+ dws chat group invite-url --group --expires-seconds 0
+Flags:
+ --group string 群聊 openConversationId (必填)
+ --expires-seconds int 链接有效期(秒),0=永久有效,不传使用服务端默认值
+```
+
+---
+
+## group quit — 退出群聊
+
+当前用户退出指定群聊。
+
+```
+Usage:
+ dws chat group quit [flags]
+Example:
+ dws chat group quit --group
+Flags:
+ --group string 群聊 openConversationId (必填)
+```
+
+---
+
+## group update-icon — 更新群头像
+
+更新指定群聊的群头像,--icon-media-id 传通过 media upload 获取的 mediaId。
+
+```
+Usage:
+ dws chat group update-icon [flags]
+Example:
+ dws chat group update-icon --group --icon-media-id
+Flags:
+ --group string 群聊 openConversationId (必填)
+ --icon-media-id string 群头像 mediaId (必填,先用 media upload 上传获取)
+```
+
+---
+
+## group update-settings — 更新群设置
+
+--setting-key 指定设置项,--status 指定值(通常 "0"=关闭,"1"=开启;含义随 settingKey 变化)。
+
+支持的 settingKey: `authority`、`joinValidation`、`onlyAdminCanAtAll`、`searchable`、`addFriendForbidden`、`toolbarStatus`、`pluginCustomizeVerify`、`onlyAdminCanDING`、`allMembersCanCreateMcsConf`、`onlyAdminCanSetMsgTop`、`onlyAdminCanPinMsg`、`onlyAdminCanSendFile`、`allMembersCanCreateCalendar`、`groupEmailDisabled`、`groupRedEnvelopeSwitch`、`groupLiveAuthority`、`groupBillAuthority`
+
+```
+Usage:
+ dws chat group update-settings [flags]
+Example:
+ dws chat group update-settings --group --setting-key searchable --status 1
+ dws chat group update-settings --group --setting-key onlyAdminCanAtAll --status 0
+Flags:
+ --group string 群聊 openConversationId (必填)
+ --setting-key string 群设置项 key (必填)
+ --status string 设置值: "0"=关闭, "1"=开启 (必填,字符串类型)
+```
+
+---
+
+## group set-admin — 设置/取消群管理员
+
+默认设为管理员,传 --off 取消。--users 传目标成员的 openDingTalkId 列表(逗号分隔)。
+
+```
+Usage:
+ dws chat group set-admin [flags]
+Example:
+ dws chat group set-admin --group --users openDingTalkId1,openDingTalkId2
+ dws chat group set-admin --group --users openDingTalkId1 --off
+Flags:
+ --group string 群聊 openConversationId (必填)
+ --users string 目标成员 openDingTalkId 列表 (必填,逗号分隔)
+ --off 传 --off 取消管理员(默认设为管理员)
+```
+
+---
+
+## group member-role — 群自定义角色
+
+`group member-role` 系列命令用于管理群的自定义身份/角色标签。包含 7 个子命令:
+
+- `list` — 列出群自定义角色
+- `add` — 新增群自定义角色
+- `update` — 更新群自定义角色名称
+- `remove` — 删除群自定义角色
+- `set-user` — 给成员设置自定义角色(覆盖语义,传空则清除该成员所有角色)
+- `remove-user` — 移除成员的指定自定义角色(不影响其他角色)
+- `query-user` — 查询成员当前持有的所有自定义角色
+
+```
+Usage:
+ dws chat group member-role list --group
+ dws chat group member-role add --group --name "管理员"
+ dws chat group member-role update --group --role-id --name "新名称"
+ dws chat group member-role remove --group --role-id
+ dws chat group member-role set-user --group --user --role-ids roleId1,roleId2
+ dws chat group member-role remove-user --group --user --role-ids roleId1
+ dws chat group member-role query-user --group --user
+
+注意:
+ - --user 传 openDingTalkId(不是 userId),可通过 `dws contact user search --query "姓名"` 获取
+ - --role-id 来源于 `member-role list` 返回的 openRoleId
+ - `set-user` 是覆盖语义:传空 --role-ids 则清除该用户所有角色
+ - `remove-user` 是增量语义:仅移除指定角色,不影响其他角色
+```
+
+---
+
+## message recall-by-bot — 撤回机器人发出的消息
+
+> **能力范围说明**:开源 dws v1.0.30 仅提供 **机器人消息撤回**(`recall-by-bot`),不提供"撤回当前用户自己发出的消息"的命令。如果你想撤回的是普通用户消息,请在钉钉客户端长按消息撤回。
+
+撤回机器人发出的消息,需要机器人 robotCode 和消息 processQueryKey。群聊撤回还需要 `--group `。
+
+```
+Usage:
+ dws chat message recall-by-bot [flags]
+Example:
+ # 群聊撤回机器人消息
+ dws chat message recall-by-bot --robot-code --group --keys
+ # 单聊撤回机器人消息(不传 --group)
+ dws chat message recall-by-bot --robot-code --keys key1,key2
+Flags:
+ --robot-code string 机器人 Code (必填)
+ --group string 群会话 openConversationId (群聊撤回必填;单聊撤回不传)
+ --keys string 逗号分隔的消息 processQueryKey 列表 (必填)
+```
+
+---
+
+## message reply — 引用回复消息
+
+以当前用户身份引用某条消息并回复,**单聊与群聊均可**。需指定会话 ID、被引用消息 ID、被引用消息发送者 openDingTalkId、回复正文。
+
+```
+Usage:
+ dws chat message reply [flags]
+Example:
+ dws chat message reply --conversation-id --ref-msg-id --ref-sender --text "收到,马上处理"
+Flags:
+ --conversation-id string 会话 openConversationId (必填,支持单聊/群聊)
+ --ref-msg-id string 被引用的消息 openMessageId (必填,由 message list 返回)
+ --ref-sender string 被引用消息发送者 openDingTalkId (必填)
+ --text string 回复正文 (必填)
+ --uuid string 幂等 UUID (可选)
+
+注意:
+ - --conversation-id 单聊和群聊使用同一字段;目前回复类型仅支持 text
+```
+
+---
+
+## message forward — 转发消息
+
+转发单条消息,**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单。
+
+```
+Usage:
+ dws chat message forward [flags]
+Example:
+ dws chat message forward --src-conversation-id --msg-id --dest-conversation-id
+Flags:
+ --src-conversation-id string 源会话 openConversationId (必填)
+ --msg-id string 源消息 openMessageId (必填,由 message list 返回)
+ --dest-conversation-id string 目标会话 openConversationId (必填)
+```
+
+---
+
+## message list-by-ids — 按消息 ID 批量获取消息
+
+根据消息 ID 列表批量获取消息内容(最多 50 条)。
+
+```
+Usage:
+ dws chat message list-by-ids [flags]
+Example:
+ dws chat message list-by-ids --msg-ids msgId1,msgId2,msgId3
+Flags:
+ --msg-ids string 消息 openMessageId 列表 (必填,逗号分隔,最多 50 条)
+```
+
+---
+
+## message query-send-status — 查询消息发送状态
+
+查询通过 `message send` 等命令发送的消息的发送状态。需要传入发送时返回的 openTaskId。
+
+```
+Usage:
+ dws chat message query-send-status [flags]
+Example:
+ dws chat message query-send-status --open-task-id
+Flags:
+ --open-task-id string 消息任务 ID openTaskId (必填,来自 send/send-by-bot 返回值)
+```
+
+---
+
+## message query-read-status — 查询消息已读/未读状态
+
+查询指定会话中消息的已读/未读状态(**仅消息发送者**可查询自己发出的消息)。
+
+```
+Usage:
+ dws chat message query-read-status [flags]
+Example:
+ dws chat message query-read-status --group --msg-id
+Flags:
+ --group string 会话 openConversationId (必填,群聊或单聊均可)
+ --msg-id string 消息 openMessageId (必填,必须是当前用户发送的消息)
+
+注意:
+ - 仅消息发送者可查询自己发出的消息的已读/未读状态;查询他人发的消息会报错
+ - --msg-id 从 `dws chat message list` 返回的消息列表中获取(字段名 openMessageId)
+```
+
+---
+
+## message search-advanced — 多维度搜索消息(推荐首选)
+
+> 推荐:这是消息搜索的首选接口。它可以完全替代 `chat message search`(keyword 可选 vs 必填,支持多个会话 vs 单个),大部分替代 `chat message list-by-sender`(通过 --sender-ids 按 openDingTalkId 搜索)和 `chat message list-mentions`(通过 --at-me 搜索 @我 的消息)。仅在以下场景需要退回其他命令:按 userId(非 openDingTalkId)搜发送者时用 list-by-sender,拉取「特别关注人」消息时用 list-focused。
+
+支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度组合搜索消息。所有参数均为可选,至少指定一个搜索条件。
+
+```
+Usage:
+ dws chat message search-advanced [flags]
+Example:
+ dws chat message search-advanced --keyword "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
+ dws chat message search-advanced --sender-ids , --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
+ dws chat message search-advanced --at-me --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
+ dws chat message search-advanced --at-ids , --conversation-ids , --limit 50 --cursor 0
+ dws chat message search-advanced --conversation-ids <单聊openConversationId> --keyword "合同" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
+Flags:
+ --keyword string 搜索关键词(可选)
+ --sender-ids string 发送者 openDingTalkId 列表,逗号分隔(可选)
+ --at-me 只搜索 @我 的消息(可选,默认 false)
+ --at-ids string @指定人的 openDingTalkId 列表,逗号分隔(可选)
+ --conversation-ids string 会话 openConversationId 列表,逗号分隔(可选,群聊或单聊均可,不传则搜索所有会话)
+ --start string 开始时间,ISO-8601 格式(可选)
+ --end string 结束时间,ISO-8601 格式(可选)
+ --cursor string 分页游标(默认 "0")
+ --limit int 每页返回数量(默认 100)
+
+注意:
+ - 所有参数均为可选,但至少需要指定一个搜索条件
+ - --sender-ids 和 --at-ids 传 openDingTalkId(非 userId),可通过 `dws contact user search --query "姓名"` 获取
+ - 群聊 openConversationId 通过 `dws chat search --query "群名"` 获取;单聊 openConversationId 通过 `dws chat conversation-info --open-dingtalk-id ` 获取
+ - 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00"、"2026-03-10" 等
+ - 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
+ - 替代关系:完全替代 search(严格超集);大部分替代 list-by-sender(按 openDingTalkId 搜索)和 list-mentions(--at-me 覆盖核心功能);不能替代 list-focused(「特别关注」是独立维度)
+```
+
+---
+
+## 表情回应(emoji / 文字表情)
+
+钉钉消息支持两种"表情回应":内置 emoji 表情 + 自定义文字表情。优先用 emoji(参见 [chat-emoji-list.md](chat-emoji-list.md) 中的 199 个默认名称,如「赞」「鼓掌」「感谢」),命中即用 `add-emoji`;未命中先用 `create-text-emotion` 创建文字表情拿到 emotionId,再 `add-text-emotion` 贴上。
+
+### message add-emoji — 给消息添加 emoji 表情回应
+
+```
+Usage:
+ dws chat message add-emoji [flags]
+Example:
+ dws chat message add-emoji --group --msg-id --emoji "赞"
+ dws chat message add-emoji --group --msg-id --emoji "鼓掌"
+Flags:
+ --group string 会话 openConversationId (必填,支持单聊/群聊)
+ --msg-id string 消息 openMessageId (必填)
+ --emoji string emoji 表情名称 (必填,参见 chat-emoji-list.md 的 name 字段)
+```
+
+### message remove-emoji — 移除 emoji 表情回应
+
+```
+Usage:
+ dws chat message remove-emoji [flags]
+Example:
+ dws chat message remove-emoji --group --msg-id --emoji "赞"
+Flags:
+ --group string 会话 openConversationId (必填,支持单聊/群聊)
+ --msg-id string 消息 openMessageId (必填)
+ --emoji string emoji 表情名称 (必填)
+```
+
+### message create-text-emotion — 创建文字表情(获取 emotionId)
+
+当 chat-emoji-list.md 中没有所需表情时,先创建再贴。
+
+```
+Usage:
+ dws chat message create-text-emotion [flags]
+Example:
+ dws chat message create-text-emotion --emotion-name "赞" --text "nice"
+ dws chat message create-text-emotion --emotion-name "感谢" --text "感谢" --background-id im_bg_5
+Flags:
+ --emotion-name string 表情名称 (必填)
+ --text string 文字内容 (必填)
+ --background-id string 背景 ID(可选,不传则由服务端默认分配)
+```
+
+### message add-text-emotion — 添加文字表情回应
+
+需要先通过 `create-text-emotion` 创建拿到 emotionId 才能贴。六个参数全部必填。
+
+```
+Usage:
+ dws chat message add-text-emotion [flags]
+Example:
+ dws chat message add-text-emotion --group --msg-id --emotion-id --emotion-name "赞" --text "nice" --background-id im_bg_5
+Flags:
+ --group string 会话 openConversationId (必填,支持单聊/群聊)
+ --msg-id string 消息 openMessageId (必填)
+ --emotion-id string 表情 ID (必填,通过 create-text-emotion 获取)
+ --emotion-name string 表情名称 (必填)
+ --text string 文字内容 (必填)
+ --background-id string 背景 ID (必填)
+```
+
+### message remove-text-emotion — 移除文字表情回应
+
+```
+Usage:
+ dws chat message remove-text-emotion [flags]
+Example:
+ dws chat message remove-text-emotion --group --msg-id --emotion-id --emotion-name "赞" --text "nice" --background-id
+Flags:
+ --group string 会话 openConversationId (必填)
+ --msg-id string 消息 openMessageId (必填)
+ --emotion-id string 表情 ID (必填)
+ --emotion-name string 表情名称 (必填)
+ --text string 文字内容 (必填)
+ --background-id string 背景 ID (必填)
+```
+
+---
+
+## message send-card — 创建并推送流式卡片
+
+向群聊或单聊发送流式卡片消息。群聊传 --group,单聊传 --user(openDingTalkId),二者互斥。
+
+**⚠️ 必须配套使用 `update-card`:** 创建卡片后通过 `update-card` 推送内容更新,**最后一次更新必须将 `--flow-status` 设为 3(finish)**,否则卡片会一直停在"生成中"加载态。
+
+flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。
+
+```
+Usage:
+ dws chat message send-card [flags]
+Example:
+ dws chat message send-card --group
+ dws chat message send-card --user
+ dws chat message send-card --group --card-template-id --card-data '{"key":"value"}'
+Flags:
+ --group string 群聊 openConversationId(群聊时必填,与 --user 互斥)
+ --user string 单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)
+ --card-template-id string 卡片模板 ID(可选)
+ --card-data string 卡片数据 JSON(可选)
+```
+
+## message update-card — 流式更新卡片内容
+
+--biz-id 为 send-card 返回的业务 ID,--flow-status 控制流式状态。**最后一次更新必须将 --flow-status 设为 3(finish)**。
+
+```
+Usage:
+ dws chat message update-card [flags]
+Example:
+ dws chat message update-card --biz-id --content "更新的卡片内容" --flow-status 2
+ dws chat message update-card --biz-id --content "最终内容" --flow-status 3
+Flags:
+ --biz-id string 卡片业务 ID (必填,来自 send-card 返回的 bizId)
+ --content string 卡片消息内容 (必填)
+ --flow-status int 流式状态 (必填,1/2/3/4/5)
+```
+
+---
+
+## set-top — 设置/取消会话置顶
+
+设置/取消会话置顶(**单聊/群聊均可**)。默认置顶,传 --off 取消置顶。
+
+```
+Usage:
+ dws chat set-top [flags]
+Example:
+ dws chat set-top --group
+ dws chat set-top --group --off
+Flags:
+ --group string 会话 openConversationId (必填,支持单聊/群聊)
+ --off 传 --off 取消置顶(默认置顶)
+```
+
+---
+
+## mute — 会话消息免打扰
+
+开启或关闭会话消息免打扰(**单聊/群聊均可**)。默认开启,传 --off 关闭免打扰。
+
+```
+Usage:
+ dws chat mute [flags]
+Example:
+ dws chat mute --group
+ dws chat mute --group --off
+Flags:
+ --group string 会话 openConversationId (必填,支持单聊/群聊)
+ --off 传 --off 关闭免打扰(默认开启)
+```
+
+---
+
+## group-mute — 群全员禁言
+
+全员禁言/取消全员禁言。默认禁言,传 --off 取消。
+
+```
+Usage:
+ dws chat group-mute [flags]
+Example:
+ dws chat group-mute --group
+ dws chat group-mute --group --off
+Flags:
+ --group string 群聊 openConversationId (必填)
+ --off 传 --off 取消全员禁言(默认开启)
+```
+
+---
+
+## group-mute-member — 群成员禁言
+
+指定群成员禁言/解除禁言。默认禁言,传 --off 解除。
+
+--mute-time 为禁言时长(毫秒),仅禁言时必填,常用值:300000(5 分钟)、3600000(1 小时)、86400000(1 天)、604800000(1 周)、2592000000(30 天)。
+
+```
+Usage:
+ dws chat group-mute-member [flags]
+Example:
+ dws chat group-mute-member --group --users openDingTalkId1,openDingTalkId2 --mute-time 3600000
+ dws chat group-mute-member --group --users openDingTalkId1 --off
+Flags:
+ --group string 群聊 openConversationId (必填)
+ --users string 被禁言成员 openDingTalkId 列表 (必填,逗号分隔)
+ --mute-time int 禁言时长(毫秒,仅禁言时必填)
+ --off 传 --off 解除禁言(默认禁言)
+```
+
+---
+
+## list-categories — 列出用户自定义会话分类
+
+获取当前用户的所有自定义会话分类。
+
+```
+Usage:
+ dws chat list-categories
+Example:
+ dws chat list-categories
+```
+
+## list-conversations — 按分类拉取会话列表
+
+根据分类 ID 拉取该分类下的会话列表。分类 ID 通过 `list-categories` 获取。
+
+```
+Usage:
+ dws chat list-conversations [flags]
+Example:
+ dws chat list-conversations --category-id
+Flags:
+ --category-id string 分类 ID (必填,由 list-categories 返回)
+```
+
+
+## 意图判断
+
+用户说"建群/创建群聊" → `chat group create`
+用户说"搜索群/找群" → `chat search`
+用户说"群成员/看群里有谁" → `chat group members list`
+用户说"拉人进群/加群成员" → `chat group members add`
+用户说"踢人/移除群成员" → `chat group members remove`
+用户说"加机器人到群" → `chat group members add-bot`
+用户说"改群名" → `chat group rename`
+用户说"聊天记录/会话消息/拉取会话" → `chat message list`
+用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
+用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(用户明确说"单聊"时使用)
+用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
+用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
+用户说"发群消息(以个人身份)" → `chat message send --group`
+用户说"发单聊消息(以个人身份)" → `chat message send --user`(有 userId 时)或 `chat message send --open-dingtalk-id`(有 openDingTalkId 时)
+用户说"机器人发消息/机器人群发" → `chat message send-by-bot`
+用户说"机器人撤回消息" → `chat message recall-by-bot`
+用户说"Webhook 发消息/告警消息" → `chat message send-by-webhook`
+用户说"话题回复/群话题消息回复/拉取话题回复" → `chat message list-topic-replies`
+用户说"所有消息/全部会话消息/拉取全部消息/时间范围内消息/我的消息/我今天的消息/查我的钉钉消息/最近的消息" → `chat message list-all`
+用户说"特别关注人的消息/关注的人的消息/星标联系人的消息" → `chat message list-focused`
+用户说"查看我的机器人" → `chat bot search`
+用户说"搜索消息/查找关键词/搜一下消息里的XX" → `chat message search`
+用户说"我和XX的共同群/我们都在哪些群/查共同群" → `chat search-common`
+用户说"置顶会话/置顶消息/我的置顶/查看置顶" → `chat list-top-conversations`
+用户说"获取会话信息/会话详情/会话元数据" → `chat conversation-info`
+用户说"撤回我发的消息/撤回消息" → `chat message recall-by-bot`(仅能撤回机器人消息;撤回用户自己发的消息开源 dws v1.0.30 暂不支持,需在钉钉客户端长按撤回)
+用户说"撤回机器人发的消息/机器人撤回消息" → `chat message recall-by-bot`(机器人接口撤回机器人发出的消息,需要 robot-code + processQueryKey)
+用户说"引用回复/回复消息/引用消息回复" → `chat message reply`
+用户说"转发消息/转发一条消息/把消息转发到另一个群" → `chat message forward`
+用户说"消息已读未读/谁看了消息/查读状态/消息读取状态" → `chat message query-read-status`
+用户说"查询消息发送状态/消息发没发成功/消息状态" → `chat message query-send-status`
+用户说"多维度搜索消息/按发送者搜消息/按人搜消息/指定多个群搜索/@我的消息搜索" → `chat message search-advanced`(推荐首选,支持多维度组合搜索)
+用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids`
+用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji`
+用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji`
+用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion`
+用户说"取消文字表情回应/移除文字表情" → `chat message remove-text-emotion`
+用户说"创建文字表情/新建文字表情" → `chat message create-text-emotion`
+用户说"流式卡片/AI 卡片/发送卡片" → `chat message send-card` → `chat message update-card`(必须配合 update-card,最后一次 --flow-status 必须为 3 finish)
+用户说"置顶会话/取消置顶/会话置顶(操作)" → `chat set-top`(设置/取消置顶),`chat list-top-conversations`(查看置顶列表)
+用户说"免打扰/消息免打扰/静音/开启免打扰/关闭免打扰" → `chat mute`
+用户说"全员禁言/群禁言/解除禁言" → `chat group-mute`
+用户说"禁言某人/指定成员禁言/解除某人禁言" → `chat group-mute-member`
+用户说"设管理员/取消管理员/设置群管理员" → `chat group set-admin`
+用户说"转让群主/换群主/群主转让" → `chat group transfer-owner`
+用户说"群邀请链接/入群链接/加群链接" → `chat group invite-url`
+用户说"退出群聊/我要退群" → `chat group quit`
+用户说"改群头像/更新群头像" → `chat group update-icon`
+用户说"群设置/修改群设置" → `chat group update-settings`
+用户说"根据群号查群信息/群号查群/群号转 openConversationId" → `chat group get-by-group-id`(用户提供数字群号时把它转为 openConversationId)
+用户说"查看群身份/群的自定义身份列表/群成员角色" → `chat group member-role list`
+用户说"创建/添加群身份/添加群角色" → `chat group member-role add`
+用户说"修改群身份名称/改群角色名" → `chat group member-role update`
+用户说"删除群身份/删群角色" → `chat group member-role remove`
+用户说"给某人设置群身份/设定用户角色" → `chat group member-role set-user`
+用户说"移除某人的群身份/撤销群角色" → `chat group member-role remove-user`
+用户说"查询某人的群身份/某人在群里有什么身份" → `chat group member-role query-user`
+用户说"查看会话分类/自定义分类" → `chat list-categories`
+用户说"某个分类下的会话/分类会话列表" → `chat list-conversations`
+
+关键区分:
+- `chat message list` — 拉取指定会话的消息(需指定 --group 或 --user),按时间点 + 方向翻页
+- `chat message list-direct --user` — list 的单聊模式,拉取与指定用户的单聊记录(用户明确说"单聊""私聊"时使用)
+- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
+- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
+- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
+- `chat message list-all` — 拉取当前用户所有会话的消息,按时间范围 + cursor 分页。只要用户没有指定某个具体的会话(如某个群名、某个人名),即使提到"单聊消息""群聊消息"等笼统范围,也应路由到此命令
+- `chat message list-topic-replies` — 拉取群话题的回复消息列表
+- `chat message list-focused` — 拉取特别关注人的消息,cursor 分页
+- `chat list-top-conversations` — 拉取置顶会话列表(用户询问"置顶会话"或"置顶消息"时路由到此),cursor 分页
+- `chat message send` — 以**当前用户**身份发消息(群聊或单聊),text 为位置参数;支持 --media-id 发送图片消息
+- `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群)
+- `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在)
+- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊),text 为 --text flag
+- `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息
+- `chat message recall-by-bot` — 通过机器人撤回已发送的消息
+- `chat conversation-info` — 按会话 ID 获取单聊/群聊的基础元数据(名称、类型、成员数等)
+- `chat bot search` — 搜索当前用户名下的机器人,拿到 robotCode 用于 send-by-bot / recall-by-bot / group members add-bot
+- `chat message recall-by-bot` — 撤回机器人发出的消息(需 --robot-code + --keys;群聊另加 --group)。撤回用户自己的消息开源 dws v1.0.30 不支持
+- `chat message reply` — 引用回复消息(单聊/群聊均可),以当前用户身份,需 --conversation-id + --ref-msg-id + --ref-sender + --text
+- `chat message forward` — 转发单条消息(源/目标会话均支持单聊/群聊),需 --src-conversation-id + --msg-id + --dest-conversation-id
+- `chat message list-by-ids` — 按消息 ID 列表批量查询(最多 50 条)
+- `chat message query-send-status` — 查询消息发送状态,需 --open-task-id(send 返回值)
+- `chat message query-read-status` — 查询消息的已读/未读状态(仅消息发送者可查询自己发的消息),需 --group + --msg-id
+- `chat message search-advanced` — 多维度搜索消息(推荐首选,是 search 的严格超集,可叠加 keyword/sender-ids/at-me/at-ids/conversation-ids/start/end)
+- `chat message add-emoji` / `remove-emoji` — 对消息添加/移除 emoji 表情回应(参考 chat-emoji-list.md 的 199 个默认表情)
+- `chat message add-text-emotion` / `remove-text-emotion` — 添加/移除文字表情回应
+- `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用
+- `chat message send-card` / `update-card` — 流式卡片,必须配套使用;最后一次 update-card 的 --flow-status 必须为 3 (finish)
+- `chat set-top` — 设置/取消会话置顶(单聊/群聊均可),默认置顶,--off 取消
+- `chat mute` — 开启/关闭会话消息免打扰(单聊/群聊均可),默认开启,--off 关闭
+- `chat group-mute` — 群全员禁言开关,默认开启,--off 关闭
+- `chat group-mute-member` — 指定群成员禁言,需 --users + --mute-time(毫秒,仅禁言时必填),--off 解除
+- `chat group set-admin` — 设置/取消群管理员,默认设为管理员,--off 取消
+- `chat group transfer-owner` — 转让群主
+- `chat group invite-url` — 获取群邀请链接,可选 --expires-seconds(0=永久)
+- `chat group quit` — 当前用户退出群聊
+- `chat group update-icon` / `update-settings` — 更新群头像 / 群设置
+- `chat group get-by-group-id` — 把数字群号转为 openConversationId
+- `chat group member-role` — 群自定义角色管理(list/add/update/remove/set-user/remove-user/query-user)
+- `chat list-categories` / `list-conversations` — 自定义会话分类列表 / 按分类拉会话
+
+## 核心工作流
+
+```bash
+# 1. 搜索群 — 提取 openconversation_id
+dws chat search --query "项目冲刺" --format json
+
+# 2. 拉取群消息
+dws chat message list --group --time "2025-03-01 00:00:00" --format json
+
+# 2b. 拉取未读会话列表
+dws chat message list-unread-conversations --count 20 --format json
+
+# 3. 以个人身份发送群消息
+dws chat message send --group --title "周报提醒" "请大家本周五前提交周报" --format json
+
+# 4. 以个人身份单聊(通过 userId)
+dws chat message send --user --title "问候" "你好" --format json
+
+# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用)
+dws chat message send --open-dingtalk-id --title "问候" "你好" --format json
+
+# 5. 机器人发群消息(Markdown)
+dws chat message send-by-bot --robot-code \
+ --group --title "日报" --text "## 今日完成..." --format json
+
+# 6. 机器人单聊发消息
+dws chat message send-by-bot --robot-code \
+ --users userId1,userId2 --title "提醒" --text "请提交周报" --format json
+
+# 7. Webhook 发告警
+dws chat message send-by-webhook --token \
+ --title "告警" --text "CPU 超 90%" --at-all --format json
+```
+
+## 复合工作流
+
+### 机器人发消息后撤回(完整流程)
+
+撤回只能用于 `send-by-bot` 发出的消息。个人身份 (`chat message send`) 发出的消息**无法通过 API 撤回**。
+
+```bash
+# Step 1: 查我的机器人 — 提取 robot-code
+dws chat bot search --format json
+
+# Step 2: 用机器人发消息 — 提取返回中的 processQueryKey
+dws chat message send-by-bot --robot-code --group \
+ --title "通知" --text "内容" --format json
+
+# Step 3: 用同一个 robot-code + processQueryKey 撤回
+dws chat message recall-by-bot --robot-code --group \
+ --keys --format json
+```
+
+### 将已有机器人加入群并发消息(完整流程)
+
+机器人需先在钉钉开放平台创建好,这里只做"找机器人 → 加入群 → 发消息"。
+
+```bash
+# Step 1: 搜索我的机器人 — 提取 robotCode
+dws chat bot search --name "项目提醒" --format json
+
+# Step 2: 搜索群 — 提取 openConversationId
+dws chat search --query "项目群" --format json
+
+# Step 3: 将机器人添加到群(需当前用户对该群有管理权限)
+dws chat group members add-bot --id --robot-code --format json
+
+# Step 4: 机器人发消息
+dws chat message send-by-bot --robot-code --group \
+ --title "提醒" --text "请及时更新项目状态" --format json
+```
+
+### 机器人 @指定人发群消息
+
+`--text` 中**必须**包含 `<@userId>` 占位符,否则 @ 不生效。
+
+```bash
+# Step 1: 搜人获取 userId
+dws aisearch person --keyword "张三" --dimension name --format json
+
+# Step 2: 取 userId 发送(注意 text 中的占位符)
+dws chat message send-by-bot --robot-code --group \
+ --title "提醒" --text "<@userId1> <@userId2> 请查收本周报告" --format json
+```
+
+### 发送图片/文件消息(跨产品: drive → chat)
+
+```bash
+# Step 1: 上传文件到钉盘 — 获取 uploadId 和凭证
+dws drive upload-info --file-name "截图.png" --file-size <字节数> --format json
+
+# Step 2: HTTP PUT 上传文件到 OSS
+curl -X PUT -T "截图.png" ""
+
+# Step 3: 提交上传 — 获取 dentryUuid
+dws drive commit --file-name "截图.png" --file-size <字节数> --upload-id --format json
+
+# Step 4: 获取下载链接
+dws drive download --file-id --format json
+
+# Step 5: 用 Markdown 图片语法发送
+dws chat message send --group \
+ --title "截图" --text "" --format json
+```
+
+## 上下文传递表
+
+| 操作 | 从返回中提取 | 用于 |
+|------|-------------|------|
+| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
+| `chat group create` | `openConversationId` | 同上 |
+| `chat group get-by-group-id` | `openConversationId` | 把数字群号转换后给 message send/list 等命令使用 |
+| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
+| `chat message search` / `search-advanced` | `nextCursor` | 下次 message search/search-advanced 的 --cursor |
+| `aisearch person` | `userId` | message send 的 --user、--at-users、send-by-bot 的 --users、list-by-sender 的 --sender-user-id |
+| `aisearch person` → `contact user get` | `openDingTalkId` | list-by-sender 的 --sender-open-dingtalk-id、message send/list 的 --open-dingtalk-id、search-advanced 的 --sender-ids/--at-ids、reply 的 --ref-sender、group transfer-owner 的 --new-owner、group set-admin / group-mute-member 的 --users、group member-role 系列的 --user |
+| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code、group members add-bot 的 --robot-code |
+| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
+| `chat message send` | `openTaskId` | query-send-status 的 --open-task-id |
+| `chat message list` | `openMessageId` | recall 的 --msg-id、reply 的 --ref-msg-id、forward 的 --msg-id、query-read-status 的 --msg-id、list-by-ids 的 --msg-ids、add-emoji/add-text-emotion 的 --msg-id |
+| `chat conversation-info` | `openConversationId` / `newCSpaceIdIM` | 作为 message send/list 等后续操作的会话上下文;newCSpaceIdIM 用于 drive upload-info 的 --space-id |
+| `chat search-common` | `openConversationId` | message send/list 等的 --group |
+| `chat list-categories` | `categoryId` | list-conversations 的 --category-id |
+| `chat group member-role list` | `openRoleId` | group member-role update/remove/set-user/remove-user 的 --role-id / --role-ids |
+| `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id |
+| `chat message send-card` | `bizId` | update-card 的 --biz-id |
+| `drive download` | 下载链接 | message send 的 Markdown 图片/链接语法 |
+
+## 注意事项
+
+- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
+- `chat message send` 的 text 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;`--at-all`、`--at-users` 仅在 `--group` 群聊时生效;发送图片消息用 `--media-id`
+- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
+- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
+- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识
+- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部
+- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
+- `chat message search` 按关键词搜索消息内容,`--keyword` 必填,可选 `--group` 限定搜索某个会话
+- `chat search-common` 搜索共同群,`--nicks` 传人员昵称(逗号分隔),`--match-mode` AND/OR 控制匹配逻辑
+- `chat list-top-conversations` 拉取置顶会话列表,分页用 `--limit`(默认 1000)/`--cursor`
+- `send-by-bot` 群聊传 `--group`,单聊传 `--users`,二者互斥且必选其一
+- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回)
+- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效
+
+- **发消息前参数审查(必须执行)**:发消息(`message send`、`send-by-bot`、`send-by-webhook`、`send-card`、`reply`、`forward`)是严肃操作,发错人/发错群会导致严重问题。执行前 agent 必须把全部参数(收件人/群、消息内容、@对象、消息类型等)与用户的原始需求逐一对比,确认每个参数都能从原始需求中找到明确依据;存在任何不明确/有歧义/原始需求中未提及的参数,必须先向用户确认,严禁自行假设
+- **消息换行符**:`message send` / `send-by-bot` / `send-by-webhook` 的 `--text` 按 Markdown 渲染,换行有两层要求,缺一不可:(1) 必须使用**真实换行符** `U+000A`,而非字面量字符串 `\n`;(2) Markdown 单换行不产生换行,需用空行(连续两个真实换行符)做段落分隔,或行尾两空格 + 换行 / `
` 做硬换行
+- `chat message recall` 撤回当前用户自己发的消息,--group + --msg-id 必填;与 `recall-by-bot`(撤回机器人消息,--robot-code + --keys)区分
+- `chat message reply` 引用回复(单聊/群聊均可),需 --conversation-id + --ref-msg-id + --ref-sender + --text;目前回复类型仅支持 text
+- `chat message forward` 转发消息(源/目标会话单聊群聊均可),需 --src-conversation-id + --msg-id + --dest-conversation-id
+- `chat message query-send-status` 查询消息发送状态,需 --open-task-id(send 返回值)
+- `chat message query-read-status` 查询消息已读/未读状态,仅消息发送者可查询自己发的消息,需 --group + --msg-id
+- `chat message search-advanced` 多维度搜索(推荐首选),所有参数可选但至少传一个;--sender-ids / --at-ids 传 openDingTalkId
+- `chat message list-by-ids` --msg-ids 逗号分隔,最多 50 条
+- `chat message add-emoji` / `remove-emoji` 需 --group + --msg-id + --emoji;--emoji 取自 chat-emoji-list.md 的 name 列
+- `chat message add-text-emotion` / `remove-text-emotion` 六个参数全部必填:--group + --msg-id + --emotion-id + --emotion-name + --text + --background-id
+- `chat message create-text-emotion` 创建文字表情模板,返回 emotionId;--background-id 可选
+- `chat message send-card` 创建并推送流式卡片,群聊传 --group,单聊传 --user(openDingTalkId);**必须配合 update-card 使用,最后一次 update-card 的 --flow-status 必须为 3(finish)**,否则卡片会一直显示"生成中"
+- `chat set-top` 设置/取消会话置顶(单聊/群聊均可),需 --group,默认置顶,--off 取消
+- `chat mute` 开启/关闭会话消息免打扰(单聊/群聊均可),需 --group,默认开启,--off 关闭
+- `chat group-mute` 群全员禁言开关,需 --group,默认开启,--off 关闭
+- `chat group-mute-member` 指定成员禁言,需 --group + --users(openDingTalkId 逗号分隔)+ --mute-time(毫秒,仅禁言时必填,常用 300000/3600000/86400000/604800000/2592000000),--off 解除
+- `chat group set-admin` 设置/取消群管理员,需 --group + --users(openDingTalkId 逗号分隔),默认设为管理员,--off 取消
+- `chat group transfer-owner` 转让群主,需 --group + --new-owner(openDingTalkId)
+- `chat group invite-url` 获取群邀请链接,需 --group,可选 --expires-seconds(0=永久有效)
+- `chat group quit` 退出群聊,需 --group
+- `chat group update-icon` 更新群头像,需 --group + --icon-media-id(mediaId)
+- `chat group update-settings` 更新群设置,需 --group + --setting-key + --status(字符串类型 "0"/"1")
+- `chat group get-by-group-id` 把数字群号转换为 openConversationId,需 --group-id(数字字符串)
+- `chat group member-role` 系列管理群自定义角色:list 查列表,add 创建,update 改名,remove 删除;set-user 覆盖某人全部角色(传空 --role-ids 则清除),remove-user 仅移除指定角色,query-user 查询某人当前角色;--user 传 openDingTalkId(非 userId)
+- `chat list-categories` 列出当前用户自定义会话分类(无参数)
+- `chat list-conversations` 按分类拉取会话列表,需 --category-id(字符串,由 list-categories 返回)
+- **如何获取 openConversationId**(上层已有则直接用,不必重复查):群聊 `dws chat search --query "群名"`;单聊 `dws chat conversation-info --open-dingtalk-id `
+- **如何获取 openDingTalkId**:知道姓名 → `dws contact user search --query "姓名"`;只有 userId → 先 `dws contact user get --ids ` 拿姓名,再 search
+
+## 自动化脚本
+
+| 脚本 | 场景 | 用法 |
+|------|------|------|
+| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
+| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
+
+## 相关产品
+
+- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、--at-users、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 list-by-sender --sender-open-dingtalk-id、--open-dingtalk-id
+- [drive](./drive.md) — 上传文件获取下载链接,用于 Markdown 图片/文件消息
diff --git a/skills/references/products/contact.md b/skills/mono/references/products/contact.md
similarity index 100%
rename from skills/references/products/contact.md
rename to skills/mono/references/products/contact.md
diff --git a/skills/references/products/devdoc.md b/skills/mono/references/products/devdoc.md
similarity index 100%
rename from skills/references/products/devdoc.md
rename to skills/mono/references/products/devdoc.md
diff --git a/skills/references/products/ding.md b/skills/mono/references/products/ding.md
similarity index 100%
rename from skills/references/products/ding.md
rename to skills/mono/references/products/ding.md
diff --git a/skills/references/products/doc.md b/skills/mono/references/products/doc.md
similarity index 100%
rename from skills/references/products/doc.md
rename to skills/mono/references/products/doc.md
diff --git a/skills/references/products/drive.md b/skills/mono/references/products/drive.md
similarity index 69%
rename from skills/references/products/drive.md
rename to skills/mono/references/products/drive.md
index 2d1a156f..a655d360 100644
--- a/skills/references/products/drive.md
+++ b/skills/mono/references/products/drive.md
@@ -4,6 +4,22 @@
## 命令总览
+### 列出钉盘空间
+```
+Usage:
+ dws drive list-spaces [flags]
+Example:
+ dws drive list-spaces --format json
+ dws drive list-spaces --space-type org --max 50 --format json
+Flags:
+ --cursor string 分页游标 (从上次结果的 nextToken 获取)
+ --max string 每页数量
+ --space-type string 空间类型过滤
+```
+
+> 适用场景:复制/移动文件到「我的文件」或团队空间根目录时,先取 `rootFolderId`;或者枚举用户可访问的团队空间。
+> 注意:由企业服务发现 envelope 注册;个别企业 MCP gateway 可能未开通,调用前可用 `dws drive list-spaces --help` / `--dry-run` 验证。
+
### 列出钉盘目录
```
Usage:
@@ -87,15 +103,56 @@ Flags:
--conflict-handler string 同名冲突策略: AUTO_RENAME / OVERWRITE / RETURN_DENTRY_IF_EXIST (默认 AUTO_RENAME)
```
+### 一键上传 (三步合成)
+```
+Usage:
+ dws drive upload [flags]
+Example:
+ dws drive upload --file ./report.pdf
+ dws drive upload --file ./slides.pptx --file-name "Q1汇报.pptx"
+ dws drive upload --file ./data.xlsx --folder
+Flags:
+ --file string 本地文件路径 (必填)
+ --file-name string 文件显示名称 (默认使用本地文件名)
+ --folder string 父节点 ID (dentryUuid),不传则上传到空间根目录
+ --space-id string 目标空间 ID,不传则使用「我的文件」
+ --mime-type string 文件 MIME 类型,不传则自动推断
+```
+
+> 客户端胶水命令:内部自动完成 `upload-info` → HTTP PUT 到 OSS → `commit_upload` 三步。
+> 适合大多数本地文件上传场景,无需 Agent 自己发 HTTP PUT。
+> 注意:`--folder` 接受 `dentryUuid`(UUID 格式),不要传纯数字 `dentryId`。
+
+### 删除文件/文件夹到回收站
+
+> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
+
+```
+Usage:
+ dws drive delete [flags]
+Example:
+ dws drive delete --node --yes --format json
+Flags:
+ --node string 文件或文件夹 nodeId(dentryUuid 格式,必填)
+
+Global Flags:
+ --yes 跳过二次确认 (危险操作,建议先与用户确认)
+```
+
+> 由企业服务发现 envelope 注册(路由到 doc 服务的 `delete_document` tool);不同企业 MCP gateway 可能不暴露,调用前可用 `dws drive delete --help` 验证。
+> 删除是软删除(进回收站),但仍需用户明确确认;不要在自动化脚本里默认带 `--yes`。
+
## 意图判断
+用户说"钉盘有什么空间/团队文件/列空间" → `list-spaces`
用户说"钉盘有什么文件/列钉盘/看钉盘目录" → `list`
用户说"钉盘文件详情/文件信息" → `info` (需 fileId)
用户说"新建钉盘目录/钉盘里建文件夹" → `mkdir`
用户说"下载钉盘文件/把这个文件拿下来" → `download` 拿临时 URL,再由 Agent 自行发起 HTTP GET
用户说"上传文件到钉盘/把本地文件传钉盘":
-- 三步走: `upload-info` → HTTP PUT 到预签名 URL → `commit`
-- **没有**一条聚合命令可以完成;必须完整走完三步
+- **首选 `upload`**:一条命令自动完成三步(适合本地文件直接上传)
+- 也可手动三步:`upload-info` → HTTP PUT 到预签名 URL → `commit`(适合自定义流式上传,或不想在客户端做 HTTP PUT 的场景)
+用户说"删除钉盘文件/移到回收站" → `delete`(不可逆,必须确认)
关键区分:
- drive(钉盘云存储,面向文件二进制) vs doc(在线文档/知识库,面向富文本内容)
@@ -162,8 +219,8 @@ dws drive mkdir --name "2026 Q1 归档" --format json
## 注意事项
-- 上传是**三步**流程:`upload-info` → 客户端 HTTP PUT 到预签名 URL → `commit`。**没有**自动聚合命令,跳过任何一步都会失败
-- Step 2 的 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;只有 PUT 返回 200 才能调 `commit`
+- 上传两条路径:**首选** `upload` 一条命令搞定(内部走三步);如果需要自己控制 HTTP PUT,可走 `upload-info` → 客户端 HTTP PUT → `commit` 三步
+- 用三步流程时,Step 2 的 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;只有 PUT 返回 200 才能调 `commit`
- 上传凭证 (`uploadId`) 有过期时间,拿到后尽快 commit;过期需重新调 `upload-info`
- `download` 只返回临时 URL(几分钟级别有效期),不会把文件落地;要真正下载到本地必须再发 HTTP GET
- `--parent-id` 在 upload-info / commit 中要保持一致,否则 commit 会报位置不匹配
diff --git a/skills/references/products/mail.md b/skills/mono/references/products/mail.md
similarity index 97%
rename from skills/references/products/mail.md
rename to skills/mono/references/products/mail.md
index 97f62b1d..b6f69dbe 100644
--- a/skills/references/products/mail.md
+++ b/skills/mono/references/products/mail.md
@@ -14,7 +14,7 @@ Example:
| 字段 | 类型 | 说明 |
|------|------|------|
-| `mailboxes` | `List[]` | 邮箱列表,每条包含邮箱地址、账号类型、所属企业 |
+| `emailAccounts` | `List[]` | 邮箱列表,每条含 `email` / `type` / `orgName`;type 取值 `PERSONAL`(个人邮箱)/ `ORGANIZATION`(企业邮箱) |
### 搜索邮件 (KQL 语法)
```
diff --git a/skills/references/products/minutes.md b/skills/mono/references/products/minutes.md
similarity index 100%
rename from skills/references/products/minutes.md
rename to skills/mono/references/products/minutes.md
diff --git a/skills/references/products/oa.md b/skills/mono/references/products/oa.md
similarity index 89%
rename from skills/references/products/oa.md
rename to skills/mono/references/products/oa.md
index ae544b11..df0e68ed 100644
--- a/skills/references/products/oa.md
+++ b/skills/mono/references/products/oa.md
@@ -26,15 +26,15 @@ Flags:
Usage:
dws oa approval list-initiated [flags]
Example:
- dws oa approval list-initiated
- dws oa approval list-initiated --process-code --max-results 20
- dws oa approval list-initiated --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --next-token
+ dws oa approval list-initiated --process-code --start "2026-03-01T00:00:00+08:00"
+ dws oa approval list-initiated --process-code --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --max-results 20
+ dws oa approval list-initiated --process-code --start "2026-03-01T00:00:00+08:00" --next-token
Flags:
- --process-code string 按指定表单/流程模板过滤 (可选)
- --start string 发起开始时间 ISO-8601 (可选)
- --end string 发起结束时间 ISO-8601 (可选)
- --max-results string 每页条数 (可选)
- --next-token string 分页 token (首页留空)
+ --process-code string 审批表单 processCode (必填) — 来自 dws oa approval list-forms
+ --start string 发起开始时间 ISO-8601 (必填)
+ --end string 发起结束时间 ISO-8601 (可选, 缺省查到当前)
+ --max-results int 每页大小 (最大 20, 默认 20)
+ --next-token int 分页游标 (首次传 0)
```
查询当前用户作为申请人发起的审批,用来查"我报销到哪一步了"、"我之前提的请假单"。
@@ -164,9 +164,9 @@ dws oa approval reject --instance-id --task-id --r
# 5. 查看流程记录 — 谁何时做了什么
dws oa approval records --instance-id --format json
-# 6. 查我发起的审批 / 按模板过滤
-dws oa approval list-initiated --format json
-dws oa approval list-initiated --process-code --format json
+# 6. 查我发起的审批(--process-code 和 --start 都是必填,processCode 先 list-forms 拿到)
+dws oa approval list-initiated --process-code --start "2026-03-01T00:00:00+08:00" --format json
+dws oa approval list-initiated --process-code --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --format json
# 7. 能发起哪些表单
dws oa approval list-forms --cursor 0 --size 20 --format json
diff --git a/skills/references/products/report.md b/skills/mono/references/products/report.md
similarity index 97%
rename from skills/references/products/report.md
rename to skills/mono/references/products/report.md
index 2a856db9..749dedd7 100644
--- a/skills/references/products/report.md
+++ b/skills/mono/references/products/report.md
@@ -68,9 +68,9 @@ Usage:
Example:
dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20
Flags:
- --cursor string 分页游标 (必填)
+ --cursor int 分页游标,首次传 0 (可选,默认 0)
--end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)
- --size string 每页大小 (必填)
+ --size int 每页条数,最大 20 (可选,默认 20)
--start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)
```
diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md
new file mode 100644
index 00000000..40c05489
--- /dev/null
+++ b/skills/mono/references/products/sheet.md
@@ -0,0 +1,235 @@
+# 在线电子表格 (sheet) 命令参考
+
+> ⚠️ **本文档已对齐开源 dws v1.0.30 实际暴露的 cobra 子命令**。文档中描述的所有命令在该版本下都能 dispatch 成功(PASS),仅业务返回值取决于真实数据 / 权限。
+
+## 适用范围(重要)
+
+`sheet` 产品**仅支持钉钉在线电子表格**(`contentType=ALIDOC`、`extension=axls`),**不支持**上传的 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件。
+
+| 文件类型 | 处理方式 |
+|---------|---------|
+| 在线电子表格(`axls`) | 走 `sheet` 全部命令(读/写/筛选/合并等服务端原子操作) |
+| `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件 | 必须用 `dws doc download --node --output <路径>` 先下载到本地再用本地工具解析,**禁止**调用任何 `sheet` 子命令 |
+
+> 用户直接粘贴 `alidocs` URL 时,先用 `dws doc info --node --format json` 确认 `contentType=ALIDOC` 且 `extension=axls` 后再走 `sheet`;否则转 `dws doc download`。
+
+## 命令总览(按功能分组)
+
+### 工作表 (Worksheet) 级
+
+| 命令 | 用途 |
+|------|------|
+| `dws sheet create` | 在知识库中创建一个新的钉钉表格文档 |
+| `dws sheet new` | 在已有钉钉表格文档中新建一张工作表 |
+| `dws sheet list` | 列出指定文档的所有工作表 |
+| `dws sheet info` | 获取指定工作表详情 |
+
+### 区域 (Range) 读写
+
+| 命令 | 用途 |
+|------|------|
+| `dws sheet range read` | 读取指定区域的单元格内容 |
+| `dws sheet range update` | 写入/更新指定区域的单元格 |
+| `dws sheet append` | 在工作表末尾追加若干行 |
+
+### 行列 (Dimension)
+
+| 命令 | 用途 |
+|------|------|
+| `dws sheet add-dimension` | 在末尾追加空行或空列 |
+| `dws sheet insert-dimension` | 在指定位置插入空行/空列 |
+| `dws sheet delete-dimension` | 删除指定位置起的若干行/列 |
+| `dws sheet move-dimension` | 移动行/列到指定位置 |
+| `dws sheet update-dimension` | 更新行/列属性(显隐、行高/列宽) |
+
+### 单元格合并
+
+| 命令 | 用途 |
+|------|------|
+| `dws sheet merge-cells` | 合并指定范围的单元格(`mergeAll`/`mergeRows`/`mergeColumns`) |
+| `dws sheet unmerge-cells` | 取消指定范围的合并 |
+
+### 查找/替换
+
+| 命令 | 用途 |
+|------|------|
+| `dws sheet find` | 在工作表中搜索单元格内容(支持正则/整格匹配/隐藏) |
+| `dws sheet replace` | 全局查找替换 |
+
+### 筛选视图 (Filter View) — 命名视图、按列条件、不影响表本身
+
+| 命令 | 用途 |
+|------|------|
+| `dws sheet filter-view create` | 创建筛选视图 |
+| `dws sheet filter-view list` | 列出工作表的所有筛选视图 |
+| `dws sheet filter-view update` | 更新筛选视图(名称/范围/条件) |
+| `dws sheet filter-view delete` | 删除整个筛选视图 |
+| `dws sheet filter-view update-criteria` | 设置/更新视图内某列的筛选条件 |
+| `dws sheet filter-view delete-criteria` | 清除视图内某列的筛选条件 |
+
+### 图片
+
+| 命令 | 用途 |
+|------|------|
+| `dws sheet write-image` | 将已上传的图片资源写入指定单元格(需要 `--resource-id` 或 `--resource-url`) |
+
+## 通用必填参数
+
+绝大多数 `sheet` 命令都需要:
+
+- `--node ` —— 钉钉表格文档的 nodeId 或 `https://alidocs.dingtalk.com/i/nodes/` URL
+- `--sheet-id ` —— 工作表 ID,从 `dws sheet list` 拿
+
+例外:
+- `create` 只需要 `--name`(在知识库创建文档时不需要 nodeId)
+- `list` / `info` / `range read` 只需要 `--node`
+
+## 常用命令示例
+
+### 创建文档 + 新建工作表
+
+```bash
+# 在知识库下创建一个钉钉表格文档
+dws sheet create --name "销售数据" --workspace --format json
+# 返回的 nodeId 用于后续操作
+
+# 在已有文档中新建一张工作表
+dws sheet new --node --name "Q1 数据" --format json
+```
+
+### 读写区域
+
+```bash
+# 读 A1:D10
+dws sheet range read --node --sheet-id --range "A1:D10" --format json
+
+# 写入 5x4 区域(values 是二维 JSON 数组)
+dws sheet range update --node --sheet-id --range "A1:D5" \
+ --values '[["姓名","岗位","入职","薪资"],["张三","研发","2024-01","30000"]]' \
+ --format json
+
+# 追加行
+dws sheet append --node --sheet-id \
+ --values '[["李四","产品","2025-03","28000"]]' \
+ --format json
+```
+
+### 行列操作
+
+```bash
+# 在第 5 行处插入 2 个空行
+dws sheet insert-dimension --node --sheet-id \
+ --dimension rows --position 4 --length 2 --format json
+
+# 末尾追加 3 列
+dws sheet add-dimension --node --sheet-id \
+ --dimension columns --length 3 --format json
+
+# 删除第 10-12 行
+dws sheet delete-dimension --node --sheet-id \
+ --dimension rows --position 9 --length 3 --format json
+
+# 隐藏 B 列(startIndex=1, length=1)
+dws sheet update-dimension --node --sheet-id \
+ --dimension columns --start-index 1 --length 1 --hidden true --format json
+```
+
+### 合并/取消合并
+
+```bash
+# 合并 A1:C1
+dws sheet merge-cells --node --sheet-id \
+ --range "A1:C1" --merge-type mergeAll --format json
+
+# 取消合并
+dws sheet unmerge-cells --node --sheet-id \
+ --range "A1:C1" --format json
+```
+
+### 查找/替换
+
+```bash
+# 查找
+dws sheet find --node --sheet-id \
+ --find "TODO" --match-case false --format json
+
+# 全局替换
+dws sheet replace --node --sheet-id \
+ --find "TODO" --replacement "DONE" --format json
+```
+
+### 筛选视图
+
+```bash
+# 创建筛选视图(范围必须包含表头行)
+dws sheet filter-view create --node --sheet-id \
+ --name "未完成项" --range "A1:E100" --format json
+# 返回的 filterViewId 用于后续 update/delete/criteria 操作
+
+# 列出所有筛选视图
+dws sheet filter-view list --node --sheet-id --format json
+
+# 给视图的某一列设置筛选条件(column 是相对视图范围首列的 0-based 偏移)
+dws sheet filter-view update-criteria --node --sheet-id \
+ --filter-view-id --column 2 \
+ --filter-criteria '{"conditions":[{"type":"TEXT_CONTAINS","values":["pending"]}]}' \
+ --format json
+
+# 清除某列的筛选条件(不删除视图本身)
+dws sheet filter-view delete-criteria --node --sheet-id \
+ --filter-view-id --column 2 --format json
+
+# 删除整个筛选视图
+dws sheet filter-view delete --node --sheet-id \
+ --filter-view-id --format json
+```
+
+### 写入图片
+
+```bash
+# 已有图片资源 ID 和 URL 后写入单元格
+dws sheet write-image --node --sheet-id \
+ --range "B2:B2" --resource-id --resource-url \
+ --width 200 --height 100 --format json
+```
+
+## 易混淆点
+
+| 区分 | 说明 |
+|---|---|
+| `dws sheet create` vs `dws sheet new` | `create` 在知识库**新建一个文档**(返回新 nodeId);`new` 在**已有文档中新建一张工作表**(需 nodeId) |
+| `filter-view update-criteria` vs `filter-view delete-criteria` | update 是设置/覆盖列条件;delete 是清除列条件(视图本身保留);要删整个视图用 `filter-view delete` |
+| `dws sheet write-image` vs `range update` | write-image 写入图片(需 resourceId + resourceUrl);range update 写入文本/数字/公式 |
+| `range update` vs `append` | range update 指定区域覆盖;append 在末尾追加行 |
+| online axls vs 本地 xlsx | sheet 全部命令只认 axls;本地 xlsx 必须先 `doc download` 再用本地工具解析 |
+
+## 危险操作(必须先向用户确认)
+
+| 命令 | 风险 |
+|---|---|
+| `delete-dimension` | 删除行/列(含数据),不可恢复 |
+| `filter-view delete` | 删除整个筛选视图 |
+| `replace` | 全局替换可能影响大量单元格 |
+| `unmerge-cells` | 取消合并可能丢失部分单元格内容 |
+
+执行前先 `--dry-run` 预览,并向用户展示操作摘要 + 拿到明确同意,再加 `--yes` 提交。
+
+## 何时**不要**用 sheet
+
+- 用户给的是 `xlsx` / `xls` / `xlsm` / `csv` 本地文件 → 用 `dws doc download` 下载后本地解析
+- 用户给的是 AI 表格(不是在线电子表格)→ 用 `dws aitable record query` 等
+- 用户给的是富文本/普通文档 → 用 `dws doc read`
+
+## 不支持的能力(开源 dws v1.0.30)
+
+以下命令在内部 wukong 版本可能可用,但**开源 v1.0.30 未暴露**,请勿尝试调用:
+
+- 复制工作表 / 更新工作表元信息(`copy_sheet` / `update_sheet`)
+- 表级筛选器(`create_filter / get_filter / update_filter / delete_filter / set_filter_criteria / clear_filter_criteria / sort_filter`)—— 改用筛选视图(`filter-view *`)实现等价功能
+- 导出 xlsx(`submit_export_job / query_export_job`)—— 改在钉钉客户端导出
+- 浮动图片(float-image 系列)/ 下拉框(dropdown 系列)/ 单元格样式(range set-style 系列)
+
+## 权威参考
+
+- 看某个命令的完整 help:`dws sheet --help`
+- 看 schema:`dws schema sheet.`
diff --git a/skills/references/products/simple.md b/skills/mono/references/products/simple.md
similarity index 92%
rename from skills/references/products/simple.md
rename to skills/mono/references/products/simple.md
index b58ddaef..cdaa9e27 100644
--- a/skills/references/products/simple.md
+++ b/skills/mono/references/products/simple.md
@@ -11,11 +11,11 @@
Usage:
dws devdoc article search [flags]
Example:
- dws devdoc article search --keyword "OAuth2 接入" --page 1 --size 10
+ dws devdoc article search --query "OAuth2 接入" --page 1 --size 10
Flags:
- --keyword string 搜索关键词 (必填)
- --page string 页码 (默认 1)
- --size string 每页数量 (默认 10)
+ --query string 搜索关键词 (必填)
+ --page string 页码 (默认 1)
+ --size string 每页数量 (默认 10)
```
---
diff --git a/skills/references/products/todo.md b/skills/mono/references/products/todo.md
similarity index 100%
rename from skills/references/products/todo.md
rename to skills/mono/references/products/todo.md
diff --git a/skills/references/products/wiki.md b/skills/mono/references/products/wiki.md
similarity index 92%
rename from skills/references/products/wiki.md
rename to skills/mono/references/products/wiki.md
index 64427c75..d4e6ea91 100644
--- a/skills/references/products/wiki.md
+++ b/skills/mono/references/products/wiki.md
@@ -53,7 +53,7 @@ Usage:
Example:
dws wiki space search --keyword "产品文档" --format json
dws wiki space search --keyword "技术方案" --limit 20 --format json
- dws wiki space search --type myWikiSpace --format json
+ dws wiki space list --type myWikiSpace --format json
Flags:
--keyword string 搜索关键词 (--type myWikiSpace 时可省略)
--type string 知识库类型: myWikiSpace 时直接返回「我的文档」,省略则搜索组织知识库
@@ -80,7 +80,7 @@ Flags:
> - 仅支持 USER 类型。
> - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。
> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。
-> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,请改用 `dws doc permission add` 在节点级别授权。
+> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,节点级权限授权在开源 dws v1.0.30 暂不支持,请在钉钉客户端文档中通过「分享」设置。
### 修改知识库成员角色
```
@@ -117,7 +117,7 @@ Flags:
- 用户说"查看知识库/知识库详情" → `space get`
- 用户说"我的知识库/知识库列表/有哪些知识库" → `space list`
- 用户说"搜索知识库/找知识库" → `space search`
-- 用户说"我的文档/个人空间" → `space search --type myWikiSpace` 或 `space list --type myWikiSpace`
+- 用户说"我的文档/个人空间" → `space list --type myWikiSpace`
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--space` + `--user` + `--role`)
- 用户说"修改某人在知识库的权限/调整成员角色" → `member update`
- 用户说"知识库有哪些成员/查看知识库成员" → `member list`
@@ -125,8 +125,8 @@ Flags:
关键区分:
- wiki(知识库空间级管理:创建/查询/列出/搜索/成员管理) vs doc(文档内容级操作:搜索/读写/编辑/节点级权限)
- wiki space(知识库容器) vs drive(钉盘文件存储/上传/下载)
-- **wiki member**(容器级,授权整个知识库)vs **doc permission**(节点级,授权单篇文档)
- - 「我的文档」**只能用** `doc permission`,不能用 `wiki member`
+- **wiki member**(容器级,授权整个知识库);节点级 `doc permission` 在开源 dws v1.0.30 不可用
+ - 「我的文档」当前开源版本不能通过 CLI 改权限;在钉钉客户端用「分享」设置
## 核心工作流
@@ -141,7 +141,7 @@ dws wiki space list --type myWikiSpace --format json
dws wiki space search --keyword "产品" --format json
# 搜索「我的文档」
-dws wiki space search --type myWikiSpace --format json
+dws wiki space list --type myWikiSpace --format json
# 创建知识库
dws wiki space create --name "新项目文档" --description "项目相关文档归档" --format json
diff --git a/skills/references/recovery-guide.md b/skills/mono/references/recovery-guide.md
similarity index 100%
rename from skills/references/recovery-guide.md
rename to skills/mono/references/recovery-guide.md
diff --git a/skills/references/url-patterns.md b/skills/mono/references/url-patterns.md
similarity index 97%
rename from skills/references/url-patterns.md
rename to skills/mono/references/url-patterns.md
index 29a58b2c..fadd529f 100644
--- a/skills/references/url-patterns.md
+++ b/skills/mono/references/url-patterns.md
@@ -98,7 +98,7 @@ Step 3 → 按下方路由规则映射到对应产品
> axls vs xlsx 关键区分:
> - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作)
> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错)
-> - 用户想把在线表格导出为 xlsx 文件 → 用 `dws sheet submit_export_job` 提交导出任务(输入是 `axls`,输出是 xlsx,这是 axls → xlsx 的格式转换,不属于 xlsx 读取场景)
+> - 用户想把在线表格导出为 xlsx 文件 → 开源 dws CLI 暂未暴露在线表格导出能力(envelope schema 里有 `submit_export_job` / `query_export_job`,但当前 cobra 未注册),需要在钉钉客户端手动导出 xlsx
### 示例
diff --git a/skills/scripts/attendance_my_record.py b/skills/mono/scripts/attendance_my_record.py
similarity index 100%
rename from skills/scripts/attendance_my_record.py
rename to skills/mono/scripts/attendance_my_record.py
diff --git a/skills/scripts/attendance_team_shift.py b/skills/mono/scripts/attendance_team_shift.py
similarity index 100%
rename from skills/scripts/attendance_team_shift.py
rename to skills/mono/scripts/attendance_team_shift.py
diff --git a/skills/scripts/bulk_add_fields.py b/skills/mono/scripts/bulk_add_fields.py
similarity index 100%
rename from skills/scripts/bulk_add_fields.py
rename to skills/mono/scripts/bulk_add_fields.py
diff --git a/skills/scripts/calendar_free_slot_finder.py b/skills/mono/scripts/calendar_free_slot_finder.py
similarity index 100%
rename from skills/scripts/calendar_free_slot_finder.py
rename to skills/mono/scripts/calendar_free_slot_finder.py
diff --git a/skills/scripts/calendar_schedule_meeting.py b/skills/mono/scripts/calendar_schedule_meeting.py
similarity index 100%
rename from skills/scripts/calendar_schedule_meeting.py
rename to skills/mono/scripts/calendar_schedule_meeting.py
diff --git a/skills/scripts/calendar_today_agenda.py b/skills/mono/scripts/calendar_today_agenda.py
similarity index 100%
rename from skills/scripts/calendar_today_agenda.py
rename to skills/mono/scripts/calendar_today_agenda.py
diff --git a/skills/mono/scripts/chat_export_messages.py b/skills/mono/scripts/chat_export_messages.py
new file mode 100644
index 00000000..54324607
--- /dev/null
+++ b/skills/mono/scripts/chat_export_messages.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""
+导出群聊消息到 JSON 文件(从指定时间点拉取)
+
+用法:
+ python chat_export_messages.py \
+ --group \
+ --time "2026-03-10 00:00:00" \
+ --output messages.json
+
+ python chat_export_messages.py \
+ --query "项目冲刺" \
+ --time "2026-03-10 00:00:00" \
+ --no-forward --limit 100
+"""
+
+import sys
+import json
+import subprocess
+import argparse
+from typing import List, Any, Optional
+
+
+def run_dws(
+ args: List[str], dry_run: bool = False,
+) -> Optional[Any]:
+ cmd = ['dws'] + args
+ if dry_run:
+ print(f"[dry-run] {' '.join(cmd)}")
+ return None
+ try:
+ result = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=120
+ )
+ if result.returncode != 0:
+ print(f"错误:{result.stderr.strip()}", file=sys.stderr)
+ return None
+ return json.loads(result.stdout)
+ except (subprocess.TimeoutExpired, json.JSONDecodeError,
+ FileNotFoundError) as e:
+ print(f"错误:{e}", file=sys.stderr)
+ return None
+
+
+def search_group(
+ query: str, dry_run: bool = False,
+) -> Optional[str]:
+ data = run_dws([
+ 'chat', 'search',
+ '--query', query, '--format', 'json',
+ ], dry_run=dry_run)
+ if dry_run:
+ return ''
+ if not data:
+ return None
+ if isinstance(data, list):
+ groups = data
+ elif isinstance(data, dict):
+ inner = data.get('result', data)
+ if isinstance(inner, dict):
+ groups = inner.get('items', inner.get('groups', []))
+ elif isinstance(inner, list):
+ groups = inner
+ else:
+ groups = []
+ else:
+ groups = []
+ if not groups:
+ print(f"未找到群聊: {query}")
+ return None
+ g = groups[0]
+ name = g.get('title') or g.get('name', '未知')
+ conv_id = g.get('openConversationId') or g.get('id')
+ print(f" 找到群聊: {name} ({conv_id})")
+ return conv_id
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='导出群聊消息到 JSON'
+ )
+ parser.add_argument('--group', help='群聊 openconversation_id')
+ parser.add_argument('--query', help='按群名搜索')
+ parser.add_argument(
+ '--time', required=True,
+ help='起始时间 yyyy-MM-dd HH:mm:ss',
+ )
+ parser.add_argument(
+ '--no-forward', action='store_true',
+ help='拉给定时间之前的消息 (默认拉给定时间之后)',
+ )
+ parser.add_argument(
+ '--limit', type=int, default=0,
+ help='返回条数 (不传则不限制)',
+ )
+ parser.add_argument('--output', default='', help='输出文件')
+ parser.add_argument('--dry-run', action='store_true')
+ args = parser.parse_args()
+
+ conv_id = args.group
+ if not conv_id:
+ if not args.query:
+ print('错误:需要 --group 或 --query 参数')
+ sys.exit(1)
+ print(f'🔍 搜索群聊: {args.query}')
+ conv_id = search_group(args.query, args.dry_run)
+ if not conv_id and not args.dry_run:
+ sys.exit(1)
+
+ print(f'📥 拉取消息 (起始: {args.time})...')
+ all_messages: List[Any] = []
+ current_time = args.time
+ page = 0
+ max_pages = 50
+ remaining = args.limit if args.limit > 0 else float('inf')
+
+ while page < max_pages and remaining > 0:
+ cmd_args = [
+ 'chat', 'message', 'list',
+ '--group', conv_id or '',
+ '--time', current_time,
+ '--format', 'json',
+ ]
+ if args.no_forward:
+ cmd_args.append('--forward=false')
+ page_limit = min(int(remaining), 200) if args.limit > 0 else 0
+ if page_limit > 0:
+ cmd_args.extend(['--limit', str(page_limit)])
+ data = run_dws(cmd_args, dry_run=args.dry_run)
+
+ if args.dry_run:
+ print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
+ return
+
+ if not data:
+ break
+
+ if isinstance(data, list):
+ page_msgs = data
+ has_more = False
+ else:
+ page_msgs = data.get('messages', data.get('result', []))
+ has_more = data.get('hasMore', False)
+
+ if not page_msgs:
+ break
+
+ all_messages.extend(page_msgs)
+ remaining -= len(page_msgs)
+ page += 1
+
+ if not has_more:
+ break
+
+ last_msg = page_msgs[-1]
+ boundary_time = last_msg.get('createAt') or last_msg.get('time', '')
+ if not boundary_time or boundary_time == current_time:
+ break
+ current_time = boundary_time
+ print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
+
+ if not all_messages:
+ print('未拉取到消息')
+ return
+
+ if isinstance(data, list):
+ messages = data
+ elif isinstance(data, dict):
+ inner = data.get('result', data)
+ if isinstance(inner, dict):
+ messages = inner.get('messages', inner.get('records', []))
+ elif isinstance(inner, list):
+ messages = inner
+ else:
+ messages = []
+ else:
+ messages = []
+
+ if args.output:
+ with open(args.output, 'w', encoding='utf-8') as f:
+ json.dump(all_messages, f, ensure_ascii=False, indent=2)
+ print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
+ else:
+ for m in all_messages:
+ sender = m.get('senderNick') or m.get('sender', '未知')
+ text = m.get('text') or m.get('content', '')
+ time_str = m.get('createAt') or m.get('time', '')
+ print(f" [{time_str}] {sender}: {text[:80]}")
+ print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/skills/mono/scripts/chat_history_with_user.py b/skills/mono/scripts/chat_history_with_user.py
new file mode 100644
index 00000000..8068be38
--- /dev/null
+++ b/skills/mono/scripts/chat_history_with_user.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""
+查询与某人的单聊聊天记录
+
+用法:
+ python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"
+ python chat_history_with_user.py --user --time "2026-03-10 00:00:00" --limit 50
+ python chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json
+
+工作流:
+ 1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
+ 2. 调用 chat message list --user 拉取单聊消息
+ 3. 输出到终端或导出为 JSON 文件
+"""
+
+import sys
+import json
+import subprocess
+import argparse
+from typing import List, Any, Optional
+
+
+def run_dws(
+ args: List[str], dry_run: bool = False,
+) -> Optional[Any]:
+ """执行 dws 命令并解析 JSON 输出"""
+ cmd = ['dws'] + args
+ if dry_run:
+ print(f"[dry-run] {' '.join(cmd)}")
+ return None
+ try:
+ result = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=120
+ )
+ if result.returncode != 0:
+ print(f"错误:{result.stderr.strip()}", file=sys.stderr)
+ return None
+ return json.loads(result.stdout)
+ except (subprocess.TimeoutExpired, json.JSONDecodeError,
+ FileNotFoundError) as e:
+ print(f"错误:{e}", file=sys.stderr)
+ return None
+
+
+def search_user(
+ name: str, dry_run: bool = False,
+) -> Optional[str]:
+ """按关键词搜索用户,返回第一个匹配的 userId"""
+ data = run_dws([
+ 'contact', 'user', 'search',
+ '--query', name, '--format', 'json',
+ ], dry_run=dry_run)
+ if dry_run:
+ return ''
+ if not data:
+ return None
+ # 解析返回结构
+ users = data
+ if isinstance(data, dict):
+ inner = data.get('result', data)
+ if isinstance(inner, dict):
+ users = (inner.get('users', [])
+ or inner.get('list', []))
+ elif isinstance(inner, list):
+ users = inner
+ else:
+ users = []
+ if not users or not isinstance(users, list):
+ print(f"未找到用户: {name}")
+ return None
+ u = users[0]
+ user_name = u.get('name') or u.get('nick', '未知')
+ user_id = u.get('userId') or u.get('userid', '')
+ print(f" 找到用户: {user_name} ({user_id})")
+ return user_id
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='查询与某人的单聊聊天记录'
+ )
+ group = parser.add_mutually_exclusive_group(required=True)
+ group.add_argument('--name', help='按姓名搜索用户')
+ group.add_argument('--user', help='直接指定 userId')
+ parser.add_argument(
+ '--time', required=True,
+ help='起始时间 yyyy-MM-dd HH:mm:ss',
+ )
+ parser.add_argument(
+ '--no-forward', action='store_true',
+ help='拉给定时间之前的消息 (默认拉给定时间之后)',
+ )
+ parser.add_argument(
+ '--limit', type=int, default=0,
+ help='返回条数 (不传则不限制)',
+ )
+ parser.add_argument('--output', default='', help='导出到 JSON 文件')
+ parser.add_argument('--dry-run', action='store_true')
+ args = parser.parse_args()
+
+ # 1. 获取 userId
+ user_id = args.user
+ if not user_id:
+ print(f'🔍 搜索用户: {args.name}')
+ user_id = search_user(args.name, args.dry_run)
+ if not user_id and not args.dry_run:
+ sys.exit(1)
+
+ # 2. 拉取单聊消息(自动翻页)
+ print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...')
+ all_messages: List[Any] = []
+ current_time = args.time
+ page = 0
+ max_pages = 50
+ remaining = args.limit if args.limit > 0 else float('inf')
+
+ while page < max_pages and remaining > 0:
+ cmd_args = [
+ 'chat', 'message', 'list',
+ '--user', user_id or '',
+ '--time', current_time,
+ '--format', 'json',
+ ]
+ if args.no_forward:
+ cmd_args.append('--forward=false')
+ page_limit = min(int(remaining), 200) if args.limit > 0 else 0
+ if page_limit > 0:
+ cmd_args.extend(['--limit', str(page_limit)])
+ data = run_dws(cmd_args, dry_run=args.dry_run)
+
+ if args.dry_run:
+ print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
+ return
+
+ if not data:
+ break
+
+ if isinstance(data, list):
+ page_msgs = data
+ has_more = False
+ else:
+ page_msgs = data.get('messages', data.get('result', []))
+ has_more = data.get('hasMore', False)
+
+ if not page_msgs:
+ break
+
+ all_messages.extend(page_msgs)
+ remaining -= len(page_msgs)
+ page += 1
+
+ if not has_more:
+ break
+
+ last_msg = page_msgs[-1]
+ boundary_time = last_msg.get('createAt') or last_msg.get('time', '')
+ if not boundary_time or boundary_time == current_time:
+ break
+ current_time = boundary_time
+ print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
+
+ if not all_messages:
+ print('未拉取到消息')
+ return
+
+ # 3. 输出结果
+ if isinstance(data, list):
+ messages = data
+ elif isinstance(data, dict):
+ inner = data.get('result', data)
+ if isinstance(inner, dict):
+ messages = inner.get('messages', inner.get('records', []))
+ elif isinstance(inner, list):
+ messages = inner
+ else:
+ messages = []
+ else:
+ messages = []
+
+ if args.output:
+ with open(args.output, 'w', encoding='utf-8') as f:
+ json.dump(all_messages, f, ensure_ascii=False, indent=2)
+ print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
+ else:
+ for m in all_messages:
+ sender = m.get('senderNick') or m.get('sender', '未知')
+ text = m.get('text') or m.get('content', '')
+ time_str = m.get('createAt') or m.get('time', '')
+ print(f" [{time_str}] {sender}: {text[:80]}")
+ print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/skills/scripts/contact_dept_members.py b/skills/mono/scripts/contact_dept_members.py
similarity index 100%
rename from skills/scripts/contact_dept_members.py
rename to skills/mono/scripts/contact_dept_members.py
diff --git a/skills/mono/scripts/doc_create_and_write.py b/skills/mono/scripts/doc_create_and_write.py
new file mode 100644
index 00000000..367e4b93
--- /dev/null
+++ b/skills/mono/scripts/doc_create_and_write.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+"""
+在指定目录创建文档并写入 Markdown 内容(一键完成)
+
+用法:
+ python doc_create_and_write.py \
+ --name "项目周报" \
+ --content "# 本周总结\n\n## 完成事项\n- 任务A"
+
+ python doc_create_and_write.py \
+ --name "会议纪要" \
+ --content-file notes.md
+
+ python doc_create_and_write.py \
+ --name "知识库文档" --content "# 内容" --folder FOLDER_ID
+
+ python doc_create_and_write.py --name "test" --content "hello" --dry-run
+"""
+
+import sys
+import json
+import time
+import subprocess
+import argparse
+from pathlib import Path
+from typing import List, Any, Optional
+
+
+def run_dws(
+ args: List[str], dry_run: bool = False,
+) -> Optional[Any]:
+ cmd = ['dws'] + args
+ if dry_run:
+ print(f"[dry-run] {' '.join(cmd)}")
+ return {'dry_run': True}
+ try:
+ result = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=60
+ )
+ if result.returncode != 0:
+ print(f" ✗ 错误:{result.stderr.strip()}")
+ return None
+ return json.loads(result.stdout)
+ except (subprocess.TimeoutExpired, json.JSONDecodeError,
+ FileNotFoundError) as e:
+ print(f" ✗ 错误:{e}")
+ return None
+
+
+def run_dws_with_retry(
+ args: List[str],
+ dry_run: bool = False,
+ max_retries: int = 3,
+ retry_delay: float = 1.0,
+) -> Optional[Any]:
+ """带重试机制的 dws 命令执行"""
+ last_error = None
+ for attempt in range(1, max_retries + 1):
+ result = run_dws(args, dry_run=dry_run)
+ if result is not None:
+ return result
+ if attempt < max_retries:
+ print(f" ⚠️ 第 {attempt} 次尝试失败,{retry_delay}秒后重试...")
+ time.sleep(retry_delay)
+ retry_delay *= 1.5 # 指数退避
+ return None
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='创建文档并写入内容'
+ )
+ parser.add_argument('--name', required=True, help='文档名称')
+ parser.add_argument('--content', default='', help='Markdown 内容')
+ parser.add_argument('--content-file', default='', help='内容文件')
+ parser.add_argument('--folder', default='', help='目标文件夹 ID 或 URL')
+ parser.add_argument('--workspace', default='', help='目标知识库 ID')
+ parser.add_argument(
+ '--mode', default='append', choices=['overwrite', 'append'],
+ help='写入模式: overwrite=覆盖, append=追加 (默认 append)',
+ )
+ parser.add_argument(
+ '--max-retries', type=int, default=3,
+ help='每块写入失败时的最大重试次数 (默认 3)',
+ )
+ parser.add_argument('--dry-run', action='store_true')
+ args = parser.parse_args()
+
+ content = args.content
+ if args.content_file:
+ p = Path(args.content_file)
+ if not p.exists():
+ print(f"错误:文件不存在: {p}")
+ sys.exit(1)
+ content = p.read_text(encoding='utf-8')
+ if not content:
+ print('错误:需要 --content 或 --content-file')
+ sys.exit(1)
+ chunk_size = 30000
+
+ create_args = ['doc', 'create', '--name', args.name, '--format', 'json']
+ if args.folder:
+ create_args.extend(['--folder', args.folder])
+ if args.workspace:
+ create_args.extend(['--workspace', args.workspace])
+
+ print(f'\n📝 创建文档: {args.name}')
+ create_data = run_dws(create_args, dry_run=args.dry_run)
+
+ node_id = None
+ if not args.dry_run:
+ if not create_data:
+ sys.exit(1)
+ node_id = (create_data.get('nodeId')
+ or create_data.get('dentryUuid')
+ or create_data.get('id', ''))
+ print(f" ✓ 文档已创建 (ID: {node_id})")
+
+ if len(content) <= chunk_size:
+ mode_label = '追加' if args.mode == 'append' else '覆盖'
+ print(f'\n✍️ 写入内容 (模式: {mode_label}, {len(content)} 字符)...')
+ write_data = run_dws([
+ 'doc', 'update',
+ '--node', node_id or '',
+ '--content', content,
+ '--mode', args.mode,
+ '--format', 'json',
+ ], dry_run=args.dry_run)
+ if write_data:
+ print(f" ✓ 内容已写入 ({len(content)} 字符)")
+ else:
+ chunks = []
+ pos = 0
+ while pos < len(content):
+ end = min(pos + chunk_size, len(content))
+ if end < len(content):
+ newline_pos = content.rfind('\n', pos, end)
+ if newline_pos > pos:
+ end = newline_pos + 1
+ chunks.append(content[pos:end])
+ pos = end
+
+ total_chunks = len(chunks)
+ print(f'\n✍️ 内容较长 ({len(content)} 字符), 分 {total_chunks} 块写入...')
+
+ success_chunks = 0
+ for idx, chunk in enumerate(chunks):
+ chunk_mode = args.mode if idx == 0 else 'append'
+ write_data = run_dws_with_retry(
+ [
+ 'doc', 'update',
+ '--node', node_id or '',
+ '--content', chunk,
+ '--mode', chunk_mode,
+ '--format', 'json',
+ ],
+ dry_run=args.dry_run,
+ max_retries=args.max_retries,
+ )
+ if write_data:
+ print(f" ✓ 块 {idx + 1}/{total_chunks} 已写入 ({len(chunk)} 字符)")
+ success_chunks += 1
+ elif not args.dry_run:
+ # 写入失败,报告部分写入状态
+ print(f"\n❌ 块 {idx + 1}/{total_chunks} 写入失败(已重试 {args.max_retries} 次)")
+ print(f"\n⚠️ 文档处于部分写入状态:")
+ print(f" - 文档 ID: {node_id}")
+ print(f" - 已写入: {success_chunks}/{total_chunks} 块")
+ print(f" - 失败位置: 第 {idx + 1} 块")
+ if args.mode == 'overwrite':
+ print(f" - 模式: 覆盖模式,文档可能包含不完整内容")
+ print(f" - 建议: 手动检查文档内容,或删除后重新创建")
+ else:
+ print(f" - 模式: 追加模式,已写入内容已保存")
+ print(f" - 建议: 可手动补充剩余内容,或重新运行脚本")
+ sys.exit(1)
+ print('\n✅ 完成!')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/skills/scripts/import_records.py b/skills/mono/scripts/import_records.py
similarity index 100%
rename from skills/scripts/import_records.py
rename to skills/mono/scripts/import_records.py
diff --git a/skills/mono/scripts/minutes_extract_todos.py b/skills/mono/scripts/minutes_extract_todos.py
new file mode 100644
index 00000000..7ca3d651
--- /dev/null
+++ b/skills/mono/scripts/minutes_extract_todos.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""
+从听记中提取所有待办事项并汇总
+
+用法:
+ python minutes_extract_todos.py # 最近 5 条听记
+ python minutes_extract_todos.py --max 10 # 最近 10 条
+ python minutes_extract_todos.py --id # 指定听记
+ python minutes_extract_todos.py --dry-run
+"""
+
+import sys
+import json
+import subprocess
+import argparse
+from pathlib import Path
+from typing import List, Any, Optional
+
+_scripts_dir = Path(__file__).resolve().parent
+if str(_scripts_dir) not in sys.path:
+ sys.path.insert(0, str(_scripts_dir))
+
+from minutes_list_parse import uuid_title_pairs_from_payload
+
+
+def run_dws(
+ args: List[str], dry_run: bool = False,
+) -> Optional[Any]:
+ cmd = ['dws'] + args
+ if dry_run:
+ print(f"[dry-run] {' '.join(cmd)}")
+ return None
+ try:
+ result = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=60
+ )
+ if result.returncode != 0:
+ print(f"错误:{result.stderr.strip()}", file=sys.stderr)
+ return None
+ return json.loads(result.stdout)
+ except (subprocess.TimeoutExpired, json.JSONDecodeError,
+ FileNotFoundError) as e:
+ print(f"错误:{e}", file=sys.stderr)
+ return None
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='从听记中提取待办事项'
+ )
+ parser.add_argument('--max', type=int, default=5)
+ parser.add_argument('--id', default='', help='指定听记 UUID')
+ parser.add_argument('--dry-run', action='store_true')
+ args = parser.parse_args()
+
+ uuids_with_titles = []
+ if args.id:
+ uuids_with_titles = [(args.id, args.id)]
+ else:
+ print('🎙️ 获取听记列表...')
+ data = run_dws([
+ 'minutes', 'list', 'mine',
+ '--max', str(args.max),
+ '--format', 'json',
+ ], dry_run=args.dry_run)
+ if args.dry_run:
+ run_dws([
+ 'minutes', 'get', 'todos',
+ '--id', '', '--format', 'json',
+ ], dry_run=True)
+ return
+ if not data:
+ return
+ uuids_with_titles = uuid_title_pairs_from_payload(data)
+
+ all_todos = []
+ for uuid, title in uuids_with_titles:
+ print(f" 提取待办: {title}")
+ todos_data = run_dws([
+ 'minutes', 'get', 'todos',
+ '--id', uuid, '--format', 'json',
+ ])
+ if not todos_data:
+ continue
+ if isinstance(todos_data, list):
+ items = todos_data
+ elif isinstance(todos_data, dict):
+ inner = todos_data.get('result', todos_data)
+ if isinstance(inner, dict):
+ items = inner.get('todos', [])
+ elif isinstance(inner, list):
+ items = inner
+ else:
+ items = []
+ else:
+ items = []
+ for t in items:
+ if isinstance(t, dict):
+ t['_source'] = title
+ all_todos.extend(items)
+
+ print(f"\n📋 听记待办汇总")
+ print('=' * 50)
+
+ if not all_todos:
+ print(' ✅ 暂无待办事项')
+ return
+
+ for t in all_todos:
+ if not isinstance(t, dict):
+ print(f" • {t!r}")
+ continue
+ content = (t.get('content') or t.get('text')
+ or t.get('title', ''))
+ source = t.get('_source', '')
+ print(f" • {content}")
+ if source:
+ print(f" 来自: {source}")
+
+ print(f"\n合计: {len(all_todos)} 条待办")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/skills/mono/scripts/minutes_recent_summary.py b/skills/mono/scripts/minutes_recent_summary.py
new file mode 100644
index 00000000..24e8ed44
--- /dev/null
+++ b/skills/mono/scripts/minutes_recent_summary.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""
+获取最近 N 条听记的 AI 摘要并合并输出
+
+用法:
+ python minutes_recent_summary.py # 最近 5 条
+ python minutes_recent_summary.py --max 10 # 最近 10 条
+ python minutes_recent_summary.py --output summary.md
+ python minutes_recent_summary.py --dry-run
+"""
+
+import sys
+import json
+import subprocess
+import argparse
+from pathlib import Path
+from typing import List, Any, Optional
+
+_scripts_dir = Path(__file__).resolve().parent
+if str(_scripts_dir) not in sys.path:
+ sys.path.insert(0, str(_scripts_dir))
+
+from minutes_list_parse import uuid_title_pairs_from_payload
+
+
+def run_dws(
+ args: List[str], dry_run: bool = False,
+) -> Optional[Any]:
+ cmd = ['dws'] + args
+ if dry_run:
+ print(f"[dry-run] {' '.join(cmd)}")
+ return None
+ try:
+ result = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=60
+ )
+ if result.returncode != 0:
+ print(f"错误:{result.stderr.strip()}", file=sys.stderr)
+ return None
+ return json.loads(result.stdout)
+ except (subprocess.TimeoutExpired, json.JSONDecodeError,
+ FileNotFoundError) as e:
+ print(f"错误:{e}", file=sys.stderr)
+ return None
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description='获取最近听记的 AI 摘要'
+ )
+ parser.add_argument(
+ '--max', type=int, default=5, help='获取条数 (默认 5)'
+ )
+ parser.add_argument(
+ '--output', default='', help='输出到 Markdown 文件'
+ )
+ parser.add_argument('--dry-run', action='store_true')
+ args = parser.parse_args()
+
+ print('🎙️ 获取听记列表...')
+ list_data = run_dws([
+ 'minutes', 'list', 'mine',
+ '--max', str(args.max),
+ '--format', 'json',
+ ], dry_run=args.dry_run)
+
+ if args.dry_run:
+ run_dws([
+ 'minutes', 'get', 'summary',
+ '--id', '', '--format', 'json',
+ ], dry_run=True)
+ return
+
+ if not list_data:
+ print('未找到听记')
+ return
+
+ pairs = uuid_title_pairs_from_payload(list_data)
+ if not pairs:
+ print('暂无听记')
+ return
+
+ output_lines = [f"# 最近 {len(pairs)} 条听记摘要\n"]
+ for i, (uuid, title) in enumerate(pairs, 1):
+ print(f" [{i}/{len(pairs)}] 获取摘要: {title}")
+
+ summary_data = run_dws([
+ 'minutes', 'get', 'summary',
+ '--id', uuid, '--format', 'json',
+ ])
+ summary_text = ''
+ if summary_data:
+ if isinstance(summary_data, str):
+ summary_text = summary_data
+ elif isinstance(summary_data, dict):
+ summary_text = (summary_data.get('summary')
+ or summary_data.get('content')
+ or json.dumps(summary_data,
+ ensure_ascii=False))
+
+ output_lines.append(f"## {i}. {title}\n")
+ if summary_text:
+ output_lines.append(f"{summary_text}\n")
+ else:
+ output_lines.append("(暂无摘要)\n")
+
+ full_output = '\n'.join(output_lines)
+
+ if args.output:
+ with open(args.output, 'w', encoding='utf-8') as f:
+ f.write(full_output)
+ print(f"\n✓ 已输出到 {args.output}")
+ else:
+ print('\n' + full_output)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/skills/scripts/report_inbox_today.py b/skills/mono/scripts/report_inbox_today.py
similarity index 100%
rename from skills/scripts/report_inbox_today.py
rename to skills/mono/scripts/report_inbox_today.py
diff --git a/skills/scripts/todo_batch_create.py b/skills/mono/scripts/todo_batch_create.py
similarity index 100%
rename from skills/scripts/todo_batch_create.py
rename to skills/mono/scripts/todo_batch_create.py
diff --git a/skills/scripts/todo_daily_summary.py b/skills/mono/scripts/todo_daily_summary.py
similarity index 100%
rename from skills/scripts/todo_daily_summary.py
rename to skills/mono/scripts/todo_daily_summary.py
diff --git a/skills/scripts/todo_overdue_check.py b/skills/mono/scripts/todo_overdue_check.py
similarity index 100%
rename from skills/scripts/todo_overdue_check.py
rename to skills/mono/scripts/todo_overdue_check.py
diff --git a/skills/scripts/upload_attachment.py b/skills/mono/scripts/upload_attachment.py
similarity index 100%
rename from skills/scripts/upload_attachment.py
rename to skills/mono/scripts/upload_attachment.py
diff --git a/skills/multi/dingtalk-aiapp/SKILL.md b/skills/multi/dingtalk-aiapp/SKILL.md
new file mode 100644
index 00000000..c9162821
--- /dev/null
+++ b/skills/multi/dingtalk-aiapp/SKILL.md
@@ -0,0 +1,35 @@
+---
+name: dingtalk-aiapp
+description: 钉钉 AI 应用生成。Use when 用户说 创建应用/生成系统/做工具/管理后台/工作台应用/表单系统/业务原型/页面/平台。强制触发:用户提到「应用 / 系统 / 平台 / 工具 / 后台 / 页面 / 原型」时优先匹配此 skill。Distinct from dingtalk-workbench(工作台应用列表)、dingtalk-wiki(知识库)。命令前缀:dws aiapp。
+cli_version: ">=0.2.14"
+metadata:
+ category: product
+ stability: experimental
+ requires:
+ bins:
+ - dws
+---
+
+# 钉钉 AI 应用 Skill
+
+> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
+
+> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
+
+
+
+> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws --help` 或 `--dry-run` 验证。
+
+
+> 命令参考:[aiapp.md](references/aiapp.md)。
+
+## 意图表
+
+| 用户说 | 命令 |
+|--------|------|
+| "创建一个 XX 应用 / 系统 / 工具" | `python scripts/aiapp_create_and_poll.py --prompt "<用户原始需求>"`(自动轮询进度) |
+| "查 AI 应用 / 修改已有应用" | 见 [aiapp.md](references/aiapp.md) |
+
+## 跨产品协作
+
+- 已存在的工作台应用列表 → 切到 `dingtalk-workbench`
diff --git a/skills/multi/dingtalk-aiapp/references/aiapp.md b/skills/multi/dingtalk-aiapp/references/aiapp.md
new file mode 100644
index 00000000..a7328ede
--- /dev/null
+++ b/skills/multi/dingtalk-aiapp/references/aiapp.md
@@ -0,0 +1,420 @@
+# AI应用 (aiapp) 命令参考
+
+## 强制触发规则
+
+以下意图默认必须优先使用 `aiapp`,即使用户没有说“AI应用”:
+
+- 创建一个新应用
+- 创建一个新系统
+- 生成一个业务工具
+- 按需求生成一个管理后台、工作台应用、表单系统、业务原型
+- 修改一个已经生成过的应用
+
+典型触发词包括但不限于:
+
+- 应用、系统、平台、工具、后台、页面、原型、工作台应用
+- CRM、OA、库存、仓库、审批、销售、报销、进销存、客户管理、数据录入、查询工具
+- 计算器、待办工具、表单应用、信息收集应用、报表应用
+- “帮我做一个…”
+- “帮我创建一个…”
+- “按这个需求生成一个…”
+- “把这个需求做成应用”
+
+典型例子:
+
+- “帮我创建个简易的计算器应用”
+- “做一个仓库管理系统”
+- “生成一个客户管理工具”
+- “按这个 PRD 创建一个钉钉应用”
+- “修改刚才那个应用,把首页改得更简洁”
+
+不要误路由到其他产品:
+
+- 不要因为用户提到“表格/记录/字段”,就优先走 `aitable`,如果用户的真实目标是“生成一个完整应用”
+- 不要因为用户提到“文档/需求”,就优先走 `doc`,如果用户的真实目标是“把需求做成应用”
+- 不要因为用户提到“任务/项目”,就优先走 `tb`,如果用户的真实目标是“生成一个管理系统”
+
+只有在以下场景不要走 `aiapp`:
+
+- 用户明确要操作一个现成的多维表、文档、项目、日历、机器人消息
+- 用户不是要“生成应用”,而是要“修改已有产品对象”
+
+## 命令总览
+
+### 创建 AI 应用
+```
+Usage:
+ dws aiapp create [flags]
+Example:
+ dws aiapp create --prompt "创建一个天气查询应用"
+ dws aiapp create --prompt "翻译应用" --skills s1,s2
+ dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" --attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]'
+Flags:
+ --prompt string 创建 AI 应用的 prompt (必填)
+ --skills string 技能 ID 列表,逗号分隔
+ --attachments string 附件对象数组的 JSON 字符串,用于把 Excel/图片等输入材料一起传入
+```
+
+### 查询 AI 应用
+```
+Usage:
+ dws aiapp query [flags]
+Example:
+ dws aiapp query --task-id
+Flags:
+ --task-id string AI 应用任务 ID (必填)
+```
+
+### 修改 AI 应用
+```
+Usage:
+ dws aiapp modify [flags]
+Example:
+ dws aiapp modify --prompt "改为翻译应用" --thread-id
+ dws aiapp modify --prompt "新描述" --thread-id --skills s1,s2
+ dws aiapp modify --prompt "根据新图片优化首页视觉风格" --thread-id