feat(skill): dws skill setup with mono/multi modes (#332)

新增 `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
This commit is contained in:
修雨 authored and GitHub committed 2026-05-26 09:17:37 +08:00
1 parent 86ff7e2f50
commit 5ce7cb61b0
162 files changed
+22471 -1200

No files matched your search

+63 -11
View File
@@ -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 irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
``` ```
<details>
<summary><strong>Skill mode: mono vs multi</strong></summary>
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.
</details>
<details> <details>
<summary>Other install methods</summary> <summary>Other install methods</summary>
@@ -230,26 +251,56 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
### Agent Skills ### 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 ```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 curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
``` ```
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project). > `install.sh` installs 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=<path>`.
**What's included (mono layout):**
| Component | Path | Description | | Component | Path | Description |
|-----------|------|-------------| |-----------|------|-------------|
| Master Skill | `SKILL.md` | Intent routing, decision tree, safety rules, error handling | | Master Skill | `skills/mono/SKILL.md` | Intent routing, decision tree, safety rules, error handling |
| Product references | `references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) | | Product references | `skills/mono/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) | | Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `references/global-reference.md` | Auth, output formats, global flags | | Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `references/error-codes.md` | Error codes + debugging workflows | | Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling | | Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `scripts/*.py` | 13 batch operation scripts (see below) | | Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
<details> <details>
<summary><strong>Ready-made scripts</strong> — 13 Python scripts for common multi-step workflows</summary> <summary><strong>Ready-made scripts</strong> — 13 Python scripts for common multi-step workflows</summary>
@@ -430,7 +481,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
<details> <details>
<summary>Coming soon</summary> <summary>Coming soon</summary>
`conference` (video meetings) - `conference` (video meetings)
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
</details> </details>
+63 -11
View File
@@ -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 irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
``` ```
<details>
<summary><strong>Skill 模式:mono 与 multi</strong></summary>
安装时可以选择两种 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`),随时重跑都行。
</details>
<details> <details>
<summary>其他安装方式</summary> <summary>其他安装方式</summary>
@@ -230,26 +251,56 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
### Agent Skills ### 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 ```bash
# 安装 skills 到当前项目 # 安装 skills 到当前项目(默认 mono)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
``` ```
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。 > `install.sh` 安装到 `$HOME/.agents/skills/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` | 意图路由、决策树、安全规则、错误处理 | | 主 Skill | `skills/mono/SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
| 产品参考 | `references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) | | 产品参考 | `skills/mono/references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
| 意图指南 | `references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) | | 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `references/global-reference.md` | 认证、输出格式、全局 flag | | 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `references/error-codes.md` | 错误码 + 调试流程 | | 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 | | Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `scripts/*.py` | 13 个批量操作脚本(见下方) | | 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
<details> <details>
<summary><strong>现成脚本</strong> — 13 个 Python 脚本,覆盖常见多步工作流</summary> <summary><strong>现成脚本</strong> — 13 个 Python 脚本,覆盖常见多步工作流</summary>
@@ -430,7 +481,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
<details> <details>
<summary>即将推出</summary> <summary>即将推出</summary>
`conference`(视频会议) - `conference`(视频会议)
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
</details> </details>
+36 -3
View File
@@ -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() { function main() {
const packageRoot = __dirname; const packageRoot = __dirname;
const assetsDir = path.join(packageRoot, "assets"); const assetsDir = path.join(packageRoot, "assets");
const vendorDir = path.join(packageRoot, "vendor"); 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}`]; const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
if (!assetName) { if (!assetName) {
throw new Error(`unsupported platform: ${process.platform}/${process.arch}`); throw new Error(`unsupported platform: ${process.platform}/${process.arch}`);
@@ -156,8 +181,16 @@ function main() {
} }
extractArchive(archivePath, vendorDir); extractArchive(archivePath, vendorDir);
extractSkills(skillsPath, skillDir); extractSkills(skillsPath, skillsStaging);
installSkillsToHomes(skillDir);
// 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(); main();
+26
View File
@@ -14,11 +14,37 @@ require (
) )
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/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/godbus/dbus/v5 v5.2.2 // indirect
github.com/itchyny/timefmt-go v0.1.7 // 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-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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 ( require (
+53
View File
@@ -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/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 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= 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/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 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= 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 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 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/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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= 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= 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 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+65 -9
View File
@@ -24,6 +24,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -85,24 +86,80 @@ type CliSkillDTO struct {
// agentSkillPaths maps target names to their relative skill installation paths. // agentSkillPaths maps target names to their relative skill installation paths.
// These paths are relative to the user's home directory. // These paths are relative to the user's home directory.
//
// Source of truth for both `dws skill install <skillId> <target>` and
// `dws skill setup --target <name>`. Every entry in skillSetupAgentHomes
// (skill_setup.go) MUST have a matching path value here — enforced by
// TestAgentSkillPathsCoversSetupHomes.
var agentSkillPaths = map[string]string{ 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", "qoder": ".qoder/skills",
"claude": ".claude/skills", "claude": ".claude/skills",
"cursor": ".cursor/skills", "cursor": ".cursor/skills",
"codex": ".codex/skills", "codex": ".codex/skills",
"opencode": filepath.Join(".config", "opencode", "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 { func supportedTargets() string {
targets := make([]string, 0, len(agentSkillPaths)+1) targets := make([]string, 0, len(agentSkillPaths)+1)
for target := range agentSkillPaths { for target := range agentSkillPaths {
targets = append(targets, target) targets = append(targets, target)
} }
sort.Strings(targets)
targets = append(targets, ".") targets = append(targets, ".")
return strings.Join(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
// " <name> -> ~/<path>" 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 { func buildSkillCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "skill", Use: "skill",
@@ -122,6 +179,7 @@ func buildSkillCommand() *cobra.Command {
newSkillSearchCommand(), newSkillSearchCommand(),
newSkillFindHintCommand(), newSkillFindHintCommand(),
newSkillAddHintCommand(), newSkillAddHintCommand(),
newSkillSetupCommand(),
) )
return cmd return cmd
} }
@@ -179,17 +237,15 @@ func newSkillInstallCommand() *cobra.Command {
target 安装目标(必填),支持: %s target 安装目标(必填),支持: %s
安装路径: 安装路径:
qoder -> ~/.qoder/skills/ %s .%s -> 当前目录
claude -> ~/.claude/skills/
cursor -> ~/.cursor/skills/
codex -> ~/.codex/skills/
opencode -> ~/.config/opencode/skills/
. -> 当前目录
示例: 示例:
dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/
dws skill install skill-123 claude # 安装到 ~/.claude/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), Args: cobra.ExactArgs(2),
DisableAutoGenTag: true, DisableAutoGenTag: true,
RunE: runSkillAdd, RunE: runSkillAdd,
+29 -2
View File
@@ -452,8 +452,14 @@ func TestFetchSkillDownloadInfoUnauthorized(t *testing.T) {
func TestSupportedTargets(t *testing.T) { func TestSupportedTargets(t *testing.T) {
targets := supportedTargets() targets := supportedTargets()
// Should contain all predefined targets // Should contain all predefined targets — including the agents/* sentinel
expectedTargets := []string{"qoder", "claude", "cursor", "codex", "opencode", "."} // 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 { for _, expected := range expectedTargets {
if !strings.Contains(targets, expected) { if !strings.Contains(targets, expected) {
t.Errorf("supportedTargets() should contain %s, got: %s", expected, targets) 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 <name>`) 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 <name>", home)
}
}
}
func TestAgentSkillPathsCrossPlatform(t *testing.T) { func TestAgentSkillPathsCrossPlatform(t *testing.T) {
// Verify that paths use platform-appropriate separators // Verify that paths use platform-appropriate separators
for target, path := range agentSkillPaths { for target, path := range agentSkillPaths {
+647
View File
@@ -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/<mode> 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 → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
// - multi → <agent-home> (安装时把 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(按子目录平铺到 <agent-home>/<skill-name>/):\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 <agent-home>/dws → multi 残留是 <agent-home>/dingtalk-*
// - multi dest is <agent-home> → mono 残留是 <agent-home>/dws
func mutualExclusionVictims(dest, mode string) []string {
switch mode {
case skillSetupModeMono:
// dest = <agent-home>/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 = <agent-home> → 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
}
+559
View File
@@ -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/<mode>/ 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/<name>.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 <agent-home>/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)
}
}
+22 -1
View File
@@ -18,6 +18,7 @@ REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
VERSION="${DWS_VERSION:-latest}" VERSION="${DWS_VERSION:-latest}"
SKILL_NAME="dws" SKILL_NAME="dws"
ROOT="${DWS_SKILLS_ROOT:-$PWD}" ROOT="${DWS_SKILLS_ROOT:-$PWD}"
DWS_CACHE_ROOT="${DWS_CACHE_ROOT:-$HOME/.dws}"
# ── Helpers ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────
@@ -170,8 +171,11 @@ main() {
curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip" curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip"
extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted" 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" 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}" SKILL_SRC="$TMPDIR_WORK/extracted/${SKILL_NAME}"
fi fi
@@ -184,6 +188,23 @@ main() {
printf ' Installing under root: %s\n' "$ROOT" printf ' Installing under root: %s\n' "$ROOT"
install_skills_to_root "$SKILL_SRC" "$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 '\n'
printf ' 📖 Skill includes:\n' printf ' 📖 Skill includes:\n'
printf ' • SKILL.md — Main skill with product overview and intent routing\n' printf ' • SKILL.md — Main skill with product overview and intent routing\n'
+132 -5
View File
@@ -17,6 +17,7 @@
# DWS_ARCH — architecture override (amd64 or arm64) # DWS_ARCH — architecture override (amd64 or arm64)
# DWS_NO_SKILLS — set to 1 to skip skills install # DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills # 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). # 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" $NoSkills = $env:DWS_NO_SKILLS -eq "1"
$SkillsOnly = $env:DWS_SKILLS_ONLY -eq "1" $SkillsOnly = $env:DWS_SKILLS_ONLY -eq "1"
$SkillName = "dws" $SkillName = "dws"
$SkillMode = ""
# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS). # Agent skill base directories (same order as build/npm/install.js AGENT_DIRS).
$AgentDirs = @( $AgentDirs = @(
@@ -219,6 +221,67 @@ function Write-Banner {
Write-Host "" 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 ─────────────────────────────────────────────────────────── # ── Install Binary ───────────────────────────────────────────────────────────
function Install-Binary { function Install-Binary {
@@ -297,7 +360,8 @@ function Install-Binary {
function Install-SkillsLocal { function Install-SkillsLocal {
param([string]$Root) 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)) { if (!(Test-Path $skillSrc)) {
Write-Say "⚠️ Local skills directory not found: $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" Write-Say "📦 Installing agent skills from local source: $skillSrc"
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME 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 { function Install-SkillsToHomes {
@@ -407,8 +506,12 @@ function Install-Skills {
$extractRoot = Join-Path $tmpDir "skills" $extractRoot = Join-Path $tmpDir "skills"
Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force
# Prefer the explicit mono/ subtree; fall back to legacy nested or zip root.
$skillSrc = $extractRoot $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 $skillSrc = Join-Path $extractRoot $SkillName
} }
@@ -424,6 +527,14 @@ function Install-Skills {
} }
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME 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 { } finally {
Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
} }
@@ -435,18 +546,34 @@ $SourceRoot = Resolve-SourceRoot
Write-Banner Write-Banner
if (!$NoSkills) {
Resolve-SkillMode
}
if ($SourceRoot -and !$SkillsOnly -and ($Version -eq "latest")) { if ($SourceRoot -and !$SkillsOnly -and ($Version -eq "latest")) {
Install-BinaryFromSource -Root $SourceRoot Install-BinaryFromSource -Root $SourceRoot
if (!$NoSkills) { if (!$NoSkills) {
Install-SkillsLocal -Root $SourceRoot if ($SkillMode -eq "multi") {
Write-MultiModeNotice
} else {
Install-SkillsLocal -Root $SourceRoot
}
} }
} elseif ($SkillsOnly) { } elseif ($SkillsOnly) {
Install-Skills if ($SkillMode -eq "multi") {
Write-MultiModeNotice
} else {
Install-Skills
}
} elseif ($NoSkills) { } elseif ($NoSkills) {
Install-Binary Install-Binary
} else { } else {
Install-Binary Install-Binary
Install-Skills if ($SkillMode -eq "multi") {
Write-MultiModeNotice
} else {
Install-Skills
}
} }
Write-Host "" Write-Host ""
+149 -8
View File
@@ -14,6 +14,7 @@
# DWS_VERSION — version to install (default: latest) # DWS_VERSION — version to install (default: latest)
# DWS_NO_SKILLS — set to 1 to skip skills install # DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary) # 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). # 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}" NO_SKILLS="${DWS_NO_SKILLS:-0}"
SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}" SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}"
SKILL_NAME="dws" SKILL_NAME="dws"
SKILL_MODE=""
# ── Helpers ────────────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────────────
@@ -137,6 +139,64 @@ print_banner() {
printf '\n' 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() { install_binary_from_source() {
root="$1" root="$1"
@@ -166,7 +226,8 @@ install_binary_from_source() {
install_skills_local() { install_skills_local() {
root="$1" root="$1"
skill_src="${root}/skills" skill_src="${root}/skills/mono"
multi_src="${root}/skills/multi"
if [ ! -d "$skill_src" ]; then if [ ! -d "$skill_src" ]; then
say "⚠️ Local skills directory not found: ${skill_src}" say "⚠️ Local skills directory not found: ${skill_src}"
@@ -179,9 +240,57 @@ install_skills_local() {
install_skills_to_homes "$skill_src" install_skills_to_homes "$skill_src"
# Cache multi source for later `dws skill setup --mode multi`.
if [ -d "$multi_src" ]; then
cache_multi_skills "$multi_src"
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 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 skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
install_skills_to_homes() { install_skills_to_homes() {
skill_src="$1" skill_src="$1"
@@ -403,8 +512,14 @@ install_skills() {
err "Cannot extract release skill archive and no local source checkout found." err "Cannot extract release skill archive and no local source checkout found."
fi 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" 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" skill_src="$extract_root/$SKILL_NAME"
fi fi
if [ ! -f "$skill_src/SKILL.md" ]; then if [ ! -f "$skill_src/SKILL.md" ]; then
@@ -422,6 +537,15 @@ install_skills() {
install_skills_to_homes "$skill_src" 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" rm -rf "$tmpdir_skills"
} }
@@ -435,23 +559,40 @@ main() {
print_banner 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 if [ -n "$source_root" ]; then
install_binary_from_source "$source_root" install_binary_from_source "$source_root"
if [ "$NO_SKILLS" != "1" ]; then 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 fi
elif [ "$SKILLS_ONLY" = "1" ]; then elif [ "$SKILLS_ONLY" = "1" ]; then
local_root="$(resolve_source_root || true)" if [ "$SKILL_MODE" = "multi" ]; then
if [ -n "$local_root" ]; then print_multi_mode_notice
install_skills_local "$local_root"
else else
install_skills local_root="$(resolve_source_root || true)"
if [ -n "$local_root" ]; then
install_skills_local "$local_root"
else
install_skills
fi
fi fi
elif [ "$NO_SKILLS" = "1" ]; then elif [ "$NO_SKILLS" = "1" ]; then
install_binary install_binary
else else
install_binary install_binary
install_skills if [ "$SKILL_MODE" = "multi" ]; then
print_multi_mode_notice
else
install_skills
fi
fi fi
printf '\n' printf '\n'
+22 -1
View File
@@ -168,10 +168,31 @@ stage_homebrew_formula() {
create_skills_zip() { create_skills_zip() {
skills_zip="$DIST_DIR/dws-skills.zip" skills_zip="$DIST_DIR/dws-skills.zip"
rm -f "$skills_zip" rm -f "$skills_zip"
staging="$(mktemp -d)"
# Layout inside dws-skills.zip:
# <root>/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.
# <root>/mono/ ← explicit mono source tree
# <root>/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" . 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 ---------- # ---------- darwin ad-hoc signing ----------
File renamed without changes.
File renamed without changes.
+3
View File
@@ -8,6 +8,9 @@ cli_version: ">=1.0.15"
通过 `dws` 命令管理钉钉产品能力。 通过 `dws` 命令管理钉钉产品能力。
> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
## 严格禁止 (NEVER DO) ## 严格禁止 (NEVER DO)
- 不要使用 dws 命令以外的方式操作(禁止 curl、HTTP API、浏览器) - 不要使用 dws 命令以外的方式操作(禁止 curl、HTTP API、浏览器)
- 不要编造 UUID、ID 等标识符,必须从命令返回中提取 - 不要编造 UUID、ID 等标识符,必须从命令返回中提取
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,209 @@
# 钉钉默认表情列表(emoji 回应可用)
> 本文件列出钉钉支持的所有用户可见默认表情(showType=1),共 199 个。
>
> 使用规则:
> - 用户描述的表情命中下表中的 `name` → 使用 `chat message add-emoji --emoji <name>` 贴 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 |
File diff suppressed because it is too large. Load diff
File renamed without changes.
File renamed without changes.
@@ -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: Usage:
@@ -87,15 +103,56 @@ Flags:
--conflict-handler string 同名冲突策略: AUTO_RENAME / OVERWRITE / RETURN_DENTRY_IF_EXIST (默认 AUTO_RENAME) --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 <dentryUuid>
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 <dentryUuid> --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` 用户说"钉盘有什么文件/列钉盘/看钉盘目录" → `list`
用户说"钉盘文件详情/文件信息" → `info` (需 fileId) 用户说"钉盘文件详情/文件信息" → `info` (需 fileId)
用户说"新建钉盘目录/钉盘里建文件夹" → `mkdir` 用户说"新建钉盘目录/钉盘里建文件夹" → `mkdir`
用户说"下载钉盘文件/把这个文件拿下来" → `download` 拿临时 URL,再由 Agent 自行发起 HTTP GET 用户说"下载钉盘文件/把这个文件拿下来" → `download` 拿临时 URL,再由 Agent 自行发起 HTTP GET
用户说"上传文件到钉盘/把本地文件传钉盘": 用户说"上传文件到钉盘/把本地文件传钉盘":
- 三步走: `upload-info` → HTTP PUT 到预签名 URL → `commit` - **首选 `upload`**:一条命令自动完成三步(适合本地文件直接上传)
- **没有**一条聚合命令可以完成;必须完整走完三步 - 也可手动三步:`upload-info` → HTTP PUT 到预签名 URL → `commit`(适合自定义流式上传,或不想在客户端做 HTTP PUT 的场景)
用户说"删除钉盘文件/移到回收站" → `delete`(不可逆,必须确认)
关键区分: 关键区分:
- drive(钉盘云存储,面向文件二进制) vs doc(在线文档/知识库,面向富文本内容) - drive(钉盘云存储,面向文件二进制) vs doc(在线文档/知识库,面向富文本内容)
@@ -162,8 +219,8 @@ dws drive mkdir --name "2026 Q1 归档" --format json
## 注意事项 ## 注意事项
- 上传是**三步**流程:`upload-info` → 客户端 HTTP PUT 到预签名 URL → `commit`。**没有**自动聚合命令,跳过任何一步都会失败 - 上传两条路径:**首选** `upload` 一条命令搞定(内部走三步);如果需要自己控制 HTTP PUT,可走 `upload-info` → 客户端 HTTP PUT → `commit` 三步
- Step 2 的 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;只有 PUT 返回 200 才能调 `commit` - 用三步流程时,Step 2 的 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;只有 PUT 返回 200 才能调 `commit`
- 上传凭证 (`uploadId`) 有过期时间,拿到后尽快 commit;过期需重新调 `upload-info` - 上传凭证 (`uploadId`) 有过期时间,拿到后尽快 commit;过期需重新调 `upload-info`
- `download` 只返回临时 URL(几分钟级别有效期),不会把文件落地;要真正下载到本地必须再发 HTTP GET - `download` 只返回临时 URL(几分钟级别有效期),不会把文件落地;要真正下载到本地必须再发 HTTP GET
- `--parent-id` 在 upload-info / commit 中要保持一致,否则 commit 会报位置不匹配 - `--parent-id` 在 upload-info / commit 中要保持一致,否则 commit 会报位置不匹配
@@ -14,7 +14,7 @@ Example:
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `mailboxes` | `List[]` | 邮箱列表,每条包含邮箱地址、账号类型、所属企业 | | `emailAccounts` | `List[]` | 邮箱列表,每条含 `email` / `type` / `orgName`;type 取值 `PERSONAL`(个人邮箱)/ `ORGANIZATION`(企业邮箱) |
### 搜索邮件 (KQL 语法) ### 搜索邮件 (KQL 语法)
``` ```
@@ -26,15 +26,15 @@ Flags:
Usage: Usage:
dws oa approval list-initiated [flags] dws oa approval list-initiated [flags]
Example: Example:
dws oa approval list-initiated dws oa approval list-initiated --process-code <processCode> --start "2026-03-01T00:00:00+08:00"
dws oa approval list-initiated --process-code <processCode> --max-results 20 dws oa approval list-initiated --process-code <processCode> --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --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 <nextToken> dws oa approval list-initiated --process-code <processCode> --start "2026-03-01T00:00:00+08:00" --next-token <nextToken>
Flags: Flags:
--process-code string 按指定表单/流程模板过滤 (可选) --process-code string 审批表单 processCode (必填) — 来自 dws oa approval list-forms
--start string 发起开始时间 ISO-8601 (可选) --start string 发起开始时间 ISO-8601 (必填)
--end string 发起结束时间 ISO-8601 (可选) --end string 发起结束时间 ISO-8601 (可选, 缺省查到当前)
--max-results string 每页条数 (可选) --max-results int 每页大小 (最大 20, 默认 20)
--next-token string 分页 token (首页留空) --next-token int 分页游标 (首次传 0)
``` ```
查询当前用户作为申请人发起的审批,用来查"我报销到哪一步了"、"我之前提的请假单"。 查询当前用户作为申请人发起的审批,用来查"我报销到哪一步了"、"我之前提的请假单"。
@@ -164,9 +164,9 @@ dws oa approval reject --instance-id <processInstanceId> --task-id <taskId> --r
# 5. 查看流程记录 — 谁何时做了什么 # 5. 查看流程记录 — 谁何时做了什么
dws oa approval records --instance-id <processInstanceId> --format json dws oa approval records --instance-id <processInstanceId> --format json
# 6. 查我发起的审批 / 按模板过滤 # 6. 查我发起的审批(--process-code 和 --start 都是必填,processCode 先 list-forms 拿到)
dws oa approval list-initiated --format json dws oa approval list-initiated --process-code <processCode> --start "2026-03-01T00:00:00+08:00" --format json
dws oa approval list-initiated --process-code <processCode> --format json dws oa approval list-initiated --process-code <processCode> --start "2026-03-01T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --format json
# 7. 能发起哪些表单 # 7. 能发起哪些表单
dws oa approval list-forms --cursor 0 --size 20 --format json dws oa approval list-forms --cursor 0 --size 20 --format json
@@ -68,9 +68,9 @@ Usage:
Example: Example:
dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20 dws report list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20
Flags: Flags:
--cursor string 分页游标 (必填) --cursor int 分页游标,首次传 0 (可选,默认 0)
--end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填) --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) (必填) --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)
``` ```
+235
View File
@@ -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 <ID> --output <路径>` 先下载到本地再用本地工具解析,**禁止**调用任何 `sheet` 子命令 |
> 用户直接粘贴 `alidocs` URL 时,先用 `dws doc info --node <URL> --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 <NODE_ID>` —— 钉钉表格文档的 nodeId 或 `https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>` URL
- `--sheet-id <SHEET_ID>` —— 工作表 ID,从 `dws sheet list` 拿
例外:
- `create` 只需要 `--name`(在知识库创建文档时不需要 nodeId)
- `list` / `info` / `range read` 只需要 `--node`
## 常用命令示例
### 创建文档 + 新建工作表
```bash
# 在知识库下创建一个钉钉表格文档
dws sheet create --name "销售数据" --workspace <WS_ID> --format json
# 返回的 nodeId 用于后续操作
# 在已有文档中新建一张工作表
dws sheet new --node <NODE_ID> --name "Q1 数据" --format json
```
### 读写区域
```bash
# 读 A1:D10
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --format json
# 写入 5x4 区域(values 是二维 JSON 数组)
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D5" \
--values '[["姓名","岗位","入职","薪资"],["张三","研发","2024-01","30000"]]' \
--format json
# 追加行
dws sheet append --node <NODE_ID> --sheet-id <SHEET_ID> \
--values '[["李四","产品","2025-03","28000"]]' \
--format json
```
### 行列操作
```bash
# 在第 5 行处插入 2 个空行
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension rows --position 4 --length 2 --format json
# 末尾追加 3 列
dws sheet add-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension columns --length 3 --format json
# 删除第 10-12 行
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension rows --position 9 --length 3 --format json
# 隐藏 B 列(startIndex=1, length=1)
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension columns --start-index 1 --length 1 --hidden true --format json
```
### 合并/取消合并
```bash
# 合并 A1:C1
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> \
--range "A1:C1" --merge-type mergeAll --format json
# 取消合并
dws sheet unmerge-cells --node <NODE_ID> --sheet-id <SHEET_ID> \
--range "A1:C1" --format json
```
### 查找/替换
```bash
# 查找
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> \
--find "TODO" --match-case false --format json
# 全局替换
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> \
--find "TODO" --replacement "DONE" --format json
```
### 筛选视图
```bash
# 创建筛选视图(范围必须包含表头行)
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
--name "未完成项" --range "A1:E100" --format json
# 返回的 filterViewId 用于后续 update/delete/criteria 操作
# 列出所有筛选视图
dws sheet filter-view list --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 给视图的某一列设置筛选条件(column 是相对视图范围首列的 0-based 偏移)
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> \
--filter-view-id <FV_ID> --column 2 \
--filter-criteria '{"conditions":[{"type":"TEXT_CONTAINS","values":["pending"]}]}' \
--format json
# 清除某列的筛选条件(不删除视图本身)
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> \
--filter-view-id <FV_ID> --column 2 --format json
# 删除整个筛选视图
dws sheet filter-view delete --node <NODE_ID> --sheet-id <SHEET_ID> \
--filter-view-id <FV_ID> --format json
```
### 写入图片
```bash
# 已有图片资源 ID 和 URL 后写入单元格
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--range "B2:B2" --resource-id <RES_ID> --resource-url <RES_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 <cmd> --help`
- 看 schema:`dws schema sheet.<tool_name>`
@@ -11,11 +11,11 @@
Usage: Usage:
dws devdoc article search [flags] dws devdoc article search [flags]
Example: Example:
dws devdoc article search --keyword "OAuth2 接入" --page 1 --size 10 dws devdoc article search --query "OAuth2 接入" --page 1 --size 10
Flags: Flags:
--keyword string 搜索关键词 (必填) --query string 搜索关键词 (必填)
--page string 页码 (默认 1) --page string 页码 (默认 1)
--size string 每页数量 (默认 10) --size string 每页数量 (默认 10)
``` ```
--- ---
File renamed without changes.
@@ -53,7 +53,7 @@ Usage:
Example: Example:
dws wiki space search --keyword "产品文档" --format json dws wiki space search --keyword "产品文档" --format json
dws wiki space search --keyword "技术方案" --limit 20 --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: Flags:
--keyword string 搜索关键词 (--type myWikiSpace 时可省略) --keyword string 搜索关键词 (--type myWikiSpace 时可省略)
--type string 知识库类型: myWikiSpace 时直接返回「我的文档」,省略则搜索组织知识库 --type string 知识库类型: myWikiSpace 时直接返回「我的文档」,省略则搜索组织知识库
@@ -80,7 +80,7 @@ Flags:
> - 仅支持 USER 类型。 > - 仅支持 USER 类型。
> - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。 > - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。
> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。 > - 操作者需具备知识库的 OWNER 或 MANAGER 权限。
> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,请改用 `dws doc permission add` 在节点级别授权。 > - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,节点级权限授权在开源 dws v1.0.30 暂不支持,请在钉钉客户端文档中通过「分享」设置。
### 修改知识库成员角色 ### 修改知识库成员角色
``` ```
@@ -117,7 +117,7 @@ Flags:
- 用户说"查看知识库/知识库详情" → `space get` - 用户说"查看知识库/知识库详情" → `space get`
- 用户说"我的知识库/知识库列表/有哪些知识库" → `space list` - 用户说"我的知识库/知识库列表/有哪些知识库" → `space list`
- 用户说"搜索知识库/找知识库" → `space search` - 用户说"搜索知识库/找知识库" → `space search`
- 用户说"我的文档/个人空间" → `space search --type myWikiSpace` 或 `space list --type myWikiSpace` - 用户说"我的文档/个人空间" → `space list --type myWikiSpace`
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--space` + `--user` + `--role`) - 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--space` + `--user` + `--role`)
- 用户说"修改某人在知识库的权限/调整成员角色" → `member update` - 用户说"修改某人在知识库的权限/调整成员角色" → `member update`
- 用户说"知识库有哪些成员/查看知识库成员" → `member list` - 用户说"知识库有哪些成员/查看知识库成员" → `member list`
@@ -125,8 +125,8 @@ Flags:
关键区分: 关键区分:
- wiki(知识库空间级管理:创建/查询/列出/搜索/成员管理) vs doc(文档内容级操作:搜索/读写/编辑/节点级权限) - wiki(知识库空间级管理:创建/查询/列出/搜索/成员管理) vs doc(文档内容级操作:搜索/读写/编辑/节点级权限)
- wiki space(知识库容器) vs drive(钉盘文件存储/上传/下载) - wiki space(知识库容器) vs drive(钉盘文件存储/上传/下载)
- **wiki member**(容器级,授权整个知识库)vs **doc permission**(节点级,授权单篇文档) - **wiki member**(容器级,授权整个知识库);节点级 `doc permission` 在开源 dws v1.0.30 不可用
- 「我的文档」**只能用** `doc permission`,不能用 `wiki member` - 「我的文档」当前开源版本不能通过 CLI 改权限;在钉钉客户端用「分享」设置
## 核心工作流 ## 核心工作流
@@ -141,7 +141,7 @@ dws wiki space list --type myWikiSpace --format json
dws wiki space search --keyword "产品" --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 dws wiki space create --name "新项目文档" --description "项目相关文档归档" --format json
File renamed without changes.
@@ -98,7 +98,7 @@ Step 3 → 按下方路由规则映射到对应产品
> axls vs xlsx 关键区分: > axls vs xlsx 关键区分:
> - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作) > - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作)
> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错) > - `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
### 示例 ### 示例
File renamed without changes.
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""
导出群聊消息到 JSON 文件(从指定时间点拉取)
用法:
python chat_export_messages.py \
--group <openconversation_id> \
--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 '<CONV_ID>'
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 '<CONV_ID>',
'--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()
@@ -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 <userId> --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 <userId> 拉取单聊消息
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 '<USER_ID>'
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 '<USER_ID>',
'--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()
+181
View File
@@ -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 '<NODE_ID>',
'--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 '<NODE_ID>',
'--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()
File renamed without changes.
@@ -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 <uuid> # 指定听记
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', '<TASK_UUID>', '--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()
@@ -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', '<TASK_UUID>', '--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()
File renamed without changes.
File renamed without changes.
+35
View File
@@ -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.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws <cmd> --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`
@@ -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 <taskId>
Flags:
--task-id string AI 应用任务 ID (必填)
```
### 修改 AI 应用
```
Usage:
dws aiapp modify [flags]
Example:
dws aiapp modify --prompt "改为翻译应用" --thread-id <threadId>
dws aiapp modify --prompt "新描述" --thread-id <threadId> --skills s1,s2
dws aiapp modify --prompt "根据新图片优化首页视觉风格" --thread-id <threadId> --attachments '[{"name":"homepage.png","type":"image/png","url":"https://tmp/homepage.png"}]'
Flags:
--prompt string 新的 prompt (必填)
--skills string 技能 ID 列表,逗号分隔
--thread-id string threadId (必填)
--attachments string 附件对象数组的 JSON 字符串;当前 modify 仅支持图片附件,不支持 Excel
```
## 三个动作的关系
`aiapp` 的三个动作不是彼此独立的,它们描述的是同一个应用任务的生命周期:
1. `create` 用来发起一次新的应用生成任务,返回 `taskId` 和 `threadId`
2. `query` 用来跟踪这次任务是否完成,以及读取生成结果
3. `modify` 用来基于同一个 `threadId` 继续和已生成的应用对话,本质上是对已有应用做增量修改
可以把它理解为:
- `create` = 新建一个应用会话
- `query` = 查看这个会话对应任务的执行状态和结果
- `modify` = 在这个会话里继续追加新的需求,让同一个应用继续演化
关键标识符的关系:
- `taskId` 表示某一次具体的执行任务,主要给 `query` 使用
- `threadId` 表示这个应用的持续会话,主要给 `modify` 使用
- 一次 `create` 会产生一个新的 `taskId`,同时也会产出或确认一个 `threadId`
- 后续每次 `modify` 都是在同一个 `threadId` 上发起新的任务,因此也会产生新的 `taskId`
因此推荐把它们看成一条闭环链路,而不是三个平级命令:
`create -> query -> modify -> query -> modify ...`
## `attachments` 的含义
`attachments` 是 `aiapp` 的输入材料参数,用来把文件和当前任务一起提交给 `aiapp`。
它适合这些场景:
- 根据 Excel 创建应用
- 根据需求文档、表格、截图继续完善应用
- 在修改已有应用时补充新的结构化材料
可以把它理解为:
- `prompt` 描述“希望系统做什么”
- `attachments` 提供“系统可直接参考的文件材料”
### 正确心智
- `attachments` 是 `create` 或 `modify` 的补充输入,不是独立动作
- 即使提供了附件,主动作仍然是 `aiapp create` 或 `aiapp modify`
- 当用户说“按这个 Excel 建一个应用”时,优先走 `aiapp`,并把 Excel 作为 `attachments` 传入
### 数据结构
`attachments` 不是单个字符串,而是对象数组。每个对象当前按以下结构传递:
```json
[
{
"name": "warehouse.xlsx",
"type": "excel",
"url": "https://tmp/warehouse.xlsx",
"size": 102400
}
]
```
每个附件对象当前可用字段:
- `name`:文件名
- `type`:文件类型或 MIME 类型
- `url`:可直接访问的临时下载地址
- `size`:文件大小,可选
如果要传多个附件,就在数组中放多个对象。
### Excel 场景
如果用户上传了 Excel,通常应该:
- 在 `prompt` 里明确写“根据附件中的 Excel 创建应用”或“根据附件继续修改应用”
- 同时把 Excel 通过 `attachments` 传给 `aiapp`
这类需求都应优先视为 `aiapp`:
- “根据这个 Excel 建一个库存管理应用”
- “把附件里的表格做成一个 CRM 应用”
- “按这个 Excel 继续补充刚才那个应用”
### 使用规则
- `create` 可以带图片、Excel 等附件
- `modify` 当前只建议带图片附件,不支持 Excel
- `query` 不使用 `attachments`
- 附件应按对象数组传递,而不是传单个 ID 或随意文本
- 如果用户提供本地文件 + 任意意图 → 先上传文件获取 URL,再执行对应命令
- 每个附件对象通常至少应包含可访问的临时下载地址 `url`
- 如果有附件但没有特别完整的 prompt,也仍然建议补一句明确意图,避免模型误判
### 示例
```bash
# 根据 Excel 创建应用
dws aiapp create --prompt "根据附件里的 Excel 创建一个仓库管理应用" \
--attachments '[{"name":"warehouse.xlsx","type":"excel","url":"https://tmp/warehouse.xlsx"}]' \
--format json
# 在已有应用上继续结合图片修改
dws aiapp modify --prompt "根据新图片优化首页布局和视觉风格" \
--thread-id <threadId> \
--attachments '[{"name":"homepage.png","type":"image/png","url":"https://tmp/homepage.png"}]' \
--format json
```
## `--skills` 的含义
`--skills` 是 `aiapp` 的附加能力参数,不是一个独立动作。
它的作用不是“先执行这些技能,再单独执行 `aiapp`”,而是:
- 在 `create` 时,把这些官方技能作为本次应用生成任务的附加约束或附加能力一起传入
- 在 `modify` 时,把这些官方技能作为本次修改任务的附加约束或附加能力一起传入
可以把它理解为:
- `prompt` 描述“你想做什么应用”
- `--skills` 描述“这次生成或修改时,要额外挂载哪些官方能力或规范”
因此 `--skills` 必须依附于 `create` 或 `modify` 使用,本身不能单独完成创建应用。
### 正确心智
- `aiapp` 是主动作
- `--skills` 是主动作的附加参数
- 用户是否挂载官方技能,会影响应用生成或修改效果
- 即使用户提到了某个官方技能,最终仍然应该优先调用 `aiapp create` 或 `aiapp modify`
### 常见误区
不要把以下场景理解错:
- 用户说“创建一个应用,并遵循设计规范”
- 正确做法:调用 `aiapp create ... --skills <design-skill-id>`
- 不正确做法:把“设计规范技能”当成一个和 `aiapp` 平级、单独执行的动作
- 用户说“继续修改刚才那个应用,并带上设计规范”
- 正确做法:调用 `aiapp modify ... --thread-id <threadId> --skills <design-skill-id>`
- 不正确做法:先执行设计规范技能,再单独调用 `modify`
- 用户只是在描述“希望页面更漂亮、更标准”
- 如果已知对应的官方技能 ID,可以通过 `--skills` 传入
- 如果没有明确的技能 ID,不要编造,优先把需求写进 `prompt`
### 什么时候传 `--skills`
适合传 `--skills` 的情况:
- 用户明确要求遵循某个已知的官方能力、官方规范、官方模板
- 当前上下文里已经拿到了可用的官方技能 ID/Code
- 需要在创建或修改时显式挂载这些技能
不适合传 `--skills` 的情况:
- 没有明确的技能 ID
- 只是模糊地希望“更专业”“更好看”,但没有对应官方技能可挂
- 技能本身不是 `aiapp` 官方子技能,而是其他产品的独立操作能力
### 使用规则
- `create` 和 `modify` 都可以带 `--skills`
- `query` 不使用 `--skills`
- `--skills` 里的值必须是技能 ID,多个值用逗号分隔
- 不要猜测、编造技能 ID,必须来自已知上下文或系统返回
### 示例
```bash
# 创建应用,并挂载一个钉钉设计规范技能
dws aiapp create --prompt "创建一个仓库管理应用" \
--skills dingtalk_design_spec --format json
# 修改已有应用,并继续带上官方技能
dws aiapp modify --prompt "增加个库存大盘图表页" \
--thread-id <threadId> \
--skills dingtalk_design_spec --format json
```
## 意图判断
用户说以下任何一种,都走 `create`:
- “做个应用 / 创建应用 / 生成应用 / 搭个应用”
- “做个系统 / 平台 / 后台 / 工具 / 页面”
- “帮我做个计算器 / 仓库管理 / CRM / OA / 审批 / 报表 / 信息收集应用”
- “按这段需求生成一个钉钉应用”
用户说以下任何一种,都走 `query`:
- “查看应用状态”
- “这个应用创建好了没”
- “查一下 taskId 对应的进度”
- “继续轮询应用创建进展”
用户说以下任何一种,都走 `modify`:
- “修改应用”
- “更新刚才那个应用”
- “基于这个 thread/task 继续改”
- “把首页改一下 / 把功能补上 / 换个风格”
## 核心工作流
```bash
# 1. 创建 AI 应用 — 提取 taskId 和 threadId
dws aiapp create --prompt "创建一个天气查询应用" --format json
# 2. 查询应用状态
dws aiapp query --task-id <taskId> --format json
# 3. 修改应用
dws aiapp modify --prompt "新描述" --thread-id <threadId> \
--skills skill1,skill2 --format json
```
补充理解:
- 如果用户还没有现成应用,先走 `create`
- 如果用户刚发起了创建,通常先走 `query` 看是否完成
- 如果用户说“继续改刚才那个应用”,优先找上一次返回里的 `threadId`,走 `modify`
- `modify` 完成后,仍然可以继续用新的 `taskId` 走 `query`
- 不要把 `modify` 理解成“覆盖旧应用”,它更像是在同一个应用会话中追加新需求
- `attachments` 不改变 `create/query/modify` 的主流程,它只是给 `create` 或 `modify` 增加文件输入
- 如果附件是 Excel,优先理解为 `create` 场景;`modify` 当前不要用 Excel 附件
- `--skills` 不改变 `create/query/modify` 的主流程,它只是给 `create` 或 `modify` 增加官方附加能力
推荐决策顺序:
1. 先判断用户是不是要“生成一个新的应用/系统”
2. 如果是,直接用 `aiapp create`
3. 如果用户是在已有应用基础上继续改,用 `aiapp modify`
4. 如果只是查进度或结果,用 `aiapp query`
## 查询与轮询预期
`aiapp` 的创建和修改通常不是秒级任务,而是分钟级任务。
- 简单应用通常需要几分钟
- 复杂应用可能持续十几分钟到几十分钟
因此在使用 `query` 时,不要假设“连续几次没有完成”就代表任务卡死。
### 正确的轮询策略
- 不要每 5-10 秒高频轮询
- 建议以 30 秒为默认轮询间隔
- 如果任务已经明确进入长流程,可进一步放宽到 60 秒
- 除非用户明确要求实时刷新,否则不要自行创建过于频繁的定时查询
### 代理行为约束
当 agent 已经帮用户发起了 `aiapp create` 或 `aiapp modify` 后,不要只查询 1-2 次就把后续进度甩给用户自己处理。
- 只要任务仍处于 `queued` 或 `running`,且用户没有明确要求停止,agent 应继续主动跟踪进度
- 如果当前会话无法持续等待,可以创建定时任务继续查询,但定时任务必须是“以拿到最终结果为目标”的,而不是无条件永久轮询
- 如果返回了 `threadViewUrl`,可以告诉用户“你也可以点这个链接查看实时进度”,但这不能替代 agent 自己继续追踪任务
### 定时任务规则
如果 agent 选择创建定时任务来继续查询 `aiapp` 状态,必须同时设置结束条件:
- 当 `status = succeeded` 且已拿到 `appPreviewUrl` 时,立即停止后续查询,并主动删除或关闭该定时任务
- 当 `status = failed` 时,立即停止后续查询,并主动删除或关闭该定时任务
- 不要在已经拿到目标结果后继续保留定时任务
- 不要创建“只会查询、不会自终止”的定时任务
换句话说,定时任务应当是“查询直到成功或失败,然后自动结束”的一次性追踪任务,而不是长期后台轮询
### 如何理解 `query` 返回
`query` 的返回里,除了总状态外,还可能包含进度信息,例如:
- 当前正在执行哪个步骤
- 已完成多少步骤
- 最近一次活动时间
- 当前步骤列表
- 当前 thread 的访问链接
因此判断任务是否还在推进时,不要只看 `status` 是否还是 `running`,还要结合以下字段一起看:
- `updatedAt` / `lastActivityAt`
- `progress.summary`
- `progress.currentStep`
- `progress.steps`
- `threadViewUrl`
只要这些字段仍在变化,通常就说明任务仍在正常推进。
### `threadViewUrl` 与 `appPreviewUrl` 的区别
`query` 或 `create` 的返回里,可能会有两个不同的链接:
- `threadViewUrl`:当前 thread 的访问链接。用于打开 `aiapp` 站点里的会话/生成进度页面,适合在应用还没生成完成时查看实时状态
- `appPreviewUrl`:最终生成出的应用访问地址。只有应用创建成功后才会出现,适合真正打开应用去使用
不要把这两个链接混用:
- 任务还在 `queued/running` 时,优先使用 `threadViewUrl`
- 任务 `succeeded` 且结果里已有 `appPreviewUrl` 时,再使用 `appPreviewUrl`
### 何时停止轮询
- 当 `status = succeeded` 时,停止轮询,并从结果中提取 `threadId`、应用链接等信息
- 当 `status = failed` 时,停止轮询,并向用户报告失败
- 当任务长时间保持 `running`,但 `lastActivityAt` 和 `progress` 都没有变化时,再考虑提示可能卡住,而不是过早下结论
- 如果此前为查询进度创建了定时任务,在 `succeeded` 或 `failed` 后要主动删除该定时任务,而不是留给用户处理
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `create` | `taskId`, `threadId`, `threadViewUrl` | query 的 --task-id, modify 的 --thread-id,或直接打开进度页 |
| `query` | `threadViewUrl`, `appPreviewUrl`, 应用详情 | 查看进度页,或在成功后打开应用预览并决定是否 modify |
## 注意事项
- `create` 通过自然语言 prompt 描述应用功能,系统自动生成
- 对“应用/系统/工具/后台/原型”类需求,优先使用 `aiapp`,不要改用其他产品命令模拟实现
- 对“根据附件 / 根据 Excel / 根据上传表格生成应用”这类需求,也优先使用 `aiapp`
- `attachments` 需要按对象数组传递,当前结构是 `[{name,type,url,size}]`
- `create` 可使用 Excel 附件;`modify` 当前仅支持图片附件,不支持 Excel
- `--skills` 传入官方技能 UID 列表,逗号分隔,可增强应用能力
- `modify` 需要 `--thread-id` 来标识修改哪个会话线程
- 应用未生成完成前,如果需要让用户查看实时状态,优先使用返回里的 `threadViewUrl`
- 应用生成成功后,如果需要真正打开应用,再使用 `appPreviewUrl`
- 创建后可通过 `query` 轮询 `taskId` 查看创建进度,但轮询间隔默认应为 20-30 秒,不要 10 秒级频繁轮询
- agent 不应只查 1-2 次就放弃;如果任务仍在运行,应继续跟踪,或创建带终止条件的定时任务继续跟踪
- 如果 agent 创建了用于查询进度的定时任务,在任务成功或失败后必须主动删除或结束该定时任务
- 不要直接使用用户本地文件路径作为附件 URL,必须先上传转换为互联网可访问链接
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [aiapp_create_and_poll.py](../scripts/aiapp_create_and_poll.py) | 一键创建 AI 应用并自动轮询进度直到完成 | `python aiapp_create_and_poll.py --prompt "创建一个天气查询应用"` |
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
创建 AI 应用并自动轮询等待完成
用法:
python aiapp_create_and_poll.py \
--prompt "创建一个仓库管理应用"
python aiapp_create_and_poll.py \
--prompt "生成客户管理 CRM" \
--skills skill1,skill2 \
--interval 30 \
--timeout 600
python aiapp_create_and_poll.py --dry-run --prompt "test"
"""
import sys
import json
import subprocess
import argparse
import time
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=120
)
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 main():
parser = argparse.ArgumentParser(
description='创建 AI 应用并轮询等待完成'
)
parser.add_argument(
'--prompt', required=True, help='应用描述'
)
parser.add_argument(
'--skills', default='', help='技能 ID 列表'
)
parser.add_argument(
'--interval', type=int, default=30,
help='轮询间隔秒 (默认 30)',
)
parser.add_argument(
'--timeout', type=int, default=600,
help='最大等待秒 (默认 600)',
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
print(f'🚀 创建 AI 应用...')
print(f' Prompt: {args.prompt}')
cmd_args = [
'aiapp', 'create',
'--prompt', args.prompt,
'--format', 'json',
]
if args.skills:
cmd_args.extend(['--skills', args.skills])
create_data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
run_dws([
'aiapp', 'query',
'--task-id', '<TASK_ID>',
'--format', 'json',
], dry_run=True)
return
if not create_data:
sys.exit(1)
task_id = create_data.get('taskId') or create_data.get('id', '')
thread_id = create_data.get('threadId', '')
print(f" ✓ 任务已创建")
print(f" taskId: {task_id}")
print(f" threadId: {thread_id}")
print(f'\n⏳ 轮询等待 (间隔 {args.interval}s, '
f'超时 {args.timeout}s)...')
elapsed = 0
while elapsed < args.timeout:
time.sleep(args.interval)
elapsed += args.interval
query_data = run_dws([
'aiapp', 'query',
'--task-id', task_id,
'--format', 'json',
])
if not query_data:
print(f" [{elapsed}s] ⚠ 查询失败,继续等待...")
continue
status = (query_data.get('status')
or query_data.get('state', 'unknown'))
progress = query_data.get('progress', {})
step = ''
if isinstance(progress, dict):
step = progress.get('currentStep', '')
if status == 'succeeded':
print(f" [{elapsed}s] ✅ 应用创建成功!")
if thread_id:
print(f" threadId: {thread_id}")
return
elif status == 'failed':
print(f" [{elapsed}s] ❌ 创建失败")
sys.exit(1)
else:
info = f" [{elapsed}s] ⏳ {status}"
if step:
info += f" ({step})"
print(info)
print(f"\n⏰ 超时 ({args.timeout}s),任务可能仍在运行")
print(f" 可手动查询: dws aiapp query --task-id {task_id}")
if __name__ == '__main__':
main()
+48
View File
@@ -0,0 +1,48 @@
---
name: dingtalk-aisearch
description: AI 搜问 - 搜人首选入口(按姓名/部门/职位/职责/上下级/手机号/工号维度)。Use when 用户说 找同事/找人/谁负责XX/XX的负责人是谁/查上级/查下级/团队成员/XX工号是谁/XX手机号。Distinct from dingtalk-contact(精确按 userId 查详情)。命令前缀:dws aisearch。
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.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> 命令参考:[aisearch.md](references/aisearch.md)。
## 意图表
| 用户说 | 命令 |
|--------|------|
| "找张三 / 张三是谁" | `dws aisearch person --keyword "张三" --dimension name` |
| "谁负责 XX / XX 负责人是谁" | `dws aisearch person --keyword "<XX>" --dimension duty` |
| "张三的上级 / 下级" | `dws aisearch person --keyword "张三" --dimension supervisor`(或 `subordinate`) |
| "X 部门有哪些人" | `dws aisearch person --keyword "<部门>" --dimension department` |
| "工号 12345 是谁 / 138xxxx 手机号是谁" | `dws aisearch person --keyword "<工号>" --dimension jobNumber` / `dws aisearch person --keyword "<手机号>" --dimension phone` |
## 评测高频硬约束
- 搜索目标必须完整保真:姓名、工号、手机号、部门名按用户原文完整传入 `--keyword`,严禁自行截断、拆字、改昵称或扩展同音字。
- 首次未命中时最多换维度重试一次(如 name → department/jobNumber/phone),仍必须保留完整目标值;不要用半截姓名扩大搜索。
- 找到候选后,如用户要邮箱、部门、职位、主管等详情,必须切到 `dingtalk-contact` 执行 `contact user get --ids <userId> --format json` 补全。
- 多候选且无法唯一判断时输出候选并询问;不要默认取第一个,也不要编造未返回的人员信息。
- 所有 `dws aisearch` 命令加 `--format json`。
## 跨产品协作
- 拿到 userId 后查详情 / 部门 → 切到 `dingtalk-contact`
- 拿到 userId 发消息 → 切到 `dingtalk-chat`
- 拿到 userId 发 DING → 切到 `dingtalk-ding`
@@ -0,0 +1,74 @@
# aisearch — AI 搜问
> `aisearch` 模块**规范子命令是 `person`**,所有调用请使用 `dws aisearch person --keyword "..."`。
>
> **容错说明**(无需主动使用):CLI 兼容下列 alias 兜底,模型偶尔写 `search` / `find` / `query` / `contact` / `people` 等也能跑通——但**输出和文档以 `person` 为准**。
>
> keyword flag 同样兼容 `--name` / `--q` / `--query` / `--text` 兜底,规范用法是 `--keyword`。
## 企业人员搜索
通过关键词搜索企业内人员信息,支持按维度筛选。
```
Usage:
dws aisearch person [flags]
Example:
dws aisearch person --keyword "张三" --dimension name --format json
dws aisearch person --keyword "产品部" --dimension department --format json
dws aisearch person --keyword "五道" --dimension supervisor --format json
dws aisearch person --keyword "AI搜问" --dimension duty --format json
dws aisearch person --keyword "李四" --dimension name,department --format json
dws aisearch person --keyword "13800138000" --dimension phone --format json
dws aisearch person --keyword "W12345" --dimension jobNumber --format json
Flags:
--keyword string 搜索关键词 (必填,如人名、技能关键词等)
--dimension string 查询维度,多个用逗号分隔 (默认 "all")
```
### dimension 可选值
| 值 | 含义 | 触发词 |
|----|------|--------|
| `all` | 全部维度(默认) | — |
| `name` | 姓名 | "叫什么"、"是谁" |
| `department` | 部门 | "部门"、"团队"、"哪个部门" |
| `position` | 职位 | "职位"、"岗位"、"职级" |
| `duty` | 职责/技能 | "负责什么"、"职责"、"技能"、"负责人" |
| `supervisor` | 上级 | "上级"、"领导"、"主管" |
| `subordinate` | 下级 | "下级"、"下属"、"团队成员" |
| `phone` | 手机号 | "手机号是多少"、"电话"、"联系方式" |
| `jobNumber` | 工号 | "工号"、"工号是多少"、"员工编号" |
### keyword 提取规则
仅填入实际的搜索目标(人名、技能关键词等),不包含查询维度词。维度词必须映射到 `--dimension`:
| 用户说 | keyword | dimension |
|--------|---------|-----------|
| "五道的上级是谁" | 五道 | supervisor |
| "张三负责什么" | 张三 | duty |
| "AI搜问的负责人是谁" | AI搜问 | duty |
| "产品部有谁" | 产品部 | department |
| "李四是哪个部门的" | 李四 | department |
| "13800138000是谁" | 13800138000 | phone |
| "工号W12345是谁" | W12345 | jobNumber |
---
## 意图判断
- 用户说"搜人/找人/谁负责/上级是谁/哪个部门的人" → `aisearch person`
- 用户说"搜同事/查部门/查通讯录" → `contact`(通讯录)
**关键区分**:`aisearch person`(AI 语义搜人,支持按维度筛选:职责/手机号/上级/下级等)vs `contact`(通讯录精确查询:userId/部门成员列表)
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `aisearch person` | `userId`(用户ID)、`title`(姓名) | 展示搜索结果、后续操作(发消息/建待办等) |
## 重名消歧
> **CAUTION:** 多人同名时禁止默认选第一个 — 须追加 `contact user get --ids userId1,userId2,...` 获取部门/职位后请用户确认。详见 `dingtalk-contact/references/08-directory.md`「多命中」。
+62
View File
@@ -0,0 +1,62 @@
---
name: dingtalk-aitable
description: 钉钉 AI 表格(多维表)。Use when 用户说 AI表格/多维表/数据表/base/table/建表/查记录/写数据/字段/记录增删改查/筛选/排序/公式/模板搜索/批量导入CSV或JSON/导出/仪表盘/图表/上传附件到表格/按字段类型建表。Distinct from 主 dws skill 的 dws sheet(电子表格/单元格读写/公式)、dws doc(文档编辑)。命令前缀:dws aitable。
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 root `dws` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> 命令参考:[aitable.md](references/aitable.md);复杂命令按需加载 `references/aitable/*.md`;剧本:[06-data-analytics.md](references/06-data-analytics.md)。
## 意图表
| 用户说 | 命令 |
|--------|------|
| "搜表格 / 找一个 base" | `dws aitable base search --query "<名>"` |
| "创建 AI 表格 / 多维表" | `dws aitable base create --name "<名称>" [--template-id <id>]` |
| "查数据表 / 建数据表" | `dws aitable table get --base-id <baseId>` / `dws aitable table create --base-id <baseId> --name "<表名>" --fields '[...]'` |
| "查字段 / 字段类型" | `dws aitable field get --base-id <id> --table-id <id>` |
| "查记录 / 搜索记录" | `dws aitable record query --base-id <baseId> --table-id <tableId> [--filters '...']` |
| "写记录 / 更新记录 / 删除记录" | `dws aitable record create/update/delete --base-id <baseId> --table-id <tableId> ...` |
| "筛选 / 排序 / 公式 / 跨表引用" | 先读 `references/aitable/aitable-filter-sort.md` / `aitable-formula-guide.md` |
| "批量导入 JSON / CSV" | `python scripts/import_records.py <baseId> <tableId> data.csv\|data.json` |
| "批量加字段" | `python scripts/bulk_add_fields.py --base-id <id> --table-id <id> --fields fields.json` |
| "导入 / 导出表格" | 先读 `references/aitable/aitable-export-import.md`;导出优先 `python scripts/aitable_export_via_task.py <baseId> --scope table --table-id <tableId>` |
| "仪表盘 / 图表" | 先读 `references/aitable/aitable-dashboard-chart.md` |
| "上传附件到记录" | 先读 `references/aitable/aitable-attachment.md`;可用 `python scripts/upload_attachment.py --base-id <id> --file <path>` |
## 危险操作
`base delete` / `table delete` / `field delete` / `record delete` 不可逆,必须先向用户确认再加 `--yes`。
## 评测高频硬约束
- 创建/改字段/写记录是多轮连续任务时,不能在"让我执行/先获取 ID"后停下;必须实际调用对应 `dws aitable` 命令并验证结果。
- 字段重命名使用 `dws aitable field update --base-id <baseId> --table-id <tableId> --field-id <fieldId> --name "<新名称>" --format json`;先 `field get` 找真实 `fieldId`,不要猜字段名能直接更新。
- 写记录前必须 `field get` 获取 `fieldId` 与类型;`record create/update` 的 `cells` key 用 `fieldId`,不是字段中文名。长 JSON 使用 `--records-file`。
- 表或字段创建返回名称被系统自动加后缀时,后续必须使用返回的真实 `tableId`/`fieldId`,不要继续按原名称猜。
- `record update/delete` 先 `record query/list` 定位 `recordId`;删除必须确认,普通新增/更新按用户明确要求可直接执行后读回验证。
- `record query/create/update/delete`、`field create`、导入导出、图表和附件场景必须先读对应 `references/aitable/*.md`,不要凭旧单文件参数猜 flag。
## 字段类型规则
详见本 skill 的 [field-rules.md](references/field-rules.md)。
## 跨产品协作
- 单元格 / 工作表 / 公式 → 走主 `dws` skill 的 `sheet` 产品路由(`dws sheet`)
@@ -0,0 +1,11 @@
# 数据分析
> 本场景所有 recipe 均为 full。
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId`<br>3. `aitable record query --base-id <baseId> --table-id <tableId>` → 取记录(分页)<br>4. 总结数据 |
| generate-data-report | 1. 同 read-aitable 步骤 1-3<br>2. 按「多源并行采集」(见 root `dws` skill `references/best_practices/_common/conventions.md`)执行 → 补充背景<br>3. `doc create --name "<报告名>" --content "<分析报告>"` |
| create-aitable-record | **批量导入优先**:`python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(自动分批创建)<br>单条/少量:1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId` 与类型<br>3. `aitable record create --base-id <baseId> --table-id <tableId> --records '[{"cells":{"<fieldId>":"值"}}]'` |
| update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable record query --base-id <baseId> --table-id <tableId>` → 取 `recordId`,**先展示让用户确认**<br>3. `aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<recordId>","cells":{...}}]'` |
| search-aitable-template | 1. `aitable template search --query "<关键词>"` → 取 `templateId`<br>2. 用户选定<br>3. `aitable base create --name "<表格名>" --template-id <templateId>` → 取 `baseId` |
@@ -0,0 +1,203 @@
# AI表格 (aitable) 命令参考
> **渐进式文档**:本文件为路由层(索引 + 意图判断),各命令的详细参数、示例和踩坑说明在 [aitable/](./aitable/) 目录下按需加载。
## 文档地址 (URI)
| 资源 | URI 格式 |
|------|----------|
| Base 文档 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` |
| 模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` |
> **操作后请返回文档 URI**:每次执行 base list/search/create/get 操作后,从返回数据中提取 `baseId`,拼接为 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 返回给用户。
> 补充:如果 URL 不是来自 `aitable` 命令返回,而是用户直接贴的原始 `alidocs` URL,先按 root `dws` skill `references/url-patterns.md` 的「alidocs URL 类型探测流程」probe,确认是 `able` 后再按 AI 表格处理。
## 命令索引表
### base (Base 管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` |
| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 |
| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID |
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId |
| `base update` | 更新 Base 名称 | `--base-id` `--name` | — |
| `base delete` | 删除 Base | `--base-id` | 不可逆 |
| `base get-primary-doc-id` | 获取主键文档 ID | `--base-id` `--table-id` `--record-id` | 文档类型主键字段场景 |
| `base copy` | 复制 Base | `--base-id` | — |
### table (数据表管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `table get` | 获取表结构(字段+视图目录) | `--base-id` | 不传 `--table-ids` 返回全部表 |
| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 |
| `table update` | 重命名表 | `--base-id` `--table-id` `--name` | — |
| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 |
### field (字段管理) → 详见 [aitable-field.md](./aitable/aitable-field.md)、[field-properties](./aitable/aitable-field-properties.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `field get` | 获取字段完整配置 | `--base-id` `--table-id` | 按需展开少量字段 |
| `field create` | 创建字段 | `--base-id` `--table-id` + (`--name --type` 或 `--fields`) | 支持单字段/批量模式 |
| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 |
| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 |
### record (记录管理)
| 命令 | 用途 | 必读 reference | 路由提醒 |
|------|------|----------------|----------|
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference |
| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 |
| `record update` | 更新记录 | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;**没有** `--record-id` `--cells` flag |
| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 |
### view (视图管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `view get` | 获取视图配置 | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner |
| `view update` | 更新视图(**调整字段顺序的入口**) | `--base-id` `--table-id` `--view-id` | `visibleFieldIds` 重排字段顺序 |
| `view delete` | 删除视图 | `--base-id` `--table-id` `--view-id` | 不可删最后一个/锁定视图 |
> **"移动字段/调整字段顺序"** 在 AI 表格里没有 `field reorder` 命令,必须通过 `view update --config '{"visibleFieldIds":[...]}'` 完成。
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
| 命令 | 用途 |
|------|------|
| `dashboard get/create/update/delete` | 仪表盘管理 |
| `dashboard config-example` | 查看仪表盘配置模板 |
| `chart get/create/update/delete` | 图表管理 |
| `chart widgets-example` | 查看图表 widgets 配置模板 |
### export & import → 详见 [aitable-export-import.md](./aitable/aitable-export-import.md)
| 命令 | 用途 |
|------|------|
| `export data` | 导出数据(异步两阶段轮询) |
| `import upload` | 申请文件导入上传凭证 |
| `import data` | 触发导入 |
### attachment → 详见 [aitable-attachment.md](./aitable/aitable-attachment.md)
| 命令 | 用途 | 路由提醒 |
|------|------|----------|
| `attachment upload` | 准备附件上传凭证 | 不要用钉盘 drive 上传! |
### template (模板搜索)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `template search` | 搜索模板 | `--query` |
## 意图判断
用户说"表格/多维表/AI表格":
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
- 详情 → `base get`
- 创建 → `base create`
- 修改 → `base update`
- 删除 → `base delete`
用户说"数据表/子表/table":
- 查看 → `table get`
- 创建 → `table create`
- 重命名 → `table update`
- 删除 → `table delete`
用户说"字段/列/column":
- 查看 → `field get`
- 添加 → `field create`(读 [aitable-field.md](./aitable/aitable-field.md))
- 修改 → `field update`
- 删除 → `field delete`
用户说"记录/行/数据/row":
- 查看/搜索 → `record query`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 添加/写入 → `record create`(读 [aitable-record-create.md](./aitable/aitable-record-create.md))
- 修改/更新 → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md))
- 删除 → `record delete`
用户说"筛选/过滤/filter" → 读 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md)
用户说"统计/分析/聚合/TOP N/全量" → 读 [aitable-data-analysis-sop.md](./aitable/aitable-data-analysis-sop.md)
用户说"公式/formula/计算字段/派生指标" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)
用户说"查找引用/lookup/filterUp/跨表" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)(§5.4 跨表引用)
用户说"仪表盘/图表/chart" → 读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
用户说"附件/上传文件" → 读 [aitable-attachment.md](./aitable/aitable-attachment.md)
用户说"导入/导出/import/export" → 读 [aitable-export-import.md](./aitable/aitable-export-import.md)
用户说"模板" → `template search`
命令报错/操作失败 → 读 [aitable-error-recovery.md](./aitable/aitable-error-recovery.md)
**关键区分**: base=表格文件, table=数据表, field=列, record=行
## 核心工作流
```bash
# 1. 搜索/列出 Base — 提取 baseId
dws aitable base search --query "项目" --format json
# 2. 获取 Base 信息 — 提取 tableId
dws aitable base get --base-id <BASE_ID> --format json
# 3. 获取表结构 — 提取 fieldId
dws aitable table get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 4. 查询记录
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 5. 新增记录 (cells 用 fieldId 作 key)
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldXXX":"值"}}]' --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
| `base create` | `baseId` | 后续命令 + 文档 URI |
| `base get` | `tables[].tableId` | --table-id |
| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete |
| `record query` | `recordId` | record update/delete |
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
## URL → baseId 提取
用户提供 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 链接时:
1. 提取 `/nodes/` 后的路径段作为 `baseId`
2. 去掉尾部的查询参数(`?` 及其后内容)
3. 传入 `--base-id` 参数
> 如果该 URL 来自 `dws aitable` 返回或已在当前链路 probe 过,可直接复用;
> 如果是用户直接提供的原始 `alidocs` URL,则先按 root `dws` skill `references/url-patterns.md` 的「alidocs URL 类型探测流程」probe,确认 `extension=able` 后再继续。
## 注意事项
- 所有操作使用 ID(baseId/tableId/fieldId/recordId),不使用名称
- records 的 cells key 是 fieldId,不是字段名称
- cells 写入/读取格式见 [aitable-cell-value.md](./aitable/aitable-cell-value.md)
- 最佳实践见 [aitable-best-practices.md](./aitable/aitable-best-practices.md)
## 自动化脚本
| 脚本 | 场景 |
|------|------|
| [bulk_add_fields.py](../scripts/bulk_add_fields.py) | 批量添加字段 |
| [import_records.py](../scripts/import_records.py) | 从 JSON/CSV 批量导入记录 |
| [aitable_export_via_task.py](../scripts/aitable_export_via_task.py) | 文件导出(export_data 轮询 + 下载) |
| [upload_attachment.py](../scripts/upload_attachment.py) | 上传附件到 AI 表格记录 |
## 相关产品
- `dingtalk-doc` — 富文本文档编辑,不是结构化数据表格
@@ -0,0 +1,47 @@
# attachment — 附件上传
> **STOP — 不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。必须使用以下流程。
## 准备附件上传
```
Usage:
dws aitable attachment upload [flags]
Example:
dws aitable attachment upload --base-id <BASE_ID> --file-name report.xlsx --size 204800
dws aitable attachment upload --base-id <BASE_ID> --file-name photo.png --size 1024 --mime-type image/png
Flags:
--base-id string Base ID (必填)
--file-name string 文件名,必须含扩展名 (必填)
--size int 文件大小(字节),>0 (必填)
--mime-type string MIME type(不传时根据扩展名推断)
```
## 附件上传完整流程(推荐:使用脚本,2 步完成)
```bash
# 步骤 1: 使用脚本一键上传(内部自动完成 prepare + PUT)
python3 scripts/upload_attachment.py <BASE_ID> /path/to/report.pdf
# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
# 步骤 2: 在 record create/update 中使用 fileToken 写入
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
```
> `uploadUrl` 有时效性(`expiresAt`),脚本会自动在获取后立即上传。
## 手动流程(不使用脚本)
```bash
# 1. 获取上传凭证
dws aitable attachment upload --base-id <BASE_ID> --file-name report.pdf --size 204800 --format json
# → 返回 uploadUrl、fileToken
# 2. PUT 上传(Content-Type 留空)
curl -X PUT "<uploadUrl>" -H "Content-Type:" --data-binary @report.pdf
# 3. 写入记录
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"recXXX","cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
```
@@ -0,0 +1,46 @@
# AI 表格最佳实践
## 1. 字段可写性分类
| 字段类型 | 可写 | 正确方式 |
|----------|------|----------|
| 文本/数字/日期/单选/多选/复选框/URL | ✅ | record create/update |
| 附件 | ⚠️ | 必须先走 [attachment upload 流程](./aitable-attachment.md) |
| 创建人/修改人/创建时间/修改时间 | ❌ | 系统字段,只读 |
| 公式/查找引用 | ❌ | 只读,由系统计算 |
| AI 字段 | ❌ | 只读,由 AI 自动计算 |
## 2. 查询执行契约
1. **不要拉全量后在 context 里手动统计** — 优先用 `--filters` 在服务端过滤
2. **has_more=true 时不能做全局结论** — 数据可能不完整
3. **优先用 `--filters` 在服务端过滤** — 不要拉全量后在本地 jq/grep
4. **字段名必须来自 `table get` 真实返回** — 不要猜测 fieldId
5. **减少响应体积** — 用 `--field-ids` 仅返回需要的字段
## 3. 任务选路
| 用户诉求 | 优先方案 | 不要误走 |
|---------|----------|----------|
| 查看几条数据 | `record query` | 不要用 `--all` |
| 全量拉取/统计 | `record query --all` | 不要手动循环 cursor |
| 全量导出为文件 | `export data` | 不要 `--all` 拉全量再写文件 |
| 批量写入 | `record create`(分批 100 条) | 不要一次传超过 100 条 |
| 附件上传 | `attachment upload` + `record update` | 不要在 cells 里伪造附件值 |
| 文件级导入 | `import upload` + `import data` | 不要手动解析 xlsx 再逐条写入 |
## 4. 创建/修改后回读确认
执行写操作后,建议立即回读确认结果:
| 写操作 | 建议回读命令 | 确认内容 |
|--------|-------------|----------|
| `table create` | `table get --table-ids <新tableId>` | 表名、字段列表是否符合预期 |
| `field create` | `table get --table-ids <tableId>` | 新字段是否出现在字段列表中 |
| `record create/update` | `record query --record-ids <新recordId>` | 写入值是否正确 |
## 5. AI 字段注意事项
- AI 字段的 prompt **必须至少包含一个 `fieldRef` 引用**,纯文本 prompt 会被后端拒绝
- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用
- `outputType` 必须与字段类型一致(如 `outputType=text` 配 `--type text`)
@@ -0,0 +1,342 @@
# cells 写入/读取格式规范(cellValue 数据结构)
> 适用命令:`dws aitable record create --records`、`dws aitable record update --records`、`dws aitable record query` 返回
>
> 本文件是 DWS AI 表格 cellValue 的 **source of truth**。写入记录时,必须严格按此格式构造 cells 对象。
## 顶层规则
- cells 的 key **必须是 fieldId**(如 `fldXXX`),不是字段名称
- fieldId 必须从 `table get` 返回中获取
- 不同字段类型的 value 格式不同,混用会报错
- 系统只读字段(creator/lastModifier/createdTime/lastModifiedTime/formula)不可写入
## 各字段类型详解
### text(文本)
**写入**:字符串
```json
{"fldTextId": "这是一段文本"}
```
**读取**:字符串
```json
{"fldTextId": "这是一段文本"}
```
---
### number(数字)
**写入**:数字或数字字符串
```json
{"fldNumId": 123.45}
{"fldNumId": "123.45"}
```
**读取**:字符串形式的数字
```json
{"fldNumId": "123.45"}
```
---
### singleSelect(单选)
**写入**:选项名称字符串(推荐),或对象形式 `{id, name}`
```json
{"fldSelectId": "进行中"}
{"fldSelectId": {"id": "opt_xxx", "name": "进行中"}}
```
> 写入不存在的选项名称时,系统会自动创建该选项。
> 对象写入时 id 为准,服务端会校验 id 是否存在。
**读取**:对象 `{id, name}`
```json
{"fldSelectId": {"id": "opt_abc123", "name": "进行中"}}
```
---
### multipleSelect(多选)
**写入**:选项名称数组(推荐),或对象数组
```json
{"fldMultiId": ["标签A", "标签B"]}
{"fldMultiId": [{"id": "opt_a", "name": "标签A"}, {"id": "opt_b", "name": "标签B"}]}
```
> 写入时每项需带 id(对象模式)或直接传 name 字符串。不存在的 name 会自动补入选项配置。
**读取**:对象数组
```json
{"fldMultiId": [{"id": "opt_a", "name": "标签A"}, {"id": "opt_b", "name": "标签B"}]}
```
---
### date(日期)
**写入**:日期字符串、RFC3339 字符串、或毫秒时间戳
```json
{"fldDateId": "2026-03-15"}
{"fldDateId": "2026-03-15 09:00"}
{"fldDateId": "2026-03-15T09:00+08:00"}
```
**读取**:RFC3339 字符串
```json
{"fldDateId": "2026-03-15T09:00:00+08:00"}
```
---
### currency(货币)
**写入**:数字(与 number 相同)
```json
{"fldCurrencyId": 99.5}
```
**读取**:字符串形式的数字(小数位数取决于 formatter 配置)
```json
{"fldCurrencyId": "99.5"}
```
---
### progress(进度)
**写入**:0~1 之间的浮点数(0 表示 0%,1 表示 100%)
```json
{"fldProgressId": 0.75}
```
> ⚠️ **常见错误**:写入 75 不会报错,但会被存储为 7500%(因为系统将其理解为 75 倍)。
> 正确做法:75% 应写入 0.75。API 不会拒绝超出 [0,1] 的值,但显示会异常。
> 如果字段配置了 `customizeRange`,则按自定义范围传值。
**读取**:字符串形式的数字
```json
{"fldProgressId": "0.75"}
```
---
### rating(评分)
**写入**:整数,必须在字段配置的 min~max 范围内
```json
{"fldRatingId": 4}
```
> ⚠️ 超出 max 范围的值(如 max=5 时写入 6)会被服务端拒绝并返回错误。
**读取**:数字(字符串形式)
```json
{"fldRatingId": "4"}
```
---
### checkbox(勾选)
**写入**:布尔值
```json
{"fldCheckId": true}
{"fldCheckId": false}
```
**读取**:布尔值
```json
{"fldCheckId": true}
```
---
### user(人员)
**写入**:对象数组,每项必须含 `userId` 和 `corpId`
```json
{"fldUserId": [{"userId": "staff_001", "corpId": "dingxxxxxxxx"}]}
```
> 单选字段(`multiple=false`)也必须传数组,只是数组长度为 1。
> 如果目标用户不在当前请求组织内,回退为 `[{"userRef": "ur_0AaZ19"}]`。
**读取**:对象数组
```json
{"fldUserId": [{"userId": "staff_001", "corpId": "dingxxxxxxxx"}]}
```
---
### department(部门)
**写入**:对象数组,每项含 `deptId`
```json
{"fldDeptId": [{"deptId": "52528700"}]}
```
**读取**:对象数组
```json
{"fldDeptId": [{"deptId": "52528700"}]}
```
---
### group(群组)
**写入**:对象数组,每项含 `cid`
```json
{"fldGroupId": [{"cid": "74577067501"}]}
```
> ⚠️ key 是 **`cid`**,不是 `openConversationId`
**读取**:对象数组
```json
{"fldGroupId": [{"cid": "74577067501"}]}
```
---
### url(链接)
**写入**:对象 `{text, link}` 或纯 URL 字符串
```json
{"fldUrlId": {"text": "钉钉官网", "link": "https://dingtalk.com"}}
{"fldUrlId": "https://dingtalk.com"}
```
> 纯字符串写入时,服务端自动补齐为 `{"text":"原字符串","link":"原字符串"}`
**读取**:对象 `{text, link}`
```json
{"fldUrlId": {"text": "钉钉官网", "link": "https://dingtalk.com"}}
```
---
### richText(富文本)
**写入**:对象 `{markdown: "..."}`
```json
{"fldRichId": {"markdown": "**加粗**\n普通文字\n"}}
```
**读取**:对象 `{markdown: "..."}`(有损,颜色/@人等信息可能丢失)
```json
{"fldRichId": {"markdown": "**加粗**\n普通文字\n"}}
```
---
### attachment(附件)
**写入**:对象数组,支持 `fileToken` 或 `url` 形式
```json
{"fldAttachId": [{"fileToken": "ft_xxx"}]}
{"fldAttachId": [{"url": "https://example.com/file.pdf"}]}
```
> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 获取 `fileToken`**。
> URL 形式是 best-effort 异步转存,不保证立即可用。
> 写入会**整体覆盖**原附件列表,不是追加。
**读取**:对象数组(含下载链接、文件名、大小)
```json
{"fldAttachId": [{"url": "https://...", "filename": "report.pdf", "size": 204800}]}
```
---
### telephone / email / barcode / idCard(电话/邮箱/条码/身份证)
**写入**:字符串
```json
{"fldPhoneId": "13800138000"}
{"fldEmailId": "test@example.com"}
{"fldBarcodeId": "978-3-16-148410-0"}
{"fldIdCardId": "520402196001067498"}
```
> idCard 必须是后端认可的合法身份证号格式
**读取**:字符串
```json
{"fldPhoneId": "13800138000"}
```
---
### geolocation(地理位置)
**写入**:对象,包含 `address`、`name`、`location`
```json
{
"fldGeoId": {
"address": "浙江省杭州市思凯路与爱橙街交叉口东南200米",
"name": "阿里中心·未科D1幢",
"location": ["120.007852", "30.271194"]
}
}
```
> `location` 按 **[经度, 纬度]** 传**字符串数组**
**读取**:对象(含额外的 `fullAddress` 字段,由服务端自动拼接)
```json
{
"fldGeoId": {
"address": "浙江省杭州市",
"fullAddress": "阿里中心-浙江省杭州市",
"name": "阿里中心",
"location": ["120.007852", "30.271194"]
}
}
```
---
### unidirectionalLink / bidirectionalLink(关联字段)
**写入**:对象 `{linkedRecordIds: [...]}`
```json
{"fldLinkId": {"linkedRecordIds": ["recXXX", "recYYY"]}}
```
**读取**:对象 `{linkedRecordIds: [...]}`
```json
{"fldLinkId": {"linkedRecordIds": ["recXXX", "recYYY"]}}
```
---
### 只读字段(禁止写入)
以下字段类型由系统自动填充,`record create/update` 时**禁止传入**:
| 类型 | 说明 |
|------|------|
| `creator` | 创建人 |
| `lastModifier` | 最后编辑人 |
| `createdTime` | 创建时间 |
| `lastModifiedTime` | 最后编辑时间 |
| `formula` | 公式字段(系统计算) |
| AI 字段 | 由 AI 自动计算 |
## 常见错误速查
| 错误 | 正确做法 |
|------|----------|
| cells key 用字段名称 `"课程名称"` | 用 fieldId `"fldXXX"` |
| progress 写入 `75` | 写入 `0.75`(范围 0~1) |
| attachment 直接传文件路径 | 必须先 upload 获取 fileToken |
| user 字段传用户名字符串 | 传对象数组 `[{"userId":"...", "corpId":"..."}]` |
| group 字段用 `openConversationId` | 用 `cid` |
| singleSelect 传 option id 字符串 | 传 name 字符串或 `{"id":"...", "name":"..."}` 对象 |
| 对只读字段写入值 | 不传该字段,由系统自动填充 |
@@ -0,0 +1,43 @@
# dashboard & chart — 仪表盘与图表
## 建议操作顺序
```bash
# 1) 先看配置模板(JSONC)
dws aitable dashboard config-example --format json
dws aitable chart widgets-example --format json
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
```
## 要点
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断
## dashboard 子命令
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `dashboard get` | 获取仪表盘详情(含 charts 列表) | `--base-id` `--dashboard-id` | — |
| `dashboard create` | 创建仪表盘 | `--base-id` + (`--config` 或 `--name`) | `--name` 简化版创建空看板;`--config` 传完整 JSON |
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
## chart 子命令
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` |
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` |
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 |
## 配置获取流程
创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置。
@@ -0,0 +1,130 @@
# AI 表格数据分析 SOP
> 当用户诉求涉及查询、筛选、排序、统计、Top/Bottom N、分组聚合、判断全局结论时,必须先读本文档再执行。
## 1. 查询决策树
```
用户要做什么?
│
├─ 查看/导出原始记录明细
│ → record query [--filters] [--sort] [--field-ids] [--limit]
│
├─ 按条件筛选记录(如"状态=进行中的记录")
│ → record query --filters '{"operator":"and","operands":[...]}'
│
├─ 取 Top N / Bottom N(如"销售额最高的5条")
│ → record query --sort '[{"fieldId":"xxx","direction":"desc"}]' --limit 5
│
├─ 全量统计(如"一共多少条"、"所有记录的总销售额")
│ → record query --all --field-ids <目标字段>
│ → 本地计算 count / sum / avg
│
├─ 分组统计(如"每个状态各有多少条")
│ → record query --all --field-ids <分组字段>,<度量字段>
│ → 本地按分组字段 groupby 再聚合
│
└─ 判断全局结论(如"是否所有记录都满足条件")
→ record query --all(或用 --filters 反向筛选不满足的)
→ 基于全量结果判断
```
## 2. 核心规则
### 2.1 禁止基于默认分页下全局结论
`record query` 默认返回 100 条。如果返回 JSON 中 `data.nextCursor` 非空,表示还有后续数据,当前结果**不是全量**。
```json
{"data": {"nextCursor": "3hf5MtLbLZ", "records": [...]}}
```
- ❌ 错误:只查了默认 100 条就说"共 100 条记录"
- ✅ 正确:使用 `--all` 自动翻页拿全量,再统计
### 2.2 能在服务端过滤的,不要拉到本地再过滤
| 需求 | 正确做法 | 错误做法 |
|------|---------|---------|
| 筛选"状态=已完成" | `--filters '{"operator":"and","operands":[{"operator":"eq","operands":["fldXXX","已完成"]}]}'` | `--all` 拉全量再本地 filter |
| 按日期降序取最新5条 | `--sort '[...]' --limit 5` | `--all` 拉全量再本地 sort + slice |
| 模糊搜索标题含"Q1" | `--filters` 用 `contain` 操作符 | 全量拉取再本地 grep |
### 2.3 服务端无法完成时,才用 --all + 本地计算
以下场景服务端 filters/sort 无法满足,需要 `--all` 后本地处理:
- SUM / AVG / COUNT / MAX / MIN 聚合
- 分组统计(GROUP BY)
- 多字段联合计算(如"销售额 = 单价 × 数量")
- 去重计数(COUNT DISTINCT)
- 百分比/占比计算
### 2.4 --all 使用注意
```bash
dws aitable record query \
--base-id <baseId> \
--table-id <tableId> \
--all \
--field-ids <只取需要的字段> \
--format json
```
- **必须配合 `--field-ids`** 限制返回字段,减少数据量
- 对于大表(>1000条),先告知用户可能耗时
- `--all` 会自动处理分页,无需手动翻页
## 3. filters 快速参考
详细语法见 [aitable-filter-sort.md](./aitable-filter-sort.md)。
### 常用操作符速查
| 操作符 | 适用类型 | 含义 | 示例 operands |
|--------|---------|------|-------------|
| `eq` | 通用 | 等于 | `["fldXXX", "值"]` |
| `ne` | 通用 | 不等于 | `["fldXXX", "值"]` |
| `gt` / `lt` | 数值/日期 | 大于/小于 | `["fldXXX", "25"]` |
| `gte` / `lte` | 数值/日期 | 大于等于/小于等于 | `["fldXXX", "100"]` |
| `contain` | 文本 | 包含 | `["fldXXX", "关键词"]` |
| `exist` / `un_exist` | 通用 | 有值/为空 | `["fldXXX"]`(无第二参数) |
| `any_of` | 多选 | 包含任一 | `["fldXXX", "选项A"]` |
### filters 结构模板
```json
{
"operator": "and",
"operands": [
{"operator": "eq", "operands": ["<fieldId>", "<值>"]},
{"operator": "gt", "operands": ["<fieldId>", "<数值>"]}
]
}
```
## 4. 分析结果呈现规范
### 4.1 必须包含的信息
- **数据范围**:基于哪个表、哪些筛选条件、查询了多少条记录
- **计算方法**:用了什么聚合方式(sum/count/avg 等)
- **结果值**:精确到合理小数位
### 4.2 示例
> 基于「销售数据」表,筛选条件:日期 ≥ 2026-01-01,共查询到 342 条记录。
> - 总销售额:¥1,234,567.89(SUM)
> - 平均单价:¥3,610.46(AVG)
> - 最大单笔:¥89,000.00(MAX)
## 5. 任务选路心智模型
| 用户诉求 | 优先方案 | 不要误走 |
|---------|---------|---------|
| 一次性统计/临时分析 | `record query --all` + 本地聚合 | 不要创建 formula 字段 |
| 长期展示派生指标 | 创建 formula 字段(见 [formula-guide](./aitable-formula-guide.md)) | 不要每次手算再手动写入 |
| 按条件筛选记录 | `record query --filters` | 不要 `--all` 拉全量再本地 filter |
| 取最新/最大/前N | `--sort + --limit` | 不要 `--all` 再本地排序取前N |
| 关键词检索 | `record query --filters` 用 `contain` | 不要把表格当搜索引擎全文检索 |
| 验证"是否全部满足" | 反向 filters(筛不满足的),看是否有结果 | 不要 `--all` 逐条遍历 |
@@ -0,0 +1,128 @@
# AI 表格错误恢复指南
> 当 CLI 命令返回错误时,按本文档的映射表判断恢复动作。
## 1. 错误响应结构
```json
{
"status": "error",
"summary": "Failed to create records",
"trace_id": "2104a64c17790723347215232e085e"
}
```
- `status: "error"` 表示操作失败
- `summary` 包含错误摘要信息
- `trace_id` 用于问题追踪
## 2. 常见错误与恢复动作
### 2.1 记录操作错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| `Failed to create records` | cellValue 格式错误或字段类型不匹配 | 先 `field get` 确认字段类型,再按 [cell-value](./aitable-cell-value.md) 规范重构值 |
| `record not found` | record-id 不存在或已删除 | 用 `record query` 重新查询确认目标记录 |
| rating 字段写入超出 max | 值超出字段配置范围 | 检查字段 config 的 min/max,确保值在范围内 |
| singleSelect 写入对象格式但 id 不存在 | option id 无效 | 改用 name 字符串写入(推荐),或先 `field get` 获取有效 option id |
### 2.2 字段操作错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| `Failed to create field` | config 格式错误或必填项缺失 | 检查 [field-properties](./aitable-field-properties.md) 中该类型的必填 config |
| `field not found` | field-id 不存在 | 用 `table get` 获取最新字段列表 |
| formula 创建失败 | 公式语法错误或引用字段名不匹配 | 先 `field get` 确认字段精确名称,再检查公式语法(见 [formula-guide](./aitable-formula-guide.md)) |
| 删除主字段失败 | 主字段(第一列)不可删除 | 改为更新字段名或类型,不能删除 |
### 2.3 Base/Table 操作错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| `base not found` | base-id 错误或无权限 | 确认 base-id 正确;尝试 `base list` 或 `base search` 重新定位 |
| `table not found` | table-id 错误 | 用 `table get --base-id <baseId>` 不带 table-ids 查看所有表 |
| 表名重复 | 同 Base 下已存在同名表 | 系统会自动续号(如"原名 1"),无需额外处理 |
### 2.4 视图操作错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| `view not found` | view-id 错误 | 用 `view get --base-id <baseId> --table-id <tableId>` 查看所有视图 |
| 删除最后一个视图 | 表至少保留一个视图 | 不可删除唯一视图 |
### 2.5 filters/sort 错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| filters 无效被忽略 | 根节点不是 and/or,或 operands 格式错误 | 确保 filters 根节点是 `{"operator":"and"/"or", "operands":[...]}` 结构 |
| sort 无效 | fieldId 不存在 | 先 `table get` 确认字段 ID |
| 筛选结果为空 | 条件过严或字段值不匹配 | 放宽条件验证;注意 singleSelect 筛选值用 option name 或 id |
### 2.6 导入导出错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| 导出任务超时 | 数据量大,异步任务未完成 | 用 `export data --task-id <taskId>` 轮询直到完成 |
| 导入文件格式错误 | 不支持的文件格式或文件损坏 | 确认文件为 .xlsx 格式且未加密 |
## 3. 重试策略
### 3.1 可重试的错误
| 错误类型 | 重试方式 | 最大重试次数 |
|---------|---------|------------|
| 网络超时 / 5xx | 等待 2s 后原样重试 | 2 |
| 导出任务未完成 | 轮询 task-id | 5(间隔 3s) |
| 并发写入冲突 | 串行重试 | 1 |
### 3.2 不可重试的错误(立即停止)
| 错误类型 | 原因 | 处理方式 |
|---------|------|---------|
| 权限不足 / 403 | 用户对该 Base 无权限 | 停止操作,提示用户确认权限 |
| 参数格式错误 | 请求结构不合法 | 修正参数后重试,不要原样重试 |
| 资源不存在 / 404 | ID 错误或资源已删除 | 重新查询定位资源 |
| 配额超限 / 429 | API 调用频率过高 | 等待后重试,并降低并发 |
### 3.3 重试前检查清单
在重试前,先确认:
1. ❓ 错误是暂时性的还是永久性的?
2. ❓ 参数有没有明显错误需要修正?
3. ❓ 是否需要先查询最新状态再重试?
## 4. 调试技巧
### 4.1 使用 --verbose 获取详细信息
```bash
dws aitable record create \
--base-id <baseId> \
--table-id <tableId> \
--records '[...]' \
--verbose --format json
```
`--verbose` 会输出请求/响应的详细信息,帮助定位问题。
### 4.2 使用 --dry-run 预览
```bash
dws aitable record create \
--base-id <baseId> \
--table-id <tableId> \
--records '[...]' \
--dry-run --format json
```
`--dry-run` 只预览不执行,适合在不确定参数是否正确时先验证。
## 5. 错误预防最佳实践
1. **写记录前先读字段结构** — `field get` 或 `table get` 确认字段类型和 ID
2. **写字段前先读 field-properties** — 确认 config 的必填项和格式
3. **formula 字段先确认引用字段名** — `[字段名]` 必须精确匹配
4. **options 更新传完整列表** — 更新 singleSelect/multipleSelect 的 options 是全量覆盖
5. **大批量操作分批执行** — 单次最多 100 条记录
6. **使用 --format json** — 确保输出可解析,方便错误判断
@@ -0,0 +1,57 @@
# export & import — 导入导出
## 导出数据(两阶段轮询)
`export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
> ⚠️ **`--format` 冲突警告**:`export data` 的 `--format` 是**导出格式**(excel/attachment 等),不是全局输出格式。**此命令禁止追加全局 `--format json`**,否则会覆盖导出格式导致 `INVALID_EXPORT_FORMAT` 错误。输出默认就是 JSON,无需额外指定。
```bash
# 第一步:创建任务(按 scope 传必要参数)——注意:不要加 --format json!
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
```
### 参数约束
| scope | 必传参数 |
|-------|----------|
| `all` | 只需 `--base-id` |
| `table` | 必须 `--table-id` |
| `view` | 必须 `--table-id` + `--view-id` |
## 导入文件(三步流程)
当用户要求将 Excel(`.xlsx`)或 CSV 文件完整导入 AI 表格时,**不需要自己解析文件内容**,直接使用文件级导入。
> **无需手动解析 CSV/Excel 再逐条 record create**,效率极低且容易出错。
```bash
# 第 1 步:申请上传凭证
dws aitable import upload --base-id <BASE_ID> \
--file-name data.xlsx --file-size <字节数> --format json
# → 返回 uploadUrl 和 importId
# 第 2 步:上传文件到 OSS(注意:Content-Type 必须设为空)
curl -X PUT "<uploadUrl>" -H "Content-Type:" --data-binary @data.xlsx
# 第 3 步:触发导入
dws aitable import data --import-id <importId> --format json
# → 返回 status: success 和新建的 tableIds
```
### 步骤说明
| 步骤 | 命令 | 说明 |
|------|------|------|
| 申请上传凭证 | `import upload --base-id <ID> --file-name <名称> --file-size <字节>` | `--file-size` 必须与实际文件大小一致 |
| 上传文件 | HTTP PUT(curl 等) | **必须** 带 `-H "Content-Type:"` 将 Content-Type 设为空,否则 OSS 返回 403 |
| 触发导入 | `import data --import-id <ID>` | 同步等待,大多一次调用即返回结果;超时可用相同 importId 重试 |
### 适用/不适用场景
- **适用**:xlsx / csv 文件级别导入。导入后每个 Sheet 自动建表
- **不支持**:追加到已有表、选择部分字段导入
- **不适用**:需要写入已有表的特定列 → 应使用 `record create`
@@ -0,0 +1,238 @@
# 字段类型 config 规范(field create / table create / field update)
> 适用命令:`dws aitable field create`、`dws aitable table create --fields`、`dws aitable field update --config`
>
> 本文件是 DWS AI 表格字段 config 的 **source of truth**。创建/更新字段时,必须严格按此规范构造 JSON。
## 1. 顶层规则
- `table create --fields` 和 `field create --fields` 中每个字段对象:`{"fieldName":"xxx", "type":"xxx", "config":{...}}`
- `field create --name --type --config` 中 config 单独传 JSON 字符串
- `field update --config` 只传 config 部分
- 不需要 config 的类型(如 text、checkbox、attachment)可省略 config 字段
## 2. 字段类型速查
| type | 需要 config | config 核心字段 | 说明 |
|------|-------------|----------------|------|
| `text` | ❌ | — | 纯文本 |
| `number` | 可选 | `formatter` | 数字格式 |
| `singleSelect` | ✅ | `options` | 单选 |
| `multipleSelect` | ✅ | `options` | 多选 |
| `date` | 可选 | `formatter` | 日期格式 |
| `currency` | 可选 | `currencyType`, `formatter` | 货币 |
| `progress` | 可选 | `formatter`, `min`, `max`, `customizeRange` | 进度条 |
| `rating` | 可选 | `min`, `max`, `icon` | 评分 |
| `checkbox` | ❌ | — | 勾选框 |
| `user` | 可选 | `multiple` | 人员 |
| `department` | 可选 | `multiple` | 部门 |
| `group` | 可选 | `multiple` | 群组 |
| `url` | ❌ | — | 链接 |
| `richText` | ❌ | — | 富文本 |
| `telephone` | ❌ | — | 电话 |
| `email` | ❌ | — | 邮箱 |
| `attachment` | ❌ | — | 附件 |
| `geolocation` | ❌ | — | 地理位置 |
| `formula` | ✅ | `formula` | 公式(只读字段) |
| `unidirectionalLink` | ✅ | `linkedTableId`, `multiple` | 单向关联 |
| `bidirectionalLink` | ✅ | `linkedTableId`, `multiple` | 双向关联 |
| `creator` | ❌ | — | 系统字段:创建人(只读) |
| `lastModifier` | ❌ | — | 系统字段:最后编辑人(只读) |
| `createdTime` | ❌ | — | 系统字段:创建时间(只读) |
| `lastModifiedTime` | ❌ | — | 系统字段:最后编辑时间(只读) |
## 3. 各类型 config 详解
### 3.1 number(数字)
config 字段:`formatter`
可选值:
- `INT` — 整数
- `FLOAT_1` — 1 位小数
- `FLOAT_2` — 2 位小数(默认)
- `FLOAT_3` — 3 位小数
- `FLOAT_4` — 4 位小数
- `THOUSAND` — 千分位整数
- `THOUSAND_FLOAT` — 千分位 + 小数
- `PERCENT` — 百分比(整数)
- `PERCENT_FLOAT` — 百分比(小数)
```json
{"fieldName": "工时", "type": "number", "config": {"formatter": "FLOAT_2"}}
```
```json
{"fieldName": "完成率", "type": "number", "config": {"formatter": "PERCENT"}}
```
### 3.2 singleSelect / multipleSelect(单选 / 多选)
config 字段:`options`(必填)
options 结构:
- `options` 是数组,每项至少包含 `name`
- 创建时只传 `name`,`id` 由系统生成
- **更新时**:已有选项必须回传原 `id`(从 `field get` 获取),新增选项不传 id
```json
{
"fieldName": "优先级",
"type": "singleSelect",
"config": {
"options": [
{"name": "紧急"},
{"name": "高"},
{"name": "中"},
{"name": "低"}
]
}
}
```
更新已有字段时(保留原选项 + 新增):
```json
{
"options": [
{"id": "opt_existing_1", "name": "紧急"},
{"id": "opt_existing_2", "name": "高"},
{"id": "opt_existing_3", "name": "中"},
{"name": "极低"}
]
}
```
> 更新 options 是**全量覆盖**,不是追加!不传的旧选项会被删除,关联的单元格数据丢失。
### 3.3 date(日期)
config 字段:`formatter`
可选值:
- `YYYY-MM-DD`(默认)
- `YYYY-MM-DD HH:mm`
- `YYYY-MM-DD HH:mm:ss`
- `YYYY/MM/DD`
- `YYYY/MM/DD HH:mm`
```json
{"fieldName": "截止日期", "type": "date", "config": {"formatter": "YYYY-MM-DD"}}
```
```json
{"fieldName": "创建时间", "type": "date", "config": {"formatter": "YYYY-MM-DD HH:mm"}}
```
### 3.4 currency(货币)
config 字段:`currencyType`(必填)、`formatter`(可选)
currencyType 可选值:
`CNY` | `HKD` | `USD` | `EUR` | `GBP` | `MOP` | `VND` | `JPY` | `KRW` | `AED` | `AUD` | `BRL` | `CAD` | `CHF` | `INR` | `IDR` | `MXN` | `MYR` | `PHP` | `PLN` | `RUB` | `SGD` | `THB` | `TRY` | `TWD`
formatter 可选值(控制小数位):`INT` | `FLOAT_1` | `FLOAT_2`(默认)| `FLOAT_3` | `FLOAT_4`
```json
{"fieldName": "预算", "type": "currency", "config": {"currencyType": "CNY", "formatter": "FLOAT_2"}}
```
### 3.5 progress(进度)
config 字段:`formatter`(固定为 `PERCENT`)、`customizeRange`、`min`、`max`
- 默认范围:0~1(即 0%~100%)
- 自定义范围时 `customizeRange` 必须为 `true`
```json
{"fieldName": "完成度", "type": "progress", "config": {"formatter": "PERCENT"}}
```
自定义范围:
```json
{"fieldName": "进度", "type": "progress", "config": {"formatter": "PERCENT", "customizeRange": true, "min": 0, "max": 1}}
```
### 3.6 rating(评分)
config 字段:`min`、`max`、`icon`
- `min`:固定为 `1`
- `max`:1~10,默认 `5`
- `icon`:默认 `star`
```json
{"fieldName": "满意度", "type": "rating", "config": {"min": 1, "max": 5, "icon": "star"}}
```
### 3.7 user / department / group(人员 / 部门 / 群组)
config 字段:`multiple`
- `multiple`:`true`(多选,默认)| `false`(单选)
```json
{"fieldName": "负责人", "type": "user", "config": {"multiple": false}}
```
```json
{"fieldName": "协作部门", "type": "department", "config": {"multiple": true}}
```
### 3.8 formula(公式)
config 字段:`formula`(必填)
- 公式中引用字段使用**方括号 + 字段名**:`[字段名]`
- 支持的函数:参考钉钉 AI 表格公式文档
```json
{"fieldName": "合计", "type": "formula", "config": {"formula": "[单价] * [数量]"}}
```
```json
{"fieldName": "是否逾期", "type": "formula", "config": {"formula": "IF([截止日期] < NOW(), \"是\", \"否\")"}}
```
> ⚠️ formula 字段创建后为**只读**,不能通过 record create/update 写入值。
### 3.9 unidirectionalLink(单向关联)
config 字段:`linkedTableId`(必填)、`multiple`
- `linkedTableId`:目标表的 tableId
- `multiple`:`true`(多选,默认)| `false`(单选)
```json
{"fieldName": "关联项目", "type": "unidirectionalLink", "config": {"linkedTableId": "tblXXXXXX", "multiple": true}}
```
### 3.10 bidirectionalLink(双向关联)
config 字段:`linkedTableId`(必填)、`multiple`
- 与单向关联参数相同
- 创建后系统会**自动**在被关联表创建反向字段
```json
{"fieldName": "关联任务", "type": "bidirectionalLink", "config": {"linkedTableId": "tblYYYYYY", "multiple": true}}
```
## 4. AI 字段(ai-config)
AI 字段不使用 config,而使用独立的 `--ai-config` 参数。详见 [aitable-field.md](./aitable-field.md) 中的 AI 字段创建示例。
核心规则:
- `outputType` 必须与 `--type` 对应:text→text, select→singleSelect, multiSelect→multipleSelect, number→number, currency→currency, image/video→attachment
- `prompt` 中必须至少包含一个 `fieldRef` 引用
- 纯文本 prompt 会被后端拒绝
## 5. 常见错误
| 错误 | 说明 |
|------|------|
| options 更新时不传已有选项的 id | 会被视为新选项,旧选项被删除,关联数据丢失 |
| options 更新时只传新增项 | 全量覆盖,旧选项全部丢失 |
| formula 字段尝试写入值 | 只读字段,record create/update 会报错 |
| linkedTableId 传表名而非 ID | 必须传 tableId(如 `tblXXX`),不接受表名 |
| progress 值写入 50 表示 50% | 实际应写入 0.5(range 0~1) |
| rating 值超出 max | 写入会报错 |
@@ -0,0 +1,100 @@
# field — 字段管理
## field get — 获取字段详情
```
Usage:
dws aitable field get [flags]
Example:
dws aitable field get --base-id <BASE_ID> --table-id <TABLE_ID>
dws aitable field get --base-id <BASE_ID> --table-id <TABLE_ID> --field-ids fld1,fld2
Flags:
--base-id string Base ID (必填)
--field-ids string 字段 ID 列表,逗号分隔,单次最多 10 个
--table-id string Table ID (必填)
```
返回字段的完整配置(含 options 等)。在 table get 拿到字段目录后,按需展开少量字段的完整配置。
## field create — 创建字段
```
Usage:
dws aitable field create [flags]
Example:
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "状态" --type "singleSelect" --config '{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}'
# 或者使用批量创建模式:
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--fields '[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"}]}}]'
Flags:
--base-id string Base ID (必填)
--name string 要创建的单字段名称(与 --type 配合使用,替代 --fields)
--type string 要创建的单字段类型(参考 table create 字段类型)
--config string 单字段配置,如 options(可选)
--ai-config string 单字段 AI 配置 JSON(可选,用于创建 AI 字段)
--fields string 批量新增字段 JSON 数组,单次最多 15 个 (与 --name/--type 二选一)
--table-id string Table ID (必填)
```
允许部分成功,返回结果逐项标明成功/失败状态。
### AI 字段创建示例
```bash
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "AI摘要" --type text \
--ai-config '{
"outputType":"text",
"prompt":[
{"type":"text","value":"请将下面内容总结成不超过80字的中文摘要:"},
{"type":"fieldRef","fieldId":"fld_content"}
],
"autoRecompute":true,
"enableWebSearch":false,
"enableThinking":true
}' --format json
```
说明:
- `outputType` 与字段类型需一致(如 `outputType=text` 配 `--type text`)
- `prompt` 里通过 `fieldRef` 引用已有字段
- `autoRecompute=true` 表示引用字段变化后自动重算
- **AI 字段的 prompt 必须至少包含一个 `fieldRef` 引用**,纯文本 prompt 会被后端拒绝
## field update — 更新字段
```
Usage:
dws aitable field update [flags]
Example:
dws aitable field update --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --name "新字段名"
dws aitable field update --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --config '{"options":[{"name":"A"},{"name":"B"}]}'
Flags:
--base-id string Base ID (必填)
--config string 字段配置 JSON (不修改时省略)
--ai-config string AI 配置 JSON (不修改时省略)
--field-id string Field ID (必填)
--name string 新字段名称 (不修改时省略)
--table-id string Table ID (必填)
```
- 不可变更字段类型
- 更新 singleSelect/multipleSelect 的 options 时需传入完整列表,已有选项应回传原 id
- `--name` / `--config` / `--ai-config` 至少传一个
## field delete — 删除字段
```
Usage:
dws aitable field delete [flags]
Example:
dws aitable field delete --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --yes
Flags:
--base-id string Base ID (必填)
--field-id string 待删除字段 ID (必填)
--table-id string Table ID (必填)
```
不可逆。禁止删除主字段和最后一个字段。
@@ -0,0 +1,85 @@
# filters & sort — 筛选排序语法参考
## filters 结构规范
### 强制规则
1. **根节点必须是逻辑操作符**:`"operator"` 必须是 `"and"` 或 `"or"`,不能是 `"eq"` 等比较操作符
2. 比较操作必须放在根节点的 `"operands"` 数组内的对象中
3. `singleSelect` 和 `multipleSelect` 字段,推荐使用 **选项的 exact String 名称 (name)** 作为比较值
4. fieldId 必须通过 `table get` 或 `field get` 获取,不能直接用字段名称
### 精简防呆模板
```json
{
"operator": "and",
"operands": [
{
"operator": "eq",
"operands": ["fld_state", "进行中"]
}
]
}
```
4 种衍生:
- **OR 查询**:根节点 `"operator"` 改为 `"or"`
- **多条件 AND**:在 `"operands"` 数组中增加对象
- **文本包含**:内层 `"operator"` 改为 `"contain"`
- **为空判断**:`"operator":"un_exist"`,operands 只需 `["fieldId"]`
### 支持的操作符(已验证完整列表)
| 操作符 | 含义 | operands 格式 |
|--------|------|--------------|
| `eq` / `ne` | 等于 / 不等于 | `["fieldId", "value"]` |
| `contain` / `exclusive` | 包含 / 不包含(文本模糊) | `["fieldId", "value"]` |
| `gt` / `gte` / `lt` / `lte` | 大于 / ≥ / 小于 / ≤ | `["fieldId", "numStr"]` |
| `exist` / `un_exist` | 有值 / 为空 | `["fieldId"]`(无需第二项) |
| `any_of` / `none_of` / `all_of` | 包含任一 / 不包含任一 / 全包含(多选字段) | `["fieldId", "optionName"]` |
| `date_eq` / `before` / `after` | 日期等于 / 早于 / 晚于 | `["fieldId", "dateStr"]` |
| `not_before` / `not_after` | 不早于 / 不晚于 | `["fieldId", "dateStr"]` |
| `from_now` | 从现在起 N 天内 | `["fieldId", "天数"]` |
| `date_between` | 日期区间 | `["fieldId", "[startTs, endTs]"]` |
> ⚠️ 注意操作符拼写:不等于是 `ne`(不是 `not_eq`),不包含是 `exclusive`(不是 `not_contain`),为空是 `un_exist`(不是 `is_empty`)。
### 错误示例
❌ **缺失根节点 and/or**(API 将忽略该 filter,返回全表):
```json
{"operator":"eq","operands":["fldXXX","本科"]}
```
❌ **传入选项 ID 而非名称**(可能导致匹配不到 0 记录):
```json
{"operator":"and","operands":[{"operator":"eq","operands":["fldXXX","CXzrOHK9JI"]}]}
```
### 完整示例
单条件:
```bash
dws aitable record query --base-id X --table-id Y \
--filters '{"operator":"and","operands":[{"operator":"eq","operands":["fldStatusId","进行中"]}]}'
```
多条件 AND:
```bash
dws aitable record query --base-id X --table-id Y \
--filters '{"operator":"and","operands":[{"operator":"eq","operands":["fldStatusId","进行中"]},{"operator":"gt","operands":["fldStockId","0"]}]}'
```
## sort 结构规范
`--sort` 传 JSON 数组,排序方向字段**必须是 `direction`**,不要使用 `order`。
```bash
--sort '[{"fieldId":"fldXXX","direction":"desc"}]'
```
多字段排序:
```bash
--sort '[{"fieldId":"fldPriority","direction":"desc"},{"fieldId":"fldCreatedAt","direction":"asc"}]'
```
@@ -0,0 +1,224 @@
# AI 表格公式字段指南
> 当用户要创建 formula 类型字段、编写表内计算公式、做派生指标时,必须先读本文档。
## 1. 何时使用 formula 字段
| 场景 | 用 formula | 不用 formula |
|------|-----------|-------------|
| 长期展示在表中的派生值(如"总价=单价×数量") | ✅ | |
| 条件标记(如"超期=IF(截止日期<TODAY(),'是','否')") | ✅ | |
| 文本拼接(如"全名=姓&名") | ✅ | |
| 一次性统计分析(如"本月总销售额") | | ✅ 用 record query + 本地聚合 |
| 跨表查找引用 | | ✅ 用 lookup 字段(见下方说明) |
## 2. 创建 formula 字段
```bash
dws aitable field create \
--base-id <baseId> \
--table-id <tableId> \
--name "总价" \
--type formula \
--config '{"formula": "[单价] * [数量]"}' \
--format json
```
### config 结构
```json
{
"formula": "<公式表达式>"
}
```
- `formula` 是唯一必填字段
- 表达式中引用字段使用 **方括号 + 字段名**:`[字段名]`
- 字段名必须精确匹配(含空格、大小写)
## 3. 公式语法
### 3.1 引用规则
| 引用方式 | 语法 | 说明 |
|---------|------|------|
| 引用本表字段 | `[字段名]` | 字段名必须精确匹配 |
| 引用关联表字段 | 不支持 | 需要用 lookup 字段 |
### 3.2 常用函数分类
#### 数值计算
| 函数 | 用途 | 示例 |
|------|------|------|
| `+` `-` `*` `/` | 四则运算 | `[单价] * [数量]` |
| `SUM(...)` | 求和 | `SUM([Q1], [Q2], [Q3], [Q4])` |
| `ROUND(value, digits)` | 四舍五入 | `ROUND([金额] * 0.1, 2)` |
| `ABS(value)` | 绝对值 | `ABS([差额])` |
| `MAX(a, b, ...)` | 最大值 | `MAX([成绩1], [成绩2])` |
| `MIN(a, b, ...)` | 最小值 | `MIN([报价1], [报价2])` |
#### 文本处理
| 函数 | 用途 | 示例 |
|------|------|------|
| `&` | 文本拼接 | `[姓] & [名]` |
| `CONCATENATE(...)` | 拼接多个值 | `CONCATENATE([城市], "-", [区])` |
| `LEFT(text, n)` | 取左侧 n 字符 | `LEFT([编号], 4)` |
| `RIGHT(text, n)` | 取右侧 n 字符 | `RIGHT([手机], 4)` |
| `LEN(text)` | 文本长度 | `LEN([备注])` |
| `UPPER(text)` / `LOWER(text)` | 大小写转换 | `UPPER([代码])` |
#### 逻辑判断
| 函数 | 用途 | 示例 |
|------|------|------|
| `IF(条件, 真值, 假值)` | 条件判断 | `IF([金额] > 1000, "大额", "普通")` |
| `AND(a, b, ...)` | 逻辑与 | `IF(AND([状态]="完成", [评分]>=4), "优秀", "")` |
| `OR(a, b, ...)` | 逻辑或 | `IF(OR([等级]="A", [等级]="B"), "通过", "未通过")` |
| `NOT(expr)` | 逻辑非 | `NOT([已归档])` |
| `SWITCH(expr, v1, r1, v2, r2, ..., default)` | 多条件匹配 | `SWITCH([状态], "待办","🔴", "进行中","🟡", "完成","🟢", "")` |
#### 日期函数
| 函数 | 用途 | 示例 |
|------|------|------|
| `TODAY()` | 当前日期 | `IF([截止日期] < TODAY(), "已逾期", "正常")` |
| `NOW()` | 当前时间 | `NOW()` |
| `YEAR(date)` / `MONTH(date)` / `DAY(date)` | 提取年/月/日 | `YEAR([创建时间])` |
| `DATEDIF(start, end, unit)` | 日期差 | `DATEDIF([开始], [结束], "d")` 返回天数 |
| `DATEADD(date, count, unit)` | 日期加减 | `DATEADD([创建时间], 7, "d")` |
> `DATEDIF` 的 unit 参数:`"y"`=年, `"m"`=月, `"d"`=天
#### 空值处理
| 函数 | 用途 | 示例 |
|------|------|------|
| `BLANK()` | 空值常量 | `IF([备注] = BLANK(), "无", [备注])` |
| `IF(field, ...)` | 字段为空时视为 false | `IF([评分], [评分], 0)` |
## 4. 常见公式模板
### 4.1 计算类
```
// 含税价格
[不含税价] * (1 + [税率])
// 完成率百分比
[已完成数] / [总数]
// 折扣后价格
[原价] * (1 - [折扣率])
```
### 4.2 状态标记类
```
// 逾期标记
IF([截止日期] < TODAY(), "⚠️ 已逾期", "正常")
// 优先级标签
SWITCH([优先级], "紧急","🔴P0", "高","🟠P1", "中","🟡P2", "低","🟢P3", "")
// 进度状态
IF([进度] >= 1, "✅ 已完成", IF([进度] > 0, "🔄 进行中", "⏳ 未开始"))
```
### 4.3 文本拼接类
```
// 编号生成
"PRJ-" & [项目编码] & "-" & [序号]
// 地址拼接
[省] & [市] & [区] & [详细地址]
```
## 5. 注意事项与限制
### 5.1 formula 字段是只读的
- formula 字段的值由系统自动计算,**不能通过 `record create/update` 写入**
- 如果用户要"设置某个计算结果",应引导其修改源字段
### 5.2 字段名必须精确
- 公式中的 `[字段名]` 必须与表中实际字段名完全一致
- 创建 formula 字段前,先通过 `field get` 确认字段名
### 5.3 循环引用
- formula 字段不能引用自身
- 不能形成 A→B→A 的循环引用
### 5.4 与跨表引用字段的区别
钉钉 AI 表格有两种跨表取值方式:`lookup`(关联引用)和 `filterUp`(查找引用)。
| 维度 | formula | lookup (关联引用) | filterUp (查找引用) |
|------|---------|-----------------|-------------------|
| 字段类型 | `formula` | `lookup` | `filterUp` |
| 数据来源 | 本表字段 | 通过已有关联字段(bidirectionalLink/unidirectionalLink)取关联表字段 | 直接指定目标表 + 筛选条件取值 |
| 前置条件 | 无 | 必须先有关联字段 | 无需关联字段 |
| 适用场景 | 本表内计算、条件判断 | "我关联了某条记录,取它的某个字段值" | "在另一张表里按条件查找记录并聚合取值" |
#### lookup config(已验证)
```json
{
"associateField": "<本表中的关联字段 fieldId(bidirectionalLink/unidirectionalLink 类型)>",
"valuesField": "<关联目标表中要取值的字段 fieldId>",
"aggregator": "SUM|AVERAGE|COUNT|MAX|MIN|CONCATENATE"
}
```
创建示例:
```bash
dws aitable field create --base-id <baseId> --table-id <tableId> \
--name "关联名称" --type lookup \
--config '{"associateField":"<linkFieldId>","valuesField":"<targetFieldId>","aggregator":"CONCATENATE"}'
```
#### filterUp config(已验证)
```json
{
"targetSheet": "<目标表 tableId>",
"filters": [
{
"fieldId": "<目标表字段Id>",
"operator": "equal|contain",
"value": "<匹配值>",
"link": "AND"
}
],
"valuesField": "<目标表中要取值的字段Id>",
"aggregator": "SUM|AVERAGE|COUNT|MAX|MIN|CONCATENATE"
}
```
> `filters` 必须非空(至少一条筛选规则)。
> `filters[].operator` 仅支持:`equal`、`contain`(`not_equal`/`not_contain`/`is_empty` 等均不支持)。
> `filters[].link` 统一为 `"AND"` 或 `"OR"`。
### 5.5 创建前检查清单
1. 已通过 `field get` 确认所有引用字段的精确名称
2. 引用字段不包含 formula/lookup 等只读字段(可能导致二次计算延迟)
3. 公式语法正确(括号匹配、函数名正确)
4. 字段类型兼容(数值运算的字段确实是 number 类型)
## 6. 更新 formula 字段
```bash
dws aitable field update \
--base-id <baseId> \
--table-id <tableId> \
--field-id <fieldId> \
--config '{"formula": "[新字段A] + [新字段B]"}' \
--format json
```
更新时只需传新的 `formula` 表达式,系统会自动重新计算所有记录。
@@ -0,0 +1,45 @@
# record create — 新增记录
## 命令格式
```
Usage:
dws aitable record create [flags]
Example:
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldTextId":"文本内容","fldNumId":123}}]'
Flags:
--base-id string Base ID (必填)
--records string 记录列表 JSON 数组,单次最多 100 条 (必填,与 --records-file 二选一)
--records-file string 从文件读取 records JSON(替代 --records,适合超长数据或 Windows 环境)
--table-id string Table ID (必填)
```
## Windows / 超长 JSON 推荐
将 records JSON 写入文件,用 `--records-file ./records.json` 传入,避免命令行截断和引号转义问题。
## 常见错误(严格避免)
| 错误 | 说明 |
|------|------|
| 参数名用 `--data` | ❌ 参数名是 `--records`,不是 `--data` |
| cells key 用字段名 | ❌ cells key 必须是 fieldId(如 `fldXXX`),不是字段名称(如 `"课程名称"`) |
| 不先获取 fieldId | ❌ 必须先 `table get` 获取 fieldId,再写入记录 |
| 单次超 100 条 | ❌ 单次最多 100 条,超过需分批 |
## 正确流程
```bash
# 先获取 fieldId
dws aitable table get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 从返回中提取 fieldId(如 fldABC123)
# 再用 fieldId 写入记录
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldABC123":"Python入门"}}]' --format json
```
## cells 写入格式
各字段类型的写入格式见 [aitable-cell-value.md](./aitable-cell-value.md)。
@@ -0,0 +1,20 @@
# record delete — 删除记录
## 命令格式
```
Usage:
dws aitable record delete [flags]
Example:
dws aitable record delete --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids rec1,rec2 --yes
Flags:
--base-id string Base ID (必填)
--record-ids string 待删除记录 ID 列表,逗号分隔,最多 100 条 (必填)
--table-id string Table ID (必填)
```
## 注意事项
- **不可逆操作**,调用前建议先 `record query` 确认目标记录
- 需要先通过 `record query` 获取 recordId
- 单次最多删除 100 条记录
@@ -0,0 +1,74 @@
# record query — 查询记录
## 命令格式
```
Usage:
dws aitable record query [flags]
Example:
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID>
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids rec1,rec2
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --query "关键词" --limit 50
Flags:
--base-id string Base ID (必填)
--cursor string 分页游标,首次不传
--field-ids string 返回字段 ID 列表,逗号分隔,单次最多 100 个
--filters string 结构化过滤条件 JSON
--query string 全文关键词搜索
--limit int 单次最大记录数,默认 100,最大 100
--record-ids string 指定记录 ID 列表,逗号分隔,单次最多 100 个
--sort string 排序条件 JSON 数组
--table-id string Table ID (必填)
--all 启用自动翻页,循环获取并合并所有记录后统一输出
--page-limit int 自动翻页最大页数(仅 --all 时生效)。默认 50,设为 0 表示无限制
```
两种模式: 按 ID 取(传 record-ids,忽略 filters/sort)或条件查(filters+sort+cursor 分页)。
## 自动翻页(--all + --page-limit)
- 传入 `--all` 启用自动翻页,CLI 自动循环获取并合并所有记录后统一输出
- `--page-limit` 控制最大翻页次数,默认 50 页(5000 条),设为 0 表示无限制
- 页间间隔 200ms,中途网络错误会 graceful stop 并输出已获取的数据
- **被截断时**(达到 page-limit 但仍有数据):输出中包含 `"hasMore": true` 和 `"cursor": "..."` 字段,可通过 `--cursor` 从断点继续拉取
- 适用于需要一次性获取全量数据的场景(如导出、统计、批量处理)
```bash
# 默认(最多 50 页 = 5000 条)
dws aitable record query --base-id X --table-id Y --all
# 无限制(拉完为止)
dws aitable record query --base-id X --table-id Y --all --page-limit 0
# 从上次断点继续
dws aitable record query --base-id X --table-id Y --all --cursor "上次返回的cursor"
```
## 排序参数规范
`--sort` 需要传 JSON 数组,排序方向字段必须是 `direction`(`asc` 或 `desc`),不要使用 `order`。
正确示例:
```bash
--sort '[{"fieldId":"wm8ns9bw2vmucb45xj3ix","direction":"desc"}]'
```
## filters 结构
详细语法见 [aitable-filter-sort.md](./aitable-filter-sort.md)。
快速模板:
```json
{"operator":"and","operands":[{"operator":"eq","operands":["<fieldId>","<value>"]}]}
```
> **singleSelect/multipleSelect 过滤**:filters 中可传 option id 或 option name,但建议优先用 **option id**(通过 `field get` 获取),更可靠。
## 减少响应体积
字段较多时,用 `--field-ids` 仅返回需要的字段,可显著减少返回数据量。
## 常见错误
- `--filters` 根节点直接用 `"operator":"eq"` → API 静默忽略,返回全表
- `--sort` 用 `"order":"desc"` → 必须用 `"direction":"desc"`
- 不加 `--field-ids` 拉全字段 → 大表响应体积过大
- 全量拉取后在 context 里手动统计 → 应优先用 `--filters` 服务端过滤
@@ -0,0 +1,42 @@
# record update — 更新记录
## 命令格式
```
Usage:
dws aitable record update [flags]
Example:
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"recXXX","cells":{"fldStatusId":"已完成"}}]'
Flags:
--base-id string Base ID (必填)
--records string 待更新记录 JSON 数组,单次最多 100 条 (必填,与 --records-file 二选一)
--records-file string 从文件读取 records JSON(替代 --records,适合超长数据或 Windows 环境)
--table-id string Table ID (必填)
```
只需传入需修改的字段,未传入的保持原值。每条记录必须含 recordId 和 cells。
## 高频错误 flag(LLM 极易踩坑,必读)
CLI **没有** `--record-id` 和 `--cells` 两个独立 flag,**只接受 `--records` 一个参数**,格式为 JSON 数组。
即使只改一条记录,也必须包在数组里。
| 错误(LLM 直觉) | 正确 |
|---|---|
| `--record-id recXXX --cells '{"fldX":"值"}'` | `--records '[{"recordId":"recXXX","cells":{"fldX":"值"}}]'` |
| `--id recXXX --data '{"fldX":"值"}'` | 同上 |
| `--record-id recXXX --field fldX --value "新值"` | 同上 |
## 单条更新模板(直接复制)
```bash
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"<RECORD_ID>","cells":{"<FIELD_ID>":"新值"}}]' --format json
```
## 引号转义提示
- Linux/macOS:外层用单引号 `'[...]'`,内部 JSON 用双引号即可
- Windows PowerShell:外层用双引号 `"[...]"`,内部双引号需转义为 `\"`
- 或将 JSON 写入临时文件,用 `--records-file ./records.json` 规避转义
@@ -0,0 +1,128 @@
# 易混淆操作与字段规则
## 易混淆操作 (高风险场景必读)
| 用户说的 | 正确命令 | 不是这个 |
|---------|----------|---------|
| "创建一个新表格 (Base)" | `base create` | 不是 `table create` |
| "在表格里加一个数据表" | `table create` | 不是 `base create` |
| "看看表格里有哪些表" | `base get` | 不是 `field get` |
| "看看表里有哪些列" | `field get` / `table get` | 不是 `base get` |
| "搜索表格" (找 Base) | `base search` | 不是 `record query` |
| "搜索记录" (查表内数据) | `record query` | 不是 `base search` |
| "删掉这个数据表" | `table delete` | 不是 `record delete` |
| "删掉这条数据" | `record delete` | 不是 `table delete` |
| "删掉这个列" | `field delete` | 不是 `record delete` |
| "改字段类型" | 先 `field delete` 再 `field create` | `field update` **不能改类型** |
| "移动字段/调整字段顺序" | `view update --config '{"visibleFieldIds":[...]}'`(视图层重排,首列主字段必须保留在第一位) | 没有 `field reorder`/`field move` 命令;不能改字段在元数据里的"原始定义顺序" |
## field 子命令总览
> ⚠️ field 有且仅有以下 **4 个** 子命令,没有 `list`、`reorder`、`move`:
| 子命令 | 用途 |
|-------|------|
| `field get` | 获取字段详情(含完整 config/options)。**不是 `field list`** |
| `field create` | **创建字段(支持通过 config.options 设置选项)** |
| `field update` | 更新字段名称或配置(**不能改类型**,**不能改顺序**) |
| `field delete` | 删除字段(不可逆) |
> **想"调整字段顺序"?请使用 `view update`**(视图层操作,不属于 `field` 子命令):
> - 通过 `--config '{"visibleFieldIds":["fld1","fld2",...]}'` 传入 fieldId 数组,数组顺序即视图中的字段显示顺序
> - 仅影响**该视图**的列排列,同一 table 的其他视图与字段元数据原始顺序不变
> - **首列字段(主字段)必须保留在数组第一位**,不能移动到非首位
> - **漏传的字段不会被隐藏**,而是会被 API 自动追加到列表末尾
> - **读写命名不一致**:写入键名 `visibleFieldIds`,但读出(`view get`)时该字段在 view 里叫 `columns`——校验顺序时请看 `views[].columns`
## field create 高频错误 flag(LLM 极易踩坑)
> 以下是 LLM 生成命令时**最常犯的 flag 错误**,每个都会返回 `unknown flag` 或被静默忽略。
> **CLI 只接受 `--name` / `--type` / `--config`(单字段模式)或 `--fields`(批量模式),没有其他 flag 表达字段名 / 类型 / 选项。**
| 错误写法(LLM 直觉) | 正确写法 | 说明 |
|---|---|---|
| `--field-name "状态"` | `--name "状态"` | 没有 `--field-name`,flag 就是 `--name` |
| `--field-type singleSelect` | `--type singleSelect` | 没有 `--field-type` |
| `--options "进行中,已完成,已归档"` | `--config '{"options":[{"name":"进行中"},{"name":"已完成"},{"name":"已归档"}]}'` | **没有 `--options` flag**,选项必须嵌套在 `--config` 的 JSON 里 |
| `--property '{"options":...}'` | `--config '{"options":...}'` | 没有 `--property`,对应 flag 是 `--config` |
| `--select-options "高,中,低"` | `--config '{"options":[{"name":"高"},{"name":"中"},{"name":"低"}]}'` | 同上,没有任何逗号分隔的简写语法 |
| `--field-config '{...}'` | `--config '{...}'` | 没有 `--field-config` |
> **记忆口诀**:`field create` 的参数只有 4 个单词—— **name / type / config / fields**,其他任何变体拼写都是错的。
## 字段创建时设置 config(重要)
创建 singleSelect/multipleSelect 字段时,**必须设置选项 (options)**:
```bash
# 创建带选项的单选字段 (推荐新语法: --name/--type/--config)
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "优先级" --type "singleSelect" \
--config '{"options":[{"name":"高"},{"name":"中"},{"name":"低"}]}' \
--format json
# 建表时也可以直接通过 --fields 批量带选项字段
dws aitable table create --base-id <BASE_ID> --name "任务表" \
--fields '[{"fieldName":"任务","type":"text"},{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}}]' \
--format json
```
> ⚠️ **不要混淆**:
> - **字段创建**(`field create` 的 `--config` 或 `table create` 的 `--fields`):创建时就指定 `options`
> - **记录写入**(`record create` / `record update` 的 `--records`):只能写入已存在的选项名称
## 主字段约束(table create 必读)
> ⚠️ `table create` 的 `--fields` 中,**第一个字段自动成为主字段**。
> 主字段只能是 **text** 类型,不能是 attachment、checkbox、formula 等。
**实际影响**:当用户要求创建的字段不适合做主字段时(如附件、复选框),必须:
1. 先放一个 text 字段作为第一个字段(主字段)
2. 再放用户要求的字段
3. **告知用户**为何多了一个字段
```bash
# 例: 用户要求只创建附件字段 → 附件不能做主字段,必须先加 text 主字段
dws aitable table create --base-id <BASE_ID> --name "产品图片" \
--fields '[{"fieldName":"名称","type":"text"},{"fieldName":"产品图片","type":"attachment"}]' \
--format json
```
## 只读字段 (不可写入)
以下类型的字段不可写入, 执行 `field get` / `table get` 后识别并跳过:
- 创建时间 / 修改时间 (系统自动)
- 创建人 / 修改人 (系统自动)
- 自动编号
- 公式字段
- 引用字段
## 记录写入格式(record create / record update)
> 以下是 **记录写入** 时 cells 的格式,不是字段创建的格式。
| 类型 | 写入格式 | 读取返回格式 |
|------|----------|-------------|
| text | `"fldXXX":"文本值"` | `"fldXXX":"文本值"` |
| number | `"fldXXX":123` | `"fldXXX":123` |
| singleSelect | `"fldXXX":"选项名"` (必须是已存在的选项) | `"fldXXX":{"id":"x","name":"选项名"}` |
| multipleSelect | `"fldXXX":["选项1","选项2"]` (必须是已存在的选项) | `"fldXXX":[{"id":"x","name":"选项1"}]` |
| date | `"fldXXX":"2026-03-04"` 或 Unix ms | `"fldXXX":1709510400000` (ms) |
| user | `"fldXXX":[{"userId":"123"}]` | `"fldXXX":{"uid":"123"}` |
| attachment | `[{"fileToken":"<token>"}]` — 需先通过 `attachment upload` 获取,见下方 ⬇️ | `[{"url":"...","filename":"...","size":N}]` |
## ⚠️ 附件上传完整流程(必读!)
> **不要**使用钉盘 (drive) 上传来替代此流程!钉盘 fileId **无法**写入 attachment 字段。
附件字段写入使用 `upload_attachment.py` 脚本,**2 步**完成:
```bash
# 步骤 1: 一键上传文件(脚本内部自动完成 prepare + PUT to OSS)
python3 scripts/upload_attachment.py <BASE_ID> /path/to/photo.png
# 输出: { "fileToken": "ft_xxx", "fileName": "photo.png", "size": 1024 }
# 步骤 2: 在 record create/update 中使用 fileToken
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
```
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
通过 MCP 导出任务(export_data)导出 AI 表格,并可自动下载文件。
与普通命令的区别:
- 自动处理 taskId 轮询(直到拿到 downloadUrl 或达到轮询上限)。
- 自动保存导出文件到本地(可选 --output)。
用法:
python scripts/aitable_export_via_task.py <baseId> --scope all
python scripts/aitable_export_via_task.py <baseId> --scope table --table-id <tableId>
python scripts/aitable_export_via_task.py <baseId> --scope view --table-id <tableId> --view-id <viewId>
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
ALLOWED_FORMATS = {"excel", "attachment", "excel_and_attachment", "excel_with_inline_images"}
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]:
cmd = [dws_bin] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return 124, "", f"dws command timeout after {timeout_sec}s"
except FileNotFoundError:
return 127, "", f"dws binary not found: {dws_bin}"
def parse_json_output(raw: str) -> Optional[Dict[str, Any]]:
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
return None
def normalize_download_url(url: str) -> str:
if url.startswith("http://") or url.startswith("https://"):
return url
return f"https://{url}"
def download_file(url: str, output_path: Path) -> Tuple[bool, str]:
req = Request(url, method="GET")
try:
with urlopen(req, timeout=180) as resp:
if resp.status != 200:
return False, f"download http status: {resp.status}"
output_path.write_bytes(resp.read())
return True, ""
except HTTPError as e:
body = e.read().decode("utf-8", "ignore")
return False, f"HTTP {e.code}: {body[:300]}"
except URLError as e:
return False, f"URL error: {e.reason}"
def fail(msg: str, code: int = 1) -> None:
print(f"错误:{msg}", file=sys.stderr)
sys.exit(code)
def build_start_args(args: argparse.Namespace) -> list[str]:
cmd = [
"aitable",
"export",
"data",
"--base-id",
args.base_id,
"--scope",
args.scope,
"--format",
args.export_format,
"--timeout-ms",
str(args.timeout_ms),
]
if args.table_id:
cmd.extend(["--table-id", args.table_id])
if args.view_id:
cmd.extend(["--view-id", args.view_id])
return cmd
def main() -> None:
parser = argparse.ArgumentParser(description="通过 MCP 导出任务导出 AI 表格")
parser.add_argument("base_id", help="目标 AI 表格 baseId")
parser.add_argument("--scope", choices=["all", "table", "view"], required=True, help="导出范围")
parser.add_argument("--table-id", help="scope=table/view 时必填")
parser.add_argument("--view-id", help="scope=view 时必填")
parser.add_argument("--export-format", default="excel", choices=sorted(ALLOWED_FORMATS), help="导出格式")
parser.add_argument("--timeout-ms", type=int, default=1000, help="单次等待毫秒数,默认 1000")
parser.add_argument("--poll-timeout-ms", type=int, default=3000, help="轮询等待毫秒数,默认 3000")
parser.add_argument("--max-polls", type=int, default=10, help="最大轮询次数,默认 10")
parser.add_argument("--output", help="本地保存路径(不传则按 fileName 保存到当前目录)")
parser.add_argument("--dws", default="dws", help="dws 可执行文件路径,默认 dws")
parser.add_argument("--no-download", action="store_true", help="仅返回 downloadUrl,不下载文件")
args = parser.parse_args()
if not validate_resource_id(args.base_id):
fail("无效的 baseId 格式")
if args.scope in ("table", "view") and not args.table_id:
fail("scope=table/view 时必须传 --table-id")
if args.scope == "view" and not args.view_id:
fail("scope=view 时必须传 --view-id")
print("[1/2] start export task", file=sys.stderr)
rc, out, err = run_dws(args.dws, build_start_args(args), timeout_sec=120)
if rc != 0:
fail(f"export_data 启动失败: {err or out}", rc)
obj = parse_json_output(out)
if not obj:
fail(f"export_data 返回非 JSON: {out[:300]}")
data = obj.get("data", {}) or {}
status = obj.get("status")
if status == "error":
fail(f"export_data 返回失败: {json.dumps(obj, ensure_ascii=False)}")
download_url = data.get("downloadUrl")
task_id = data.get("taskId")
file_name = data.get("fileName") or "export_result.bin"
polls = 0
while not download_url and task_id and polls < args.max_polls:
polls += 1
print(f"[2/2] polling task ({polls}/{args.max_polls})", file=sys.stderr)
rc2, out2, err2 = run_dws(
args.dws,
[
"aitable",
"export",
"data",
"--base-id",
args.base_id,
"--task-id",
task_id,
"--timeout-ms",
str(args.poll_timeout_ms),
],
timeout_sec=max(120, int(args.poll_timeout_ms / 1000) + 60),
)
if rc2 != 0:
fail(f"export_data 轮询失败: {err2 or out2}", rc2)
obj2 = parse_json_output(out2)
if not obj2:
fail(f"export_data 轮询返回非 JSON: {out2[:300]}")
if obj2.get("status") == "error":
fail(f"export_data 轮询返回失败: {json.dumps(obj2, ensure_ascii=False)}")
d2 = obj2.get("data", {}) or {}
download_url = d2.get("downloadUrl") or download_url
file_name = d2.get("fileName") or file_name
task_id = d2.get("taskId") or task_id
if not download_url:
time.sleep(0.2)
result: Dict[str, Any] = {
"baseId": args.base_id,
"scope": args.scope,
"exportFormat": args.export_format,
"taskId": task_id,
"fileName": file_name,
"downloadUrl": download_url,
"polledTimes": polls,
}
if not download_url:
result["status"] = "pending"
result["summary"] = "导出任务仍在处理中,请继续用 taskId 轮询。"
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(3)
if args.no_download:
result["status"] = "success"
result["summary"] = "导出完成(未下载文件)。"
print(json.dumps(result, ensure_ascii=False, indent=2))
return
norm_url = normalize_download_url(download_url)
output_path = Path(args.output).expanduser().resolve() if args.output else Path.cwd() / file_name
ok, dl_err = download_file(norm_url, output_path)
if not ok:
fail(f"downloadUrl 下载失败: {dl_err}")
result["status"] = "success"
result["summary"] = "导出完成并已下载。"
result["savedPath"] = str(output_path)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
通过 MCP 文件导入任务(prepare_import_upload -> PUT -> import_data)导入 AI 表格。
与 import_records.py 的区别:
- 本脚本:走“文件导入任务”链路,通常会新建导入数据表。
- import_records.py:走 create_records,写入已有 table。
用法:
python scripts/aitable_import_via_task.py <baseId> <filePath>
python scripts/aitable_import_via_task.py <baseId> <filePath> --timeout 30
python scripts/aitable_import_via_task.py <baseId> <filePath> --dws /tmp/dws
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
ALLOWED_EXTENSIONS = {".csv", ".xlsx", ".xls"}
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]:
cmd = [dws_bin] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return 124, "", f"dws command timeout after {timeout_sec}s"
except FileNotFoundError:
return 127, "", f"dws binary not found: {dws_bin}"
def parse_json_output(raw: str) -> Optional[Dict[str, Any]]:
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
return None
def put_file(upload_url: str, file_path: Path) -> Tuple[bool, str]:
payload = file_path.read_bytes()
req = Request(upload_url, data=payload, method="PUT")
# 关键:清空 Content-Type,避免 SignatureDoesNotMatch。
req.add_header("Content-Type", "")
try:
with urlopen(req, timeout=180) as resp:
if resp.status == 200:
return True, ""
return False, f"unexpected HTTP status: {resp.status}"
except HTTPError as e:
body = e.read().decode("utf-8", "ignore")
return False, f"HTTP {e.code}: {body[:300]}"
except URLError as e:
return False, f"URL error: {e.reason}"
def fail(msg: str, exit_code: int = 1) -> None:
print(f"错误:{msg}", file=sys.stderr)
sys.exit(exit_code)
def main() -> None:
parser = argparse.ArgumentParser(description="通过文件导入任务导入 AI 表格")
parser.add_argument("base_id", help="目标 AI 表格 baseId")
parser.add_argument("file_path", help="待导入文件路径(.csv/.xlsx/.xls)")
parser.add_argument("--timeout", type=int, default=30, help="import_data 等待秒数,默认 30")
parser.add_argument("--dws", default="dws", help="dws 可执行文件路径,默认 dws")
args = parser.parse_args()
base_id = args.base_id.strip()
file_path = Path(args.file_path).expanduser().resolve()
if not validate_resource_id(base_id):
fail("无效的 baseId 格式")
if not file_path.exists() or not file_path.is_file():
fail(f"文件不存在或不可读: {file_path}")
if file_path.suffix.lower() not in ALLOWED_EXTENSIONS:
fail(f"仅支持 {sorted(ALLOWED_EXTENSIONS)},当前文件: {file_path.name}")
file_size = file_path.stat().st_size
if file_size <= 0:
fail("文件为空")
print(f"[1/3] prepare import upload: {file_path.name} ({file_size} bytes)", file=sys.stderr)
rc, out, err = run_dws(
args.dws,
[
"aitable",
"import",
"upload",
"--base-id",
base_id,
"--file-name",
file_path.name,
"--file-size",
str(file_size),
"--format",
"json",
],
)
if rc != 0:
fail(f"prepare_import_upload 失败: {err or out}", rc)
prepare_obj = parse_json_output(out)
if not prepare_obj:
fail(f"prepare_import_upload 返回非 JSON: {out[:300]}")
if prepare_obj.get("status") != "success":
fail(f"prepare_import_upload 返回失败: {json.dumps(prepare_obj, ensure_ascii=False)}")
pdata = prepare_obj.get("data") or {}
upload_url = pdata.get("uploadUrl")
import_id = pdata.get("importId")
if not upload_url or not import_id:
fail(f"prepare_import_upload 缺少 uploadUrl/importId: {json.dumps(pdata, ensure_ascii=False)}")
print("[2/3] upload file bytes via PUT", file=sys.stderr)
ok, put_err = put_file(upload_url, file_path)
if not ok:
fail(f"PUT 上传失败: {put_err}")
print("[3/3] trigger import_data", file=sys.stderr)
rc2, out2, err2 = run_dws(
args.dws,
[
"aitable",
"import",
"data",
"--import-id",
import_id,
"--timeout",
str(args.timeout),
"--format",
"json",
],
timeout_sec=max(120, args.timeout + 30),
)
if rc2 != 0:
fail(f"import_data 调用失败: {err2 or out2}", rc2)
import_obj = parse_json_output(out2)
if not import_obj:
fail(f"import_data 返回非 JSON: {out2[:300]}")
result = {
"baseId": base_id,
"fileName": file_path.name,
"fileSize": file_size,
"importId": import_id,
"status": import_obj.get("status"),
"summary": import_obj.get("summary"),
"data": import_obj.get("data", {}),
"error": import_obj.get("error", {}),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
if import_obj.get("status") != "success":
sys.exit(2)
if __name__ == "__main__":
main()
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""
批量添加字段到钉钉 AI 表格数据表(新版 schema)
用法:
python bulk_add_fields.py <baseId> <tableId> fields.json
fields.json 格式:
[
{"fieldName": "字段 1", "type": "text"},
{"fieldName": "字段 2", "type": "number", "config": {"formatter": "INT"}},
{"fieldName": "字段 3", "type": "singleSelect", "config": {"options": [{"name": "高"}]}}
]
兼容写法:
- name 会自动映射为 fieldName
- phone 会自动映射为 telephone
"""
import sys
import json
import subprocess
import os
import re
from pathlib import Path
from typing import Union, List, Dict, Any, Optional, Tuple
JsonData = Union[List[Any], Dict[str, Any]]
MAX_FILE_SIZE = 10 * 1024 * 1024
ALLOWED_FILE_EXTENSIONS = ['.json']
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
ALLOWED_FIELD_TYPES = {
'text', 'number', 'singleSelect', 'multipleSelect', 'date', 'currency',
'user', 'department', 'group', 'progress', 'rating', 'checkbox',
'attachment', 'url', 'richText', 'telephone', 'email', 'idCard',
'barcode', 'geolocation', 'primaryDoc', 'formula', 'unidirectionalLink',
'bidirectionalLink', 'creator', 'lastModifier', 'createdTime',
'lastModifiedTime',
}
FIELD_TYPE_ALIASES = {
'phone': 'telephone',
}
def resolve_safe_path(path: str, allowed_root: Optional[str] = None) -> Path:
if allowed_root is None:
allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd())
allowed_root = Path(allowed_root).resolve()
target_path = (
Path(path).resolve()
if Path(path).is_absolute()
else (Path.cwd() / path).resolve()
)
try:
target_path.relative_to(allowed_root)
return target_path
except ValueError:
raise ValueError(
f"路径超出允许范围:{path}\n"
f"目标路径:{target_path}\n"
f"允许根目录:{allowed_root}\n"
f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内"
)
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def validate_file_extension(filename: str, allowed_extensions: list) -> bool:
return any(filename.lower().endswith(ext) for ext in allowed_extensions)
def safe_json_load(file_path: Path, max_size: int = MAX_FILE_SIZE) -> JsonData:
file_size = file_path.stat().st_size
if file_size > max_size:
raise ValueError(
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
)
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def normalize_field_config(field: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(field)
if 'fieldName' not in normalized and 'name' in normalized:
normalized['fieldName'] = normalized.pop('name')
normalized['type'] = FIELD_TYPE_ALIASES.get(
normalized.get('type', 'text'), normalized.get('type', 'text')
)
return normalized
def validate_field_config(field: Dict[str, Any]) -> Tuple[bool, str]:
if not isinstance(field, dict):
return False, '字段配置必须是对象'
field = normalize_field_config(field)
if 'fieldName' not in field:
return False, '缺少必需字段:fieldName'
if not isinstance(field['fieldName'], str) or not field['fieldName'].strip():
return False, 'fieldName 必须是非空字符串'
field_type = field.get('type', 'text')
if field_type not in ALLOWED_FIELD_TYPES:
return False, f"不支持的字段类型:{field_type}"
config = field.get('config')
if config is not None and not isinstance(config, dict):
return False, 'config 必须是对象'
if field_type in {'singleSelect', 'multipleSelect'}:
options = (config or {}).get('options')
if not options or not isinstance(options, list):
return False, (
'singleSelect / multipleSelect 必须提供 config.options 数组'
)
if field_type in {'unidirectionalLink', 'bidirectionalLink'}:
linked_sheet_id = (config or {}).get('linkedSheetId')
if not linked_sheet_id or not validate_resource_id(linked_sheet_id):
return False, '关联字段必须提供合法的 config.linkedSheetId'
return True, ''
def build_fields_json(fields: List[Dict[str, Any]]) -> str:
"""构建 --fields 参数的 JSON 字符串。"""
payload_fields = []
for field in fields:
normalized = normalize_field_config(field)
item: Dict[str, Any] = {
'fieldName': normalized['fieldName'].strip(),
'type': normalized.get('type', 'text'),
}
if 'config' in normalized and normalized['config'] is not None:
item['config'] = normalized['config']
payload_fields.append(item)
return json.dumps(payload_fields, ensure_ascii=False)
def run_dws(args: List[str]) -> Optional[Dict[str, Any]]:
if not args:
print('错误:空命令')
return None
cmd = ['dws'] + args
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}")
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f"无法解析响应:{result.stdout[:200]}...")
print(f"JSON 解析错误:{e}")
return None
except subprocess.TimeoutExpired:
print('错误:命令执行超时(60 秒)')
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装')
return None
def bulk_add_fields(
base_id: str, table_id: str, fields_file: str
) -> bool:
try:
safe_path = resolve_safe_path(fields_file)
except ValueError as e:
print(f"路径验证失败:{e}")
return False
if not validate_file_extension(fields_file, ALLOWED_FILE_EXTENSIONS):
print(f"错误:只允许 {', '.join(ALLOWED_FILE_EXTENSIONS)} 文件")
return False
if not safe_path.exists():
print(f"错误:文件不存在:{safe_path}")
return False
try:
fields = safe_json_load(safe_path)
except ValueError as e:
print(f"错误:{e}")
return False
except json.JSONDecodeError as e:
print(f"错误:JSON 格式无效:{e}")
return False
if not isinstance(fields, list) or not fields:
print('错误:fields.json 必须是非空 JSON 数组')
return False
if len(fields) > 15:
print('错误:单次最多创建 15 个字段,请拆分后重试')
return False
for i, field in enumerate(fields):
valid, error = validate_field_config(field)
if not valid:
print(f"错误:字段 #{i+1} 配置无效:{error}")
return False
fields_json = build_fields_json(fields)
result = run_dws([
'aitable', 'field', 'create',
'--base-id', base_id,
'--table-id', table_id,
'--fields', fields_json,
'--format', 'json',
])
if not result:
return False
print(json.dumps(result, ensure_ascii=False, indent=2))
return True
def main():
if len(sys.argv) != 4:
print(__doc__)
print('用法示例:')
print(' python bulk_add_fields.py basexxx tablexxx fields.json')
sys.exit(1)
base_id = sys.argv[1]
table_id = sys.argv[2]
fields_file = sys.argv[3]
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式')
sys.exit(1)
if not validate_resource_id(table_id):
print('错误:无效的 tableId 格式')
sys.exit(1)
success = bulk_add_fields(base_id, table_id, fields_file)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
从 CSV / JSON 批量导入记录到钉钉 AI 表格(新版 schema)
用法:
python import_records.py <baseId> <tableId> data.csv [batch_size]
python import_records.py <baseId> <tableId> data.json [batch_size]
说明:
- CSV 表头默认视为 fieldId
- JSON 支持两种格式:
1. [{"cells": {"fldxxx": "value"}}, ...]
2. [{"fldxxx": "value"}, ...] # 会自动包装成 cells
"""
import sys
import csv
import json
import subprocess
import os
import re
from pathlib import Path
from typing import Union, List, Dict, Any, Optional, Tuple
JsonData = Union[List[Any], Dict[str, Any]]
RecordDict = Dict[str, str]
MAX_FILE_SIZE = 50 * 1024 * 1024
ALLOWED_CSV_EXTENSIONS = ['.csv']
ALLOWED_JSON_EXTENSIONS = ['.json']
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
MAX_RECORDS_PER_BATCH = 100
DEFAULT_BATCH_SIZE = 50
def resolve_safe_path(
path: str, allowed_root: Optional[str] = None
) -> Path:
if allowed_root is None:
allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd())
allowed_root = Path(allowed_root).resolve()
target_path = (
Path(path).resolve()
if Path(path).is_absolute()
else (Path.cwd() / path).resolve()
)
try:
target_path.relative_to(allowed_root)
return target_path
except ValueError:
raise ValueError(
f"路径超出允许范围:{path}\n"
f"目标路径:{target_path}\n"
f"允许根目录:{allowed_root}\n"
f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内"
)
def validate_resource_id(resource_id: str) -> bool:
return bool(
resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip())
)
def validate_file_extension(
filename: str, allowed_extensions: list
) -> bool:
return any(filename.lower().endswith(ext) for ext in allowed_extensions)
def safe_csv_load(
file_path: Path, max_size: int = MAX_FILE_SIZE
) -> List[RecordDict]:
file_size = file_path.stat().st_size
if file_size > max_size:
raise ValueError(
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
)
with open(file_path, 'r', encoding='utf-8', newline='') as f:
return list(csv.DictReader(f))
def safe_json_load(
file_path: Path, max_size: int = MAX_FILE_SIZE
) -> JsonData:
file_size = file_path.stat().st_size
if file_size > max_size:
raise ValueError(
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
)
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def sanitize_record_value(
value: Any,
) -> Optional[Union[str, int, float, bool, list, dict]]:
if value is None:
return None
if isinstance(value, (bool, int, float, list, dict)):
return value
if not isinstance(value, str):
return value
if not value.strip():
return None
value = value.strip()
if value.lower() == 'true':
return True
if value.lower() == 'false':
return False
try:
if '.' in value:
return float(value)
return int(value)
except ValueError:
return value
def normalize_record(record: Dict[str, Any]) -> Dict[str, Any]:
if 'cells' in record and isinstance(record['cells'], dict):
cells = record['cells']
else:
cells = record
normalized = {}
for key, value in cells.items():
sanitized = sanitize_record_value(value)
if sanitized is not None:
normalized[key] = sanitized
return {'cells': normalized}
def validate_record(
record: Dict[str, Any], headers: List[str]
) -> Tuple[bool, str]:
if not isinstance(record, dict):
return False, '记录必须是对象'
normalized = normalize_record(record)
cells = normalized.get('cells', {})
if not cells or not isinstance(cells, dict):
return False, '记录必须包含非空 cells 对象'
return True, ''
def run_dws(args: List[str]) -> Optional[Dict[str, Any]]:
if not args:
print('错误:空命令')
return None
cmd = ['dws'] + args
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}")
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f"无法解析响应:{result.stdout[:200]}...")
print(f"JSON 解析错误:{e}")
return None
except subprocess.TimeoutExpired:
print('错误:命令执行超时(120 秒)')
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装')
return None
def import_from_csv(
base_id: str, table_id: str, csv_file: str,
batch_size: int = DEFAULT_BATCH_SIZE,
) -> bool:
try:
safe_path = resolve_safe_path(csv_file)
except ValueError as e:
print(f"路径验证失败:{e}")
return False
if not validate_file_extension(csv_file, ALLOWED_CSV_EXTENSIONS):
print(f"错误:只允许 {', '.join(ALLOWED_CSV_EXTENSIONS)} 文件")
return False
if not safe_path.exists():
print(f"错误:文件不存在:{safe_path}")
return False
try:
rows = safe_csv_load(safe_path)
except ValueError as e:
print(f"错误:{e}")
return False
except csv.Error as e:
print(f"错误:CSV 格式无效:{e}")
return False
if not rows:
print('错误:CSV 文件为空或没有有效数据行')
return False
records = [
normalize_record(row)
for row in rows
if normalize_record(row)['cells']
]
return import_records(base_id, table_id, records, batch_size)
def import_from_json(
base_id: str, table_id: str, json_file: str,
batch_size: int = DEFAULT_BATCH_SIZE,
) -> bool:
try:
safe_path = resolve_safe_path(json_file)
except ValueError as e:
print(f"路径验证失败:{e}")
return False
if not validate_file_extension(json_file, ALLOWED_JSON_EXTENSIONS):
print(f"错误:只允许 {', '.join(ALLOWED_JSON_EXTENSIONS)} 文件")
return False
if not safe_path.exists():
print(f"错误:文件不存在:{safe_path}")
return False
try:
records = safe_json_load(safe_path)
except ValueError as e:
print(f"错误:{e}")
return False
except json.JSONDecodeError as e:
print(f"错误:JSON 格式无效:{e}")
return False
if not isinstance(records, list) or not records:
print('错误:JSON 文件必须是非空数组')
return False
for i, record in enumerate(records):
valid, error = validate_record(record, [])
if not valid:
print(f"错误:记录 #{i+1} 格式无效:{error}")
return False
return import_records(
base_id, table_id,
[normalize_record(r) for r in records], batch_size,
)
def import_records(
base_id: str, table_id: str,
records: List[Dict[str, Any]], batch_size: int,
) -> bool:
if batch_size <= 0:
print('错误:batch_size 必须大于 0')
return False
if batch_size > MAX_RECORDS_PER_BATCH:
batch_size = MAX_RECORDS_PER_BATCH
total_batches = (len(records) + batch_size - 1) // batch_size
success = True
for i in range(0, len(records), batch_size):
batch = records[i:i + batch_size]
batch_num = (i // batch_size) + 1
records_json = json.dumps(batch, ensure_ascii=False)
result = run_dws([
'aitable', 'record', 'create',
'--base-id', base_id,
'--table-id', table_id,
'--records', records_json,
'--format', 'json',
])
if result:
print(
f"[{batch_num}/{total_batches}] "
f"✓ 已提交 {len(batch)} 条记录"
)
else:
print(f"[{batch_num}/{total_batches}] ✗ 导入失败")
success = False
return success
def main():
if len(sys.argv) < 4 or len(sys.argv) > 5:
print(__doc__)
print('用法示例:')
print(
' python import_records.py basexxx tablexxx data.csv 50'
)
sys.exit(1)
base_id = sys.argv[1]
table_id = sys.argv[2]
input_file = sys.argv[3]
batch_size = (
int(sys.argv[4]) if len(sys.argv) == 5
else DEFAULT_BATCH_SIZE
)
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式')
sys.exit(1)
if not validate_resource_id(table_id):
print('错误:无效的 tableId 格式')
sys.exit(1)
if input_file.lower().endswith('.csv'):
success = import_from_csv(
base_id, table_id, input_file, batch_size
)
elif input_file.lower().endswith('.json'):
success = import_from_json(
base_id, table_id, input_file, batch_size
)
else:
print('错误:仅支持 .csv 或 .json 文件')
sys.exit(1)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
上传附件到钉钉 AI 表格 attachment 字段
完整流程(内部自动执行 3 步):
1. dws aitable attachment upload → 获取 uploadUrl + fileToken
2. HTTP PUT 上传文件到 OSS
3. 返回 fileToken,可直接用于 record create/update
用法:
python upload_attachment.py <baseId> <filePath>
输出 (JSON):
{ "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
然后在 record create/update 中使用:
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
"""
import sys
import json
import subprocess
import os
import mimetypes
import re
from pathlib import Path
from typing import Optional, Dict, Any
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def detect_mime_type(file_path: Path) -> str:
"""根据文件扩展名推断 MIME type。"""
mime_type, _ = mimetypes.guess_type(str(file_path))
return mime_type or 'application/octet-stream'
def run_dws(args: list) -> Optional[Dict[str, Any]]:
"""调用 dws 命令并返回解析后的 JSON 结果。"""
cmd = ['dws'] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
print(f"错误:dws 命令失败: {result.stderr.strip()}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
print(f"错误:无法解析 dws 响应: {result.stdout[:300]}", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print('错误:dws 命令超时(60 秒)', file=sys.stderr)
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装并在 PATH 中', file=sys.stderr)
return None
def upload_to_oss(upload_url: str, file_path: Path, mime_type: str) -> bool:
"""通过 HTTP PUT 上传文件到 OSS。"""
file_data = file_path.read_bytes()
req = Request(upload_url, data=file_data, method='PUT')
req.add_header('Content-Type', mime_type)
try:
with urlopen(req, timeout=120) as resp:
if resp.status == 200:
return True
print(f"错误:OSS 上传失败,HTTP {resp.status}", file=sys.stderr)
return False
except HTTPError as e:
print(f"错误:OSS 上传 HTTP 错误 {e.code}: {e.reason}", file=sys.stderr)
return False
except URLError as e:
print(f"错误:OSS 上传网络错误: {e.reason}", file=sys.stderr)
return False
def upload_attachment(base_id: str, file_path_str: str) -> Optional[Dict[str, Any]]:
"""
执行完整的附件上传流程:
1. prepare_attachment_upload → uploadUrl + fileToken
2. PUT 文件到 OSS
3. 返回 fileToken 信息
"""
# 验证文件
file_path = Path(file_path_str).resolve()
if not file_path.exists():
print(f"错误:文件不存在: {file_path}", file=sys.stderr)
return None
if not file_path.is_file():
print(f"错误:不是文件: {file_path}", file=sys.stderr)
return None
file_size = file_path.stat().st_size
if file_size <= 0:
print("错误:文件为空", file=sys.stderr)
return None
if file_size > MAX_FILE_SIZE:
print(f"错误:文件过大 ({file_size:,} 字节,限制 {MAX_FILE_SIZE:,} 字节)", file=sys.stderr)
return None
file_name = file_path.name
mime_type = detect_mime_type(file_path)
# 步骤 1: prepare_attachment_upload
print(f"步骤 1/3: 准备上传 {file_name} ({file_size:,} 字节, {mime_type})...", file=sys.stderr)
dws_args = [
'aitable', 'attachment', 'upload',
'--base-id', base_id,
'--file-name', file_name,
'--size', str(file_size),
'--mime-type', mime_type,
'--format', 'json',
]
result = run_dws(dws_args)
if not result:
return None
status = result.get('status', '')
if status != 'success':
error = result.get('error', {})
print(f"错误:准备上传失败: {error.get('message', json.dumps(error, ensure_ascii=False))}", file=sys.stderr)
return None
data = result.get('data', {})
upload_url = data.get('uploadUrl', '')
file_token = data.get('fileToken', '')
if not upload_url or not file_token:
print(f"错误:返回数据缺少 uploadUrl 或 fileToken: {json.dumps(data, ensure_ascii=False)}", file=sys.stderr)
return None
# 步骤 2: PUT 文件到 OSS
print(f"步骤 2/3: 上传文件到 OSS...", file=sys.stderr)
if not upload_to_oss(upload_url, file_path, mime_type):
return None
# 步骤 3: 返回 fileToken
print(f"步骤 3/3: 上传完成!", file=sys.stderr)
output = {
"fileToken": file_token,
"fileName": file_name,
"size": file_size,
"mimeType": mime_type,
}
return output
def main():
if len(sys.argv) != 3:
print(__doc__)
print('用法:')
print(' python upload_attachment.py <baseId> <filePath>')
print()
print('示例:')
print(' python upload_attachment.py G1DKw2zgV2bEk6PMSBooNxlEVB5r9YAn ./report.pdf')
print()
print('然后在 record create 中使用返回的 fileToken:')
print(' dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \\')
print(' --records \'[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]\' --format json')
sys.exit(1)
base_id = sys.argv[1]
file_path = sys.argv[2]
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式', file=sys.stderr)
sys.exit(1)
result = upload_attachment(base_id, file_path)
if result is None:
sys.exit(1)
# 正常输出到 stdout(JSON 格式,方便解析)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
if __name__ == '__main__':
main()
+46
View File
@@ -0,0 +1,46 @@
---
name: dingtalk-attendance
description: 钉钉考勤(只读)。Use when 用户说 考勤/打卡记录/查打卡/查班次/考勤汇总/考勤规则/出勤情况。命令前缀:dws attendance。开源版仅支持只读查询,不支持创建班次、导入排班、修改考勤组等写操作。
cli_version: ">=0.2.14"
metadata:
category: product
stability: experimental
requires:
bins:
- dws
---
# 钉钉考勤 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.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> 命令参考:[attendance.md](references/attendance.md)。
## 意图表
| 用户说 | 命令 |
|--------|------|
| "查我 / 某人 某天的打卡" | `dws attendance record get --user <userId> --date <YYYY-MM-DD>` |
| "查考勤组 / 考勤规则 / 打卡范围" | `dws attendance rules --date <YYYY-MM-DD>` |
| "查班次 / 排班 / 谁今天上什么班" | `dws attendance shift list --users <userId1,userId2> --start <YYYY-MM-DD> --end <YYYY-MM-DD>` |
| "考勤统计 / 周月汇总 / 出勤天数" | `dws attendance summary --user <userId> --date "<yyyy-MM-dd HH:mm:ss>" --stats-type week\|month` |
## 评测高频硬约束
- 开源版考勤只有 `record / rules / shift / summary` 四个只读命令;用户提到创建班次、导入排班、加人入考勤组等写操作时,直接告知"开源版不支持",不要伪装成功。
- 查询迟到/缺勤名单时,空打卡结果不等于"没人迟到"。必须结合排班、`NotSigned`、`Absenteeism`、无记录人员分别说明;数据缺失要标为"无记录/无法判断",不要归为正常。
- 做部门 Top N 排名时,用户要求前 N 名就必须输出 N 个部门;无打卡记录或无可计算数据的部门按 0 或"无数据"保留在排名中,不能只输出有数据的少数部门。
- `summary` 必须同时传 `--user`、`--date`、`--stats-type`(week/month),缺一返回 C0002。
- `shift list` 时间跨度不超过 7 天,最多 50 人;`record get` 一次只查一个用户一天。
- 所有 dws 命令带 `--format json`,时间/日期按命令要求分别使用 `YYYY-MM-DD` 或 `yyyy-MM-dd HH:mm:ss`。
## 跨产品协作
- 拿到 userId 前先用 `dingtalk-aisearch` 解析人名
@@ -0,0 +1,112 @@
# 考勤 (attendance) 命令参考
开源 `dws attendance` 仅支持以下 4 个只读子命令:`record / rules / shift / summary`。
> 写操作(创建班次、导入排班、修改考勤组成员/配置、保存个人规则、更新假期规则、设置假期余额等)在开源版均**不支持**。用户提到这类需求时,直接告知不支持,不要尝试拼命令。
## 命令总览
### 查询个人考勤详情
```
Usage:
dws attendance record get [flags]
Example:
dws attendance record get --user USER_ID --date 2026-03-08
Flags:
--date string 查询日期, 格式 YYYY-MM-DD (必填)
--user string 钉钉用户 ID (必填)
```
一次只查一个用户一天的考勤详情。
### 查询考勤组与考勤规则
```
Usage:
dws attendance rules [flags]
Example:
dws attendance rules --date 2026-03-14
dws attendance rules --date "2026-03-14 09:00:00"
Flags:
--date string 考勤日期, 格式 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss (必填)
```
查询当前用户所属的考勤组、打卡规则、弹性工时设置等。
### 批量查询员工班次
```
Usage:
dws attendance shift list [flags]
Example:
dws attendance shift list --users userId1,userId2 --start 2026-03-03 --end 2026-03-07
Flags:
--end string 结束日期, 格式 YYYY-MM-DD (必填)
--start string 开始日期, 格式 YYYY-MM-DD (必填)
--users string 用户 ID 列表, 逗号分隔, 最多 50 人 (必填)
```
批量查询多个员工在指定日期的班次信息。**时间跨度不超过 7 天,最多 50 人**。
### 查询某人的考勤统计摘要
```
Usage:
dws attendance summary [flags]
Example:
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week
Flags:
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
--stats-type string 统计类型: week / month (必填, 钉钉服务端业务层强制要求, 不填返回 C0002)
--user string 钉钉用户 ID (必填)
```
`--user`、`--date`、`--stats-type` 均必填。
## 意图判断
用户说"打卡记录/某天考勤/某人今天打卡了吗" → `record get`
用户说"考勤组/考勤规则/打卡范围/弹性工时" → `rules`
用户说"班次/排班/今天上什么班/这周几点上下班" → `shift list`
用户说"考勤汇总/周统计/月统计/出勤天数/迟到次数" → `summary`
## 核心工作流
```bash
# 查个人某天的考勤详情
dws attendance record get --user <USER_ID> --date 2026-03-08 --format json
# 查考勤组和规则
dws attendance rules --date 2026-03-14 --format json
# 批量查班次(最多 50 人 / 7 天)
dws attendance shift list --users user001,user002 --start 2026-03-03 --end 2026-03-07 --format json
# 查月度考勤汇总
dws attendance summary --user <USER_ID> --date "2026-03-12 15:00:00" --stats-type month --format json
# 查周度考勤汇总
dws attendance summary --user <USER_ID> --date "2026-03-12 15:00:00" --stats-type week --format json
```
## 上下文传递表
| 操作 | 提取 | 用于 |
|------|------|------|
| `contact user get-self` / `contact user search` | `userId` | record get / shift list / summary 的 `--user` / `--users` |
| `rules` | `groupId` | 进一步说明考勤组归属 |
## 注意事项
- `record get` 的 `--date` 格式: `YYYY-MM-DD`,CLI 自动转换为毫秒时间戳
- `rules` 的 `--date` 支持 `YYYY-MM-DD` 或 `yyyy-MM-dd HH:mm:ss` 两种格式
- `shift list` 的 `--start/--end` 使用 `YYYY-MM-DD` 格式,跨度不超过 7 天;`--users` 最多 50 人
- `summary` 的 `--date` 必须是 `yyyy-MM-dd HH:mm:ss` 格式;`--stats-type` 仅接受 `week` 或 `month`
- 所有命令必须显式带 `--format json` 便于解析
- 用户 ID 需先从 `contact user get-self` 或 `aisearch person` 获取,不要凭记忆复用
## 严格约束
- 不要凭历史记忆复用 userId / groupId 等任何 ID,每次必须从当次命令返回值中提取
- 不要猜测命令,先查 `--help` 明确命令
- 查询迟到/缺勤名单时,空打卡结果不等于"没人迟到",必须结合 `NotSigned`、`Absenteeism`、无记录人员分别说明
- 涉及超过 3 条记录的聚合(求和、分组、计数、排序)时必须落 Python 脚本处理,禁止口算或目测
- 遇到时长字段时,注意区分单位是秒、分钟还是小时
- 写操作(创建班次、导入排班、修改考勤组、设置假期余额等)开源版不支持,遇到这类需求直接告知,不要伪装成功
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
查看我今天/本周/指定日期的考勤记录(自动获取 userId)
用法:
python attendance_my_record.py # 今天
python attendance_my_record.py today # 今天
python attendance_my_record.py 2026-03-10 # 指定日期
python attendance_my_record.py --dry-run # 仅显示命令
"""
import sys
import json
import subprocess
import re
from datetime import datetime
from typing import List, Any, Optional
DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')
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 get_my_user_id(dry_run: bool = False) -> Optional[str]:
data = run_dws([
'contact', 'user', 'get-self', '--format', 'json',
], dry_run=dry_run)
if dry_run:
return '<MY_USER_ID>'
if not data or not isinstance(data, dict):
return None
return data.get('userId') or data.get('userid')
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
date_str = args[0] if args else 'today'
if date_str == 'today':
date_str = datetime.now().strftime('%Y-%m-%d')
elif not DATE_PATTERN.match(date_str):
print(__doc__)
sys.exit(1)
print('🔍 获取当前用户信息...')
user_id = get_my_user_id(dry_run=dry_run)
if not user_id and not dry_run:
print('错误:无法获取当前用户 ID')
sys.exit(1)
print(f'📊 查询 {date_str} 考勤记录...\n')
data = run_dws([
'attendance', 'record', 'get',
'--user', user_id or '<MY_USER_ID>',
'--date', date_str,
'--format', 'json',
], dry_run=dry_run)
if dry_run:
return
if not data:
print('未查到考勤记录')
return
print(f"📋 考勤记录 ({date_str})")
print('=' * 40)
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
查询团队成员本周排班和出勤统计
用法:
python attendance_team_shift.py --users userId1,userId2,userId3
python attendance_team_shift.py --users userId1,userId2 \
--from 2026-03-10 --to 2026-03-14
python attendance_team_shift.py --users userId1 --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta
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=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 get_week_range():
today = datetime.now()
monday = today - timedelta(days=today.weekday())
friday = monday + timedelta(days=4)
return monday.strftime('%Y-%m-%d'), friday.strftime('%Y-%m-%d')
def main():
parser = argparse.ArgumentParser(
description='查询团队成员排班和出勤统计'
)
parser.add_argument(
'--users', required=True, help='用户 ID 列表,逗号分隔'
)
mon, fri = get_week_range()
parser.add_argument('--from', dest='from_date', default=mon)
parser.add_argument('--to', dest='to_date', default=fri)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
user_count = len(args.users.split(','))
if user_count > 50:
print('错误:最多查询 50 人')
sys.exit(1)
print(f"📊 团队排班查询 ({args.from_date} ~ {args.to_date})")
print(f" 人数: {user_count}")
print('=' * 50)
print('\n🔍 查询排班信息...')
data = run_dws([
'attendance', 'shift', 'list',
'--users', args.users,
'--start', args.from_date,
'--end', args.to_date,
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
return
if not data:
print('未查到排班信息')
return
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
+54
View File
@@ -0,0 +1,54 @@
---
name: dingtalk-calendar
description: 钉钉日历与会议室。Use when 用户说 约会议/查日程/订会议室/查闲忙/加参会人/改期/取消会议/今天的日程/本周日程/共同空闲。Distinct from dingtalk-conference(视频会议发起/预约/邀请入会/会中控制)、dingtalk-minutes(听记)、dingtalk-todo(待办)。命令前缀:dws calendar。
cli_version: ">=0.2.14"
metadata:
category: product
stability: experimental
requires:
bins:
- dws
---
# 钉钉日历 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.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> 命令参考:[calendar.md](references/calendar.md);剧本:[03-meeting.md](references/03-meeting.md)。
## 意图表
| 用户说 | 命令 |
|--------|------|
| "今天 / 明天 / 本周日程" | `python scripts/calendar_today_agenda.py [today\|tomorrow\|week]` |
| "约会议(含参会人 + 会议室)" | `python scripts/calendar_schedule_meeting.py --title "<主题>" --start "<起>" --end "<止>" [--users <ids>] [--book-room]` |
| "多人共同空闲" | `python scripts/calendar_free_slot_finder.py --users <ids> --date <yyyy-MM-dd>` |
| "查闲忙" | `dws calendar event list --start "<ISO>" --end "<ISO>"` |
| "加参会人" / "订房" / "取消" | `dws calendar participant add` / `room add` / `event delete` |
## 执行硬约束
- 多轮日程任务必须保留 `eventId`,后续加人、移人、订房、换房、改描述、删除都基于同一个 `eventId` 执行;不要重新创建重复日程。
- 用户明确说"帮我订一个空闲会议室"时,`room search` 返回可用会议室后直接选择第一个可预订且不需要自定义审批的 `roomId` 执行 `room add`;不要把选择权抛回用户导致任务停住。
- 已有日程订房:`dws calendar room search --start ... --end ... --format json` → `dws calendar room add --event <EVENT_ID> --rooms <ROOM_ID> --format json` → `event get` 或 `room/busy` 验证。
- 换会议室:先 `room delete --event <EVENT_ID> --rooms <OLD_ROOM_ID>`,再 `room add --event <EVENT_ID> --rooms <NEW_ROOM_ID>`,最后回查;不要只更新 `--location`。
- 参会人变化用 `participant add/delete`,日程描述变化用 `event update --desc`,删除日程用 `event delete --id`。用户当前消息已明确要求删除/取消时可直接执行;否则先确认。
- 脚本失败或参数不完整时,立即降级到明确的 `dws calendar event/participant/room` 命令,不要停在"我要查看用法"。
- 所有 dws 命令带 `--format json`;查询时间必须显式 `--start` / `--end`。
## 跨产品协作
- 视频会议发起 / 入会链接 / 邀请入会 / 会中控制 → 切到 `dingtalk-conference`
- 会后摘要 / 待办 → 切到 `dingtalk-minutes`
- 参会人按人名 → 先用 `dingtalk-aisearch` 解析
## 注意
`schedule-meeting` 必须读 [03-meeting.md](references/03-meeting.md) 中的「两准则」「搜房失败硬门禁」,禁止假设 `roomId`。
@@ -0,0 +1,56 @@
# 会议管理(日程与会议室)
> **lite 速查表**含 `start-conference`、`list-today-meetings`、`check-users-busy`(见 `dws-shared/references/best_practices/_common/lite-recipes.md`)。列表类操作须遵循 [calendar.md](./calendar.md) **「CLI 命令树与黄金路径」**,禁止无子命令的 `dws calendar` 或臆造 `calendar list`(见该文 **「反模式(禁止)」**)。**`schedule-meeting` 不做内联**:须读本文件 **「两准则」「搜房失败硬门禁」** 及下表 **schedule-meeting** 行全文。
> **听记、会后待办、摘要分享** 见 `dingtalk-minutes` sub-skill 的 `references/07-minutes.md`。
## 日程与会议室两准则(强制)
1. **时段**:用户已明确会议起止时间 → **禁止**自动改期、禁止用闲忙结果或「推荐时段」覆盖用户给定时段;只能在此时段内建日程、订会议室;该时段内无可用或指定资源不可用 → **立刻如实告知**,不得偷偷换时间段再试。
2. **会议室**:用户点名具体会议室 → **禁止**换其他会议室;在用户给定时段内查无该房 → **立刻告知**。**用户未给出时段时,必须先显式向用户追问具体开始/结束时间;禁止默认用「当前时刻至当日 23:59:59」之类窗口代查。** **`calendar_schedule_meeting.py`**:仅需 `--title`、`--start`、`--end`;先创建日程,再邀请参会人,最后搜房/订房。未给会议室范围时,`--book-room` 为 **单次**无 `--group-id` 的 `room search --available`,取返回的**第一个**会议室并 `room add`;无结果则告警、不删日程。**若用户明确限定楼层/楼宇/园区/分组,应先用 `room list-groups` 解析允许的 `group-id`,再把这些 `group-id` 传给脚本 `--room-group-id`(或手工 `room search --group-id ...`);脚本只会在这些 group 内查找,若无空房则直接返回。对于同一地点(同园区/楼栋/楼层)的会议室,必须优先锁定最相关、最贴近该地点的承载 group;该 group 查无 roomId/空房,即可判定该地点当前时段无可订会议室,**不得**再去别的无关 group 继续碰运气,因为同一地点的会议室只会挂在其所属 group 下。** **在组织内、按早停规则已把应查的分组(或未限范围时的根目录一次查询)全部查完仍无可用会议室时,必须立即向用户说明「当前时段没有可预订的会议室」或「范围内未检索到可用会议室/资源」并收束,禁止继续扩区、换参重试或虚构有房。** 手工 `room search` **禁止**为试出空闲擅自改日或拉长时间窗。
### 会议室搜索早停
> 专用于 `calendar room list-groups` / `room search` / `room add`;与通用规范「无新参数不重复 search」一致。
**`room search --available`**(与传入的 `--start` / `--end` 配对):返回的是在**该整段时段内**可被预订的空闲会议室(不是「有一段空就算」);脚本与用户手工选房均应沿用同一时间窗,避免误以为分段凑满即等价于整段可用。
**`dws calendar room search` 合法参数**(与 [calendar.md](./calendar.md) 一致):仅 `--start`、`--end`、`--group-id`(可选)、`--available`(可选)、`--format json` 等;**禁止使用 `--query`**,否则会报 `unknown flag: --query`。
**地点归组早停**:若用户给的是同一地点范围(如“西溪园区 C6 楼 3-5 层”或具体楼层/楼栋),先用 `room list-groups` 找到**最相关的承载 group**(通常是该楼层;若楼层下无会议室则为直接挂会议室的上一级)。在这个最相关 group 下查不到有效 `rooms[].roomId` 或空房时,**不得**再跳去别的同级/异地 group 继续搜;同一地点的会议室不会散落在别的 group 里。只有用户明确放宽到别的楼层、楼栋或园区,才能重新解析新的 group 并继续。
**用户点名具体会议室(如「C6-4-06-N / 贡嘎山」)**:**不要**尝试 `room search --query "<名称>"`;**禁止**把用户原文(含「C6-4-06-N 贡嘎山」整句)或展示名当作 `room add --rooms` 的 `roomId`。用户输入**几乎从不会是**有效 `roomId`。须先 `dws calendar room list-groups` 定位所在楼层/分组的 `group-id`,再 `dws calendar room search --start "<ISO>" --end "<ISO>" --group-id <GROUP_ID> [--available] --format json`,在返回 `rooms[]` 中对 `roomName`、`name` 等与用户表述匹配,**仅**取 JSON 里的 `roomId`(典型为小写十六进制串,长度以返回为准),最后 `dws calendar room add --event <eventId> --rooms <roomId>`。该时段无匹配或房间忙 → 如实告知;**禁止**为通过校验而编造、拼接或猜测 `roomId`。
### 搜房失败硬门禁(园区/范围搜尽仍无 roomId)
在用户限定的园区、楼宇、楼层或评测固定分组内,已按早停规则**逐组 `room search` 查完**仍得不到任何有效 `rooms[].roomId`(或无任何空闲房)→ **立即停止**,向用户**明确报错/失败结论**(例如:该时段在指定范围内未检索到可预订会议室或无法获得 roomId),**本回合订房流程结束**。
**用户汇报硬门禁**:一旦触发上面的失败条件,**下一条对外输出必须直接面向用户汇报结果**,不得继续在会话里自言自语式地延长推理。允许的后续只有两类:
1. **失败汇报**:明确说明“指定范围/指定会议室在该时段未找到可预订会议室,因此当前无法完成预订”
2. **确认放宽条件**:仅在需要继续推进时,明确问用户是否放宽地点范围、改时间或接受不订会议室
以下表述/行为视为**违例**:继续写“让我再试一次”“也许是 Mock/测试环境”“可能存在预设 roomId 映射”“我去别的 group 看看”“我换个时间验证一下”“我先看看脚本/示例还能不能推断出 roomId”。
以下行为**一律禁止**(与是否「想多试一次」无关):编造/假设 `roomId` 格式做「预订测试」;在**没有**合法 `roomId` 时调用 `room add` 试探错误详情;拉 `event get` / 日程详情等试图**绕开** `room search` 推断 roomId;换无关园区、扩大关键词、换工具名做未经用户授权的新搜索。
**失败后强制回读**:若出现以下任一信号,下一步**必须重新读取本文件本节与 `schedule-meeting` recipe**,不得沿着当前假设继续试:
1. 连续 **2 次** `room search` 空结果/无 `roomId`
2. 任意一次 `roomId invalid`
3. 已开始尝试「换园区 / 换楼栋 / 看 event 详情 / 猜 roomId」
回读后只允许二选一:
1. **报错收束**:已搜尽允许范围/整园仍无 `roomId` 或无空房
2. **用户确认**:明确询问是否放宽范围、换时间,或接受不订会议室
| # | 规范 |
|---|------|
| 1 | **一键脚本**:`calendar_schedule_meeting.py` 做「建日程 → 加人 → 可选搜房/订房」;未限范围时可直接 `--book-room`,脚本按根目录单次 `room search --available` 订第一家。**若搜房失败,脚本应输出明确失败原因并返回非零退出码,促使上层立即向用户汇报,而不是继续试探。** |
| 2 | **要限范围/具名**:先 `list-groups` 解析允许的 `group-id`。若用户说的是同一地点(同园区/楼栋/楼层),应优先锁定**最相关的承载 group** 并只查它;该 group 无结果即可按该地点无房收束,不再试别的无关 group。仅当用户明确给出多个允许地点时,才分别对这些 group 各 **1 次** `room search --available` 再 `room add` |
| 3 | **禁止**:无新信息时反复 `--verbose`、反复切 `--available`、父组子组试探、在最相关 group 无结果后改搜别的同级/异地 group、超 100 条后仍根分组或未授权区域全量搜;**禁止**对 `room search` 使用不存在的 `--query` |
| 4 | **`roomId` 门禁**:`room add --rooms` **只能**填 `room search` 返回 JSON 中的 `rooms[].roomId`;**禁止**将用户说的会议室名、编号文案、或「假 UUID / 试数字」当作 `roomId` |
| 5 | **全量无结果即收束**:在用户允许的搜索范围内(含**整园/全 campus** 若用户或评测要求已逐组查尽)仍无任何可用会议室或有效 `roomId` → **直接报错/告知失败并结束订房**,且**下一条消息必须汇报给用户**;**不得**假设 ID、不得用 `room add` 试探、不得绕路查日程、不得继续自说自话分析 Mock/测试环境 |
| 6 | **失败触发回读**:连续 2 次空结果、任意一次 `roomId invalid`、或开始换园区/绕路时 → **必须回读本节**;回读后只允许「报错收束」或「向用户确认是否放宽条件」 |
| Recipe | 行动指南(固定路线) |
| ------------------ | ------------------- |
| schedule-meeting | **见上文「两准则」**、**「搜房失败硬门禁」**。**未给时段且仅说「发起/开个会」**→ 不走本 recipe,走 lite recipe `start-conference`。**未给时段但有预约意图**("安排""约""定"等词):追问具体开始/结束时间。**已有时段后**,按固定顺序执行:1. `dws calendar event create` 建日程;2. 有参会人则 `dws calendar participant add`;3. 再处理会议室。**无明确会议室范围**:可直接 `python scripts/calendar_schedule_meeting.py --title "<主题>" --start "<起始>" --end "<结束>" [--users <userIds>] [--book-room] [--dry-run]`。**有明确范围(某楼/层)**:先 `dws calendar room list-groups`,锁定该地点**最相关的承载 group**;若只有一个地点,`--room-group-id` 应只传这个最相关 group,**不要**把同楼内多个楼层 group 打包传入碰运气。只有用户明确给出多个允许地点时,才把这些 `group-id` 一并传给 `python scripts/calendar_schedule_meeting.py ... --book-room --room-group-id "<id1,id2,...>"`。**用户点名具体会议室**:须手工 `dws calendar room search --start "<ISO>" --end "<ISO>" --group-id <GROUP_ID> [--available] --format json`(**无** `--query`),在 JSON 中匹配名称取 **`rooms[].roomId` 唯一真值** → `dws calendar room add --event <eventId> --rooms <roomId>`;**不得**把用户输入的会议室名当 `roomId`。**一旦连续 2 次空结果 / 任意一次 `roomId invalid`**:**必须回读本节并立即收束判断**;若整园/限定范围内搜尽仍无 roomId 或无空房 → **下一条消息必须直接向用户汇报失败结论**;否则只能向用户确认是否放宽范围/改时间。**禁止**假设 roomId、禁止无 ID 调用 `room add`、禁止用日程详情绕路、禁止继续猜测 Mock/测试环境。细则见「会议室搜索早停」。 |
| reschedule-meeting | 1. `calendar event list --start "<起始ISO>" --end "<结束ISO>"` → 取 `eventId` 2. `calendar event update --id <eventId> --start "<新起始ISO>" --end "<新结束ISO>"` 更新时间 3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group <openConversationId> --text "<变更通知>"` 通知变更 |
@@ -0,0 +1,515 @@
# 日历 (calendar) 命令参考
> ✅ **CLI 对齐状态(开源 dws v1.0.30)**:本文档已对齐开源 dws 实际暴露的 4 个子命令组:`event` / `participant` / `room` / `busy`。`attendee`(已统一为 `participant`)、`book` / `attachment` 已从开源 cli 暴露面剔除(钉钉客户端可用,cli 不提供);`event list-mine` 用 `event list --user-id <self>` 替代。
## CLI 命令树与黄金路径
- **二级子命令(必选其一)**:`event`(日程)、`participant`(参会人)、`room`(会议室)、`busy`(闲忙)。`dws calendar` 后**必须**紧跟上述之一;**禁止**只执行 `dws calendar`(无子命令)。
- **个人日程 / 给自己留时间块 / 专注时段**:统一走 **`dws calendar event create`**。当前**没有**单独的 `personal schedule create` / `calendar create` 命令。
- **查日程列表**:`dws calendar event list --start "<ISO-8601>" --end "<ISO-8601>" --format json`,或优先使用脚本 `python scripts/calendar_today_agenda.py [today|tomorrow|week]`(见文末「自动化脚本」)。
- 注:用户日历本列表(`calendar book`)在开源 v1.0.30 未暴露。
- **CLI 不存在**独立的 `dws calendar list`;若误跑无子命令的 `dws calendar`,会打印整段 Usage,**切勿**将该段 help 当作工具结果再次塞进对话(会急剧增加 token 与首字延迟)。
- **必须**遵循指令说明进行调用。**绝对禁止**使用虚构指令,使用虚构参数。
## 反模式(禁止)
1. **禁止**执行 `dws calendar` 且不带二级子命令(会刷出大量帮助文本)。合法二级子命令:`event` / `participant` / `room` / `busy`。
2. **禁止**使用不存在的子命令试探(如臆造 `dws calendar list`);需要日程列表时一律使用 **`dws calendar event list`**(带 `--start` / `--end`,见下文「查询日程列表」示例)。
3. **禁止**将完整 `--help`/Usage 输出作为「观察」重复提交给模型;若误触,应直接改用本节黄金路径中的合法命令并重试。
4. **禁止**继续使用已弃用的 `--max-results` 参数 —— 它们仍可被解析但**会被丢弃**,不再透传到 MCP,模型生成命令时也不要再带。
5. **禁止**为已有日程重新创建日程来预订会议室。若日程已存在(同一会话中刚创建、或用户明确指向某日程),必须使用 `room add --event <已有EVENT_ID> --rooms <ROOM_ID>` 追加会议室,**绝不能**再调一次 `event create --rooms`(会创建重复日程)。
6. **禁止**用 `--location` 替代会议室预订。`--location` 是纯文本地点备注字段,填入会议室名称**不会**完成任何预订或占用。预订会议室必须通过 `room add --rooms <roomId>` 或 `event create --rooms <roomId>`,roomId 来自 `room search` 返回;`--location` 与 `--rooms` 是两个独立字段,用途完全不同。
7. **禁止**只传 `--recurrence-*` 部分 flag **并不是彼此独立的参数**:只传其中一项(比如只改 `--recurrence-count`、只设 `--recurrence-type`)会让服务端收到不完整的 recurrence 结构,CLI 现已前置校验并直接拒绝这类调用。**修改已有周期日程的任何一个循环字段时,都必须重新提供完整的 pattern+range 字段集合**——必要时先 `event get` 读取现有 `recurrence`,再在命令中整体重传。
8. **禁止**用一条 指令 实现串行调用。比如当用户要求一次性安排多场不同的日程(例如「上午 10 点开项目评审、下午 2 点开复盘会、晚上 7 点聚餐」)时,必须**拆解成 N 条独立的 `event create`,依次串行执行**;每条命令自己写完整的 `--title` / `--start` / `--end`,绝不能把多个标题或多段时间塞进同一行。
## 核心概念
日历(calendar):日程的容器。每个用户有一个主日历(我的日历,id: primary),还可以订阅公共/团队日历,以及他人共享的日历。
日程(event):日历中的单个日程,包含起止时间、地点、标题、参会人等属性。支持单次日程和重复日程(有recurrence rule的日程,又称SeriesMaster),遵循RFC5545 iCalendar国际标准。
日程实例(event instance):日程的具体时间实例,可以通过event list指令查询时间段内的所有实例。1个普通日程和对应1个Instance,而1个重复性日程(SeriesMaster)对应N个Instance(同属一个日程序列)。
- 同一个日程序列具有相同的iCalUid,并且重复性日程,其eventId和iCalUid的值相同。因此可以通过重复性日程实例的iCalUid得到重复性日程(SeriesMaster)的eventId
重复规则(recurrence rule):定义重复性日程的重复规则。
参会人(attendee):日程的参与者。常用通讯录工具查询userId,dws contact user search --query "姓名"。
响应状态(response):参会人对日程的回应,包括:未响应、接受、待定、拒绝。
忙闲时间(busy):查询用户在指定时间段的忙闲状态,查询会议室在指定时间段的预定状态,用于会议时间协调。
会议室(room):room是 会议室 ,room可视为日程的资源类参会人,需要加入日程完成预订。注意和location区分,location只是地点,和room不同。
## 命令概览
### event 相关三级子命令
```
# 针对单个日程: 创建 | 修改 | 单查询 | 删除 | 响应日程(接受、暂定、拒绝)
dws calendar event [create|update|get|delete|respond] [flags]
# 按时间范围批量查询
dws calendar event list [flags]
# 对于非明确时间或一段时间范围的约会场景,可基于所有参会人的忙闲状态,推荐多个可用的时间块方案
dws calendar event suggest [flags]
```
# 日程中参会人操作:添加 | 删除 | 查询
dws calendar participant [add|delete|list] [flags]
```
### room 相关三级子命令
```
# 查询分组
dws calendar room list-groups [flags]
# 会议室搜索
dws calendar room search [flags]
# 预定会议室
dws calendar room add [flags]
# 释放会议室
dws calendar room delete [flags]
```
> room是会议室,用于线下开会场景。
### busy 相关三级子命令
```
# 按用户 / 会议室 + 时间窗查闲忙状态(--users 与 --rooms 至少其一),会议室的忙闲等同于预定记录
dws calendar busy search [flags]
```
# 把已上传到钉盘的文件挂到日程上(不负责上传,只负责挂载)
dws calendar attachment add [flags]
```
# 查询当前用户的所有日历,结果范围:用户自己的日历、已订阅的公共/团队日历、他人共享的日历。
dws calendar book list [flags]
```
> **说明**: 可以通过 --help 进一步查看指令明细,也可以继续查看下一节 命令总览
## 命令总览
### 查询日程列表
```
Usage:
dws calendar event list [flags]
Example:
dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"
Flags:
--end string 结束时间 ISO-8601 (例如 2026-03-10T18:00:00+08:00)
--start string 开始时间 ISO-8601 (例如 2026-03-10T14:00:00+08:00)
```
> `--max-results` 已弃用:MCP 不再支持,CLI 仍接受但参数会被丢弃,结果固定最多返回 100 条。
**默认行为**:不传 `--start` / `--end` 时,默认返回今天的日程(00:00:00 ~ 23:59:59)。
**权限**:查询共享日历下的日程时,至少要有reader权限。
### 获取日程详情
```
Usage:
dws calendar event get [flags]
Example:
dws calendar event get --id <EVENT_ID>
dws calendar event get --id <EVENT_ID>
Flags:
--id string 日程 ID (必填)
```
### 创建日程
```
Usage:
dws calendar event create [flags]
Example:
dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"
dws calendar event create --title "周会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \
--attendees userId1,userId2
dws calendar event create --title "项目评审" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \
--rooms roomId1,roomId2 # 创建时直接预定会议室
dws calendar event create --title "每日站会" \
--start "2026-03-10T09:00:00+08:00" --end "2026-03-10T09:30:00+08:00" \
--recurrence-type daily --recurrence-interval 1 --recurrence-range-type numbered --recurrence-count 10
Flags:
--title string 日程标题 (必填,最大2048字符)
--start string 开始时间 ISO-8601 (必填,例如 2026-03-10T14:00:00+08:00)
--end string 结束时间 ISO-8601 (必填,例如 2026-03-10T15:00:00+08:00)
--timezone string 时区 IANA 格式 (例如 Asia/Shanghai,默认 Asia/Shanghai)
--desc string 日程描述 (最大5000字符)
--attendees string 参会人 userId 列表,逗号分隔 (最多500人) 日程组织人自动放入参会人列表,无需传入userId
--open-dingtalk-ids string openDingTalkId 列表,逗号分隔 (与 --attendees 至少传一个)
--rooms string 会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自 `room search` 返回,若是循环会议,必须设置recurrence-end-date,避免长期预订)
# 以下 --recurrence-* 一旦使用任一 flag,必须同时提供完整的 pattern+range 字段(至少 --recurrence-type、--recurrence-interval(>0) 与 --recurrence-range-type)
# 否则 CLI 会报 "recurrence 结构不完整" 并拒绝执行
--recurrence-type string 循环类型: daily|weekly|absoluteMonthly|relativeMonthly|absoluteYearly
--recurrence-interval int 循环间隔 (如 daily 时表示每N天)
--recurrence-days-of-week string 周几: sunday,monday,...,saturday (weekly/relativeMonthly 时必填)
--recurrence-day-of-month int 每月第几天 (absoluteMonthly/absoluteYearly 时必填)
--recurrence-index string 每月第几周: first|second|third|fourth|last (relativeMonthly 时必填)
--recurrence-first-day-of-week string 一周起始日,默认 sunday
--recurrence-range-type string 循环范围: noEnd|endDate|numbered (与 --recurrence-type 必须成对出现)
--recurrence-end-date string 循环结束时间 ISO-8601 (range-type=endDate 时必填)
--recurrence-count int 循环次数 (range-type=numbered 时必填)
--rich-text-desc string html格式的富文本类型日程描述,用于复杂内容的展示
--location string 地点信息(纯文本备注,如‘3号楼A区’;**不等于**预订会议室)
--free-busy string 此日程的忙碌状态,默认值为busy。busy - 在忙闲视图中,此日程时间段为忙碌; free - 此日程不占用忙闲
```
> **说明**:个人日程也走 `event create`。如果只是给自己安排时间,不传 `--attendees` / `--open-dingtalk-ids` 即可。
### 修改日程
```
Usage:
dws calendar event update [flags]
Example:
dws calendar event update --id <EVENT_ID> --title "新标题"
dws calendar event update --id <EVENT_ID> --desc "新描述" --timezone Asia/Tokyo
dws calendar event update --id <EVENT_ID> --recurrence-type daily --recurrence-interval 1 \
--recurrence-range-type numbered --recurrence-count 5
Flags:
--id string 日程 ID (必填)
--title string 新标题
--start string 新开始时间 ISO-8601
--end string 新结束时间 ISO-8601
--desc string 新描述 (最大5000字符)
--timezone string 时区 IANA 格式 (例如 Asia/Shanghai)
# 以下 --recurrence-* 在 修改周期日程的循环规则时必须**整体**传入:MCP 不合并部分字段,只改其中一项(例如只传 --recurrence-count)会把规则覆盖成不完整状态
# 若只想微调已有规则,请先 `event get --id <ID>` 读取现有 recurrence,再在本命令重传完整的 pattern+range
--recurrence-type string 循环类型: daily|weekly|absoluteMonthly|relativeMonthly|absoluteYearly
--recurrence-interval int 循环间隔 (如 daily 时表示每N天)
--recurrence-days-of-week string 周几: sunday,monday,...,saturday (weekly/relativeMonthly 时必填)
--recurrence-day-of-month int 每月第几天 (absoluteMonthly/absoluteYearly 时必填)
--recurrence-index string 每月第几周: first|second|third|fourth|last (relativeMonthly 时必填)
--recurrence-first-day-of-week string 一周起始日,默认 sunday
--recurrence-range-type string 循环范围: noEnd|endDate|numbered (与 --recurrence-type 必须成对出现)
--recurrence-end-date string 循环结束时间 ISO-8601 (range-type=endDate 时必填)
--recurrence-count int 循环次数 (range-type=numbered 时必填)
--rich-text-desc string html格式的富文本类型日程描述,用于复杂内容的展示
--location string 地点信息(纯文本备注,如‘3号楼A区’;**不等于**预订会议室)
--free-busy string 修改此日程的忙碌状态,无需修改则不传。busy - 在忙闲视图中,此日程时间段为忙碌; free - 此日程不占用忙闲
```
> 支持修改标题、描述、时间、地点、忙碌状态等。如需修改会议室,请使用 dws calendar room [add|delete];如需修改参会人,请使用 dws calendar participant [add|delete]
### 删除日程
> **CAUTION:** 不可逆操作 — 所有参会人同步取消,必须先向用户确认。
```
Usage:
dws calendar event delete [flags]
Example:
dws calendar event delete --id <EVENT_ID> --yes
Flags:
--id string 日程 ID (必填)
```
### 查看参会人
```
Usage:
dws calendar participant list [flags]
Example:
dws calendar participant list --event <EVENT_ID>
Flags:
--event string 日程 ID (必填)
```
### 添加参会人
```
Usage:
dws calendar participant add [flags]
Example:
dws calendar participant add --event <EVENT_ID> --attendees <USER_ID_1>,<USER_ID_2>
dws calendar participant add --event <EVENT_ID> --attendees <USER_ID> --optional
Flags:
--event string 日程 ID (必填)
--attendees string 参会人 userId 列表,逗号分隔 (必填,最多500人)
--optional 参会人可选 (默认必选参会人)
```
### 移除参会人
> **CAUTION:** 写操作 — 执行前须用户确认。
```
Usage:
dws calendar participant delete [flags]
Example:
dws calendar participant delete --event <EVENT_ID> --attendees <USER_ID> --yes
Flags:
--event string 日程 ID (必填)
--attendees string 参会人 userId 列表,逗号分隔 (必填)
```
### 搜索会议室
> 此指令可用于搜索当前用户可用的会议室。**注意**,大部分会议室**仅在工作时间**可用,如果检索时间不在工作时间可能查不到任何结果。
> 此指令搜索到的会议室结果中,有两个值需要注意:
- customApprovalProcess: true - 表示该会议室设置了自定义审批流程,只能通过客户端完成预订。
- supportRecurring: true - 表示该会议室支持循环预定;false - 表示不支持循环预定,直接加入到循环日程会失败。
```
Usage:
dws calendar room search [flags]
Example:
dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"
dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --group-id <GROUP_ID>
dws calendar room search --room-name 永澄亭 # 注意:用户即使说「永澄亭会议室」,也应仅传「永澄亭」
dws calendar room search # 不传 --start/--end 时默认当前时间起 1 小时
Flags:
--start string 开始时间 ISO-8601 (可选,不传则默认当前时间+1分钟缓冲)
--end string 结束时间 ISO-8601 (可选,不传则默认当前时间+1 小时)
--group-id string 会议室分组ID(可选,留空查根目录;超100条时需按分组查询)
--room-name string 按会议室名称过滤(可选,服务端模糊匹配;传入前必须由调用方精简,只保留核心专名)
```
> **时间约束(API 限制)**:`start` 必须是未来的时间(服务端校验:start can not less current time)。
> - 若传入的 `--start` 早于当前时间,CLI 会自动修正为 `now + 1min`,调用方无需额外处理。
> - 若传入的 `--end` 早于当前时间,CLI 直接报错——无法检索已过去的时间段。
> - **最佳实践**:调用方在组装时间参数时应确保 start/end 都是未来时间;若不确定,可省略 `--start`/`--end` 让 CLI 使用默认值(当前时间起 1 小时)。
**名称过滤使用规范**:`--room-name` 适用于用户说「预定永澄亭」「约西湖厅」这类按名找会议室的场景。
- **服务端是模糊匹配,但匹配词越精简命中率越高**,关键疗法:**调用方必须在调用 CLI 前自行精简名称,CLI 不会再做任何删减**。
- 常见需要剔除的用户口语后缀(仅示例,实际场景由模型自行判断):「会议室」「大会议室」「小会议室」「厅」「房」等。
- 示例对映:
- 用户:「帮我订永澄亭会议室」 → `--room-name 永澄亭`
- 用户:「西湖厅有空吗」 → `--room-name 西湖厅`(本身就是专名,不删即可)
- 用户:「预定贡嘎山大会议室」 → `--room-name 贡嘎山`
**优先路径**:当用户给了会议室中文名时,**优先用 `room search --room-name <核心专名> --start <开始时间> --end <结束时间>`**代替「先走 `list-groups`再遍历」的旧流程。`--room-name` 可与 `--group-id` 同时使用,表示「在指定分组内按名称过滤」。若返回空列表,再降级使用 `list-groups` 定位分组再查。
**`roomId` 与用户说的话不是一回事**:用户说的「C6-4-06-N 贡嘎山」等是**展示名/编号文案**,**绝不能**直接填进 `room add --rooms`。`--rooms` 只接受上一步 `room search`(或同类接口)返回 JSON 里的 **`rooms[].roomId`**。形态上多为**小写十六进制串**(长度以接口为准,例如 `e6b7b65b8b30fb707afcf6c3b699f028003e6834fdd7fee7`)。含**中文、空格、连字符拼接的楼层编号**、或凭空调 UUID/纯数字「试格式」——一律视为非法,必须先搜房再取返回字段。
> 如果知道roomId,想查该会议室的预订记录,直接用dws calendar busy search 指令
---
### 预定会议室
```
Usage:
dws calendar room add [flags]
Example:
dws calendar room add --event <EVENT_ID> --rooms <ROOM_ID>
Flags:
--event string 日程 ID (必填)
--rooms string 会议室 ID 列表 (必填)
```
> room是会议室,用于线下开会场景。将room加入到日程完成预订
> 重复性日程,预订会议室时,必须设置 循环结束时间(recurrence-end-date),noEnd 或者 指定循环次数 都无法完成预定。
### 移除会议室
> **CAUTION:** 写操作 — 执行前须用户确认。
```
Usage:
dws calendar room delete [flags]
Example:
dws calendar room delete --event <EVENT_ID> --rooms <ROOM_ID> --yes
Flags:
--event string 日程 ID (必填)
--rooms string 会议室 ID 列表 (必填)
```
### 会议室分组列表
```
Usage:
dws calendar room list-groups [flags]
Example:
dws calendar room list-groups
dws calendar room list-groups --page-size 20 --page-index 0
Flags:
--page-index string 分页起始位置 (可选,不填默认 0)
--page-size string 页大小 (可选,不填默认 100,超过 100 按 100 处理)
```
### 查询用户 / 会议室闲忙状态
```
Usage:
dws calendar busy search [flags]
Example:
# 查用户闲忙
dws calendar busy search --users <USER_ID_1>,<USER_ID_2> \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"
# 查会议室闲忙
dws calendar busy search --rooms <ROOM_ID_1>,<ROOM_ID_2> \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"
# 同时查用户 + 会议室
dws calendar busy search --users <USER_ID> --rooms <ROOM_ID> \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"
Flags:
--end string 结束时间 ISO-8601 (必填)
--start string 开始时间 ISO-8601 (必填)
--users string 用户 ID 列表,逗号分隔 (与 --rooms 至少其一)
--rooms string 会议室 ID 列表,逗号分隔 (与 --users 至少其一)
```
> **说明**:
> - `--users` 与 `--rooms` 必须至少指定其一,可以同时指定;CLI 会做前置校验,两者都为空会直接报错。
> - 查询会议室闲忙前,可先用 `dws calendar room search` 或 `dws calendar room list-groups` 拿到 roomId。
> - 返回结果中的忙碌时段仅包含粗粒度的时间信息,不包含日程内容细节(如标题、参会人、地点),以保护隐私。
### 建议日程时间
```
Usage:
dws calendar event suggest [flags]
Example:
dws calendar event suggest --users userId1,userId2 --duration 60
dws calendar event suggest --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T18:00:00+08:00" --users userId1
dws calendar event suggest --users userId1 --duration 30 --timezone Asia/Tokyo
Flags:
--start string 推荐时间范围开始 ISO-8601 (默认当前时间)
--end string 推荐时间范围结束 ISO-8601 (默认次日18点)
--timezone string 时区 IANA 格式 (默认 Asia/Shanghai)
--users string 参会人 userId 列表,逗号分隔
--duration string 日程持续时间,单位分钟 (默认30)
> 对于非明确时间或一段时间范围的约会场景,可基于所有参会人的忙闲状态,推荐多个可用的时间块方案,用于解决会议时间协调问题。
```
### 响应日程
```
Usage:
dws calendar event respond [flags]
Example:
dws calendar event respond --id <EVENT_ID> --status accepted
dws calendar event respond --id <EVENT_ID> --status declined
dws calendar event respond --id <EVENT_ID> --status tentative
Flags:
--id string 日程 ID (必填)
--status string 响应状态: needsAction(未操作)|accepted(接受)|declined(拒绝)|tentative(暂定) (必填)
```
> **说明**:作为日程参会人,设置自己的响应状态(接受、拒绝、暂定)。`--status` 可选值:`needsAction`(未操作,默认值)、`accepted`(接受)、`declined`(拒绝)、`tentative`(暂定)。
## 意图判断
用户说"日程/会议/约会/日历":
- 查看 → `event list`
- 详情 → `event get`
- 创建/约/给自己留时间块/个人日程 → `event create`(带参会人时加 `--attendees`,循环日程加 `--recurrence-*`)
- 修改/改时间/改描述 → `event update`(支持修改标题、时间、描述、时区、循环规则)
- 取消/删除 → `event delete`
- 推荐时间/什么时候有空/协调时间 → `event suggest`
- 接受/拒绝/暂定日程 → `event respond`
用户说"参会人/与会者":
- 查看 → `attendee list`
- 邀请/添加 → `attendee add --attendees <USER_ID>`(可选参会人加 `--optional`)
- 移除 → `attendee delete --attendees <USER_ID>`
用户说"会议室/订会议室":
- 哪个空闲 → `room search`
- 按名找会议室(如「永澄亭」「永澄亭会议室」「约西湖厅」)→ 先在模型层精简名称(剔除「会议室」等通用后缀),再用 `room search --room-name <核心专名>`
- 预订
- 给已有日程订会议室 → `room add --event <已有EVENT_ID> --rooms <ROOM_ID>`
- 创建新日程并订会议室 → `event create --rooms`(仅当日程尚不存在时)
- 取消预定 → `room delete`
- 分组 → `room list-groups`,取 groupId 后 `room search --group-id`(可再叠加 `--room-name` 在分组内过滤)
用户说"有空吗/忙不忙/闲忙":
- 查询用户闲忙 → `busy search --users <USER_ID>`
- 查询会议室闲忙 → `busy search --rooms <ROOM_ID>`
- 用户 + 会议室一起查 → `busy search --users <USER_ID> --rooms <ROOM_ID>`
用户说"日程附件/给会议加文件/上传日程材料":
- 添加 → `attachment add`(先用钉盘上传得 fileId,再 `attachment add --files <fileId>:<name>`)
用户说"我有几个日历/查所有日历/共享日历本":
- 列表 → `book list`(主日历 id 固定为 `primary`)
用户说"查下xxx的日程安排":
- 查询是否有共享关系 -> `book list`
- 场景1: 共享日历本相关查询在开源 dws v1.0.30 暂不支持(`--calendar-id` 未暴露);默认查询 primary 主日历
- 场景2: 共享日历本中没有来自 xxx 的。那么通过 `busy search -- <USER_ID>`,查询xxx的忙闲安排
## 核心工作流
### 创建会议 + 邀请参会人 + 预订会议室
`event create` 支持 `--attendees` 在创建时直接指定参会人,**自 calendar MCP v2 起**也支持 `--rooms` 在创建时一并预定会议室;旧流程的「先创建日程再 `room add`」依然有效。
**关键区分**:`event create --rooms` 仅在**日程尚不存在**时使用;若日程已存在(同一会话刚创建、或用户指向已有日程),必须走「给已有日程订会议室」流程(见下方),**禁止**重复 `event create`。
**方式一:创建时一步完成(仅当日程尚不存在时推荐)**
```bash
# Step 1: 搜索空闲会议室,记下 roomId
dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json
# 若返回错误(会议室超100条),先查分组再按分组搜索:
# dws calendar room list-groups --format json
# dws calendar room search --start ... --end ... --group-id <GROUP_ID> --format json
# Step 2: 创建日程时直接指定参会人 + 会议室
dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \
--attendees userId1,userId2 \
--rooms <ROOM_ID_FROM_STEP1> --format json
```
**方式二:先创建日程,再单独添加参会人 / 会议室**
```bash
# Step 1: 创建日程 — 提取 eventId
dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json
# Step 2: 添加参会人(必须用 Step 1 返回的 eventId)
dws calendar participant add --event <EVENT_ID> --attendees userId1,userId2 --format json
# Step 3: 搜索空闲会议室
dws calendar room search --start ... --end ... --format json
# Step 4: 预定会议室
dws calendar room add --event <EVENT_ID> --rooms <ROOM_ID> --format json
```
### 给已存在的日程加附件
```bash
# Step 1: 用钉盘上传文件,得到 fileId(参见 dws drive 系列命令)
# Step 2: 把附件挂到指定日程
dws calendar attachment add --event <EVENT_ID> --files <FILE_ID>:report.pdf,<FILE_ID2>:slides.pptx --format json
```
### 查看日程列表
```bash
dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `event create` | `eventId` | attendee/room/attachment 操作的 --event |
| `event list` | `events[].eventId` | event get/update/delete/respond 的 --id |
| `event suggest` | 推荐的时间段 | event create 的 --start/--end |
| `event respond` | 响应结果 | — |
| `room search` | `rooms[].roomId` | room add 的 --rooms 或 event create 的 --rooms |
| `room list-groups` | `groups[].groupId` | room search 的 --group-id |
| 钉盘上传 | 文件 `fileId` | attachment add 的 --files `<fileId>:<name>` |
## 注意事项
- 时间格式: `event create/update`、`event list`、`busy search` 和 `event suggest` 用 ISO-8601
- 时区: `event create/update` 和 `event suggest` 支持 `--timezone` 指定 IANA 时区(如 `Asia/Shanghai`、`America/New_York`),不传默认 `Asia/Shanghai`
- 创建日程时可通过 `--attendees` 直接指定参会人(最多500人),也可创建后用 `attendee add --attendees ...` 单独添加
- `--attendees` 和 `--open-dingtalk-ids` 至少传一个(如果需要指定参会人)
- 添加参会人时可通过 `--optional` 设为可选参会人(默认必选)
- `event suggest` 根据参会人闲忙自动推荐合适时间,适合会议时间未确定时使用
- 创建日程**支持**通过 `--rooms` 一步预定会议室(`event create --rooms roomId1,roomId2`);若创建后再加,仍可用 `room add`
- `room search` 不带 `--group-id` 时查根目录;企业会议室超过 100 条会报错,此时需先 `room list-groups` 获取分组,再按分组逐一查询
- `room list-groups` 支持 `--page-size` / `--page-index` 分页(schema 类型为字符串)
- **`event create --rooms` / `room add --rooms` 的唯一合法来源**:最近一次(同一会话、同一时段窗口)`room search` 返回体中的 `roomId`;禁止把用户自然语言会议室名当 `roomId` 传入(否则会 `roomId invalid` 等错误)
- **搜房无结果**:在符合早停/用户限定范围内,`room search`(含按分组逐组查)全部返回空或无空闲 → 应**直接向用户报错/说明失败**并结束订房;**禁止**假设 roomId、禁止无合法 `roomId` 时调用 `room add` / `event create --rooms` 试探、禁止用 `event get` 等绕路推断 roomId
- **评测 / 自动化断言**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId`
- **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `<fileId>:<name>` 元素逗号分隔
- **日历本**:开源 dws v1.0.30 `event list` / `event get` 不接受 `--calendar-id`,固定查 primary 主日历
- **已弃用入参**:`--max-results`(event list / list-mine)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [calendar_today_agenda.py](../scripts/calendar_today_agenda.py) | 查看今天/明天/本周日程安排 | `python calendar_today_agenda.py today` |
| [calendar_schedule_meeting.py](../scripts/calendar_schedule_meeting.py) | 一键创建日程+添加参会人+预定会议室;搜房失败时输出明确原因并返回非零退出码 | `python calendar_schedule_meeting.py --title "复盘会" --start "2026-03-15T14:00" --end "2026-03-15T15:00" --users userId1 --book-room` |
| [calendar_free_slot_finder.py](../scripts/calendar_free_slot_finder.py) | 查询多人共同空闲时段 | `python calendar_free_slot_finder.py --users userId1,userId2 --date 2026-03-15` |
## 相关产品
- `dingtalk-conference` (`references/conference.md`) — 视频会议发起/预约/邀请入会/会中控制;日历日程、参会人、会议室管理仍用 calendar
- `dingtalk-contact` (`references/contact.md`) — 搜索同事 userId,用于 attendee add --attendees
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
查询多人共同空闲时段,推荐最佳会议时间
用法:
python calendar_free_slot_finder.py \
--users userId1,userId2,userId3 \
--date 2026-03-15 \
--duration 60
python calendar_free_slot_finder.py \
--users userId1,userId2 \
--date 2026-03-15 \
--start-hour 9 --end-hour 18 \
--duration 30 --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional, Tuple
TZ = timezone(timedelta(hours=8))
SLOT_STEP_MIN = 30
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 fmt_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def parse_busy_intervals(
data: Any,
) -> List[Tuple[datetime, datetime]]:
intervals = []
if not data:
return intervals
items = []
if isinstance(data, list):
items = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, list):
items = inner
elif isinstance(inner, dict):
for user_data in inner.values():
if isinstance(user_data, list):
items.extend(user_data)
elif isinstance(user_data, dict):
items.extend(
user_data.get('busyTimes', [])
)
for item in items:
start_str = item.get('startTime') or item.get('start', '')
end_str = item.get('endTime') or item.get('end', '')
if not start_str or not end_str:
continue
for fmt in (
'%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S',
'%Y-%m-%dT%H:%M%z',
):
try:
s = datetime.strptime(start_str, fmt)
e = datetime.strptime(end_str, fmt)
if s.tzinfo is None:
s = s.replace(tzinfo=TZ)
if e.tzinfo is None:
e = e.replace(tzinfo=TZ)
intervals.append((s, e))
break
except ValueError:
continue
return intervals
def find_free_slots(
day_start: datetime, day_end: datetime,
busy: List[Tuple[datetime, datetime]],
duration_min: int,
) -> List[Tuple[datetime, datetime]]:
busy_sorted = sorted(busy, key=lambda x: x[0])
merged: List[Tuple[datetime, datetime]] = []
for s, e in busy_sorted:
if merged and s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
else:
merged.append((s, e))
free: List[Tuple[datetime, datetime]] = []
cursor = day_start
for bs, be in merged:
if cursor < bs:
gap = (bs - cursor).total_seconds() / 60
if gap >= duration_min:
free.append((cursor, bs))
cursor = max(cursor, be)
if cursor < day_end:
gap = (day_end - cursor).total_seconds() / 60
if gap >= duration_min:
free.append((cursor, day_end))
return free
def main():
parser = argparse.ArgumentParser(
description='查询多人共同空闲时段'
)
parser.add_argument(
'--users', required=True, help='用户 ID 列表,逗号分隔'
)
parser.add_argument(
'--date', required=True, help='查询日期 YYYY-MM-DD'
)
parser.add_argument(
'--duration', type=int, default=60,
help='会议时长(分钟),默认 60',
)
parser.add_argument(
'--start-hour', type=int, default=9,
help='工作日开始小时,默认 9',
)
parser.add_argument(
'--end-hour', type=int, default=18,
help='工作日结束小时,默认 18',
)
parser.add_argument(
'--dry-run', action='store_true', help='仅显示命令'
)
args = parser.parse_args()
try:
date = datetime.strptime(args.date, '%Y-%m-%d')
except ValueError:
print('错误:日期格式应为 YYYY-MM-DD')
sys.exit(1)
day_start = date.replace(
hour=args.start_hour, tzinfo=TZ
)
day_end = date.replace(hour=args.end_hour, tzinfo=TZ)
data = run_dws([
'calendar', 'busy', 'search',
'--users', args.users,
'--start', fmt_iso(day_start),
'--end', fmt_iso(day_end),
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
return
busy = parse_busy_intervals(data)
free = find_free_slots(day_start, day_end, busy, args.duration)
users_list = args.users.split(',')
print(f"\n🕐 空闲时段查询 ({args.date})")
print(f" 参与人: {len(users_list)} 人")
print(f" 会议时长: {args.duration} 分钟")
print(f" 工作时间: {args.start_hour}:00 ~ "
f"{args.end_hour}:00")
print('=' * 50)
if not free:
print(' ❌ 该日无共同空闲时段')
return
print(f"\n✅ 找到 {len(free)} 个可用时段:\n")
for i, (s, e) in enumerate(free, 1):
gap_min = int((e - s).total_seconds() / 60)
label = '⭐ 推荐' if i == 1 else f' 备选{i-1}'
print(f" {label} {s.strftime('%H:%M')} ~ "
f"{e.strftime('%H:%M')} ({gap_min}分钟)")
if __name__ == '__main__':
main()
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""
一键创建日程(可选:带参与者 + 预定空闲会议室)
流程:
1. 若需预定会议室 (--book-room),先搜索空闲会议室;无可用则提前报错退出
2. 使用 event create 一次性完成日程创建 + 添加参与者 + 预定会议室
用法:
python calendar_schedule_meeting.py \
--title "Q1 复盘会" \
--start "2026-03-15T14:00" \
--end "2026-03-15T15:00" \
--users userId1,userId2 \
--book-room
python calendar_schedule_meeting.py --dry-run \
--title "测试" --start "2026-03-15T14:00" --end "2026-03-15T15:00"
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta, timezone
from typing import List, Any, Optional, Tuple
TZ = timezone(timedelta(hours=8))
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 normalize_time(time_str: str) -> str:
for fmt in ('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M',
'%Y-%m-%dT%H:%M:%S'):
try:
dt = datetime.strptime(time_str, fmt)
dt = dt.replace(tzinfo=TZ)
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
except ValueError:
continue
if '+' in time_str or time_str.endswith('Z'):
return time_str
raise ValueError(f"无法解析时间:{time_str}")
def parse_group_ids(raw: str) -> List[str]:
if raw is None:
return []
return [part.strip() for part in raw.split(',') if part.strip()]
def extract_room_candidates(payload: Any) -> Tuple[List[dict], str]:
candidates: Any = []
if isinstance(payload, list):
candidates = payload
elif isinstance(payload, dict):
if isinstance(payload.get('rooms'), list):
candidates = payload.get('rooms', [])
elif isinstance(payload.get('result'), dict):
nested = payload.get('result', {})
if isinstance(nested.get('rooms'), list):
candidates = nested.get('rooms', [])
elif isinstance(nested.get('result'), list):
candidates = nested.get('result', [])
elif isinstance(payload.get('result'), list):
candidates = payload.get('result', [])
if not isinstance(candidates, list):
return [], '返回结构中未找到会议室列表'
valid_rooms: List[dict] = []
placeholder_count = 0
for item in candidates:
if not isinstance(item, dict):
continue
if item.get('roomId') or item.get('id'):
valid_rooms.append(item)
continue
if item.get('labels') is None and len(item) == 1:
placeholder_count += 1
if valid_rooms:
return valid_rooms, f'返回 {len(valid_rooms)} 个有效会议室'
if placeholder_count:
return [], '仅返回占位结果(如 labels:null),无有效 roomId'
if candidates:
return [], '返回了对象列表,但均不含有效 roomId'
return [], '未返回任何会议室'
def main():
parser = argparse.ArgumentParser(
description='一键创建日程(可选:带参与者 + 预定会议室)'
)
parser.add_argument('--title', required=True, help='日程标题')
parser.add_argument('--start', required=True, help='开始时间')
parser.add_argument('--end', required=True, help='结束时间')
parser.add_argument('--desc', default='', help='日程描述')
parser.add_argument('--users', default='', help='参与者 userId,逗号分隔')
parser.add_argument(
'--book-room', action='store_true', help='自动搜索并预定空闲会议室'
)
parser.add_argument(
'--room-group-id', default='',
help='允许搜索的 groupId;同一地点请只传最相关 group,多个仅用于用户明确允许的多个地点'
)
parser.add_argument(
'--dry-run', action='store_true', help='仅显示命令'
)
args = parser.parse_args()
try:
start_iso = normalize_time(args.start)
end_iso = normalize_time(args.end)
except ValueError as e:
print(f"错误:{e}")
sys.exit(1)
# ── Step 1: 若需预定会议室,先搜索空闲会议室 ──────────────────
room_id: Optional[str] = None
room_name: Optional[str] = None
if args.book_room:
print('🏢 搜索空闲会议室...')
group_ids = parse_group_ids(args.room_group_id)
search_scopes = group_ids or [None]
selected_room = None
failure_reasons: List[str] = []
for group_id in search_scopes:
scope_label = f'group {group_id}' if group_id else '根目录'
print(f' - 查询范围: {scope_label}')
search_args = [
'calendar', 'room', 'search',
'--start', start_iso,
'--end', end_iso,
'--available',
'--format', 'json',
]
if group_id:
search_args.extend(['--group-id', group_id])
rooms_data = run_dws(search_args, dry_run=args.dry_run)
if args.dry_run:
continue
if not rooms_data:
failure_reasons.append(f'{scope_label}: room search 执行失败')
continue
rooms, detail = extract_room_candidates(rooms_data)
if rooms:
selected_room = rooms[0]
break
failure_reasons.append(f'{scope_label}: {detail}')
if not args.dry_run:
if selected_room:
room_id = selected_room.get('roomId') or selected_room.get('id')
room_name = selected_room.get('roomName') or selected_room.get('name')
print(f' ✓ 找到空闲会议室: {room_name} ({room_id})')
else:
print(f' ✗ {start_iso} ~ {end_iso} 时段内无可用会议室')
for reason in failure_reasons:
print(f' - {reason}')
print(' 请向用户汇报失败,或询问是否放宽范围/改时间。')
sys.exit(2)
# ── Step 2: 一次性创建日程(含参与者 + 会议室) ────────────────
print('\n📅 创建日程...')
create_args = [
'calendar', 'event', 'create',
'--title', args.title,
'--start', start_iso,
'--end', end_iso,
'--format', 'json',
]
if args.desc:
create_args.extend(['--desc', args.desc])
if args.users:
create_args.extend(['--attendees', args.users])
if room_id:
create_args.extend(['--rooms', str(room_id)])
result = run_dws(create_args, dry_run=args.dry_run)
if not result:
sys.exit(1)
# 解析响应
event_id = None
if not args.dry_run and isinstance(result, dict):
# MCP 响应通常嵌套在 result 字段内: {"result": {"id": "..."}}
inner = result.get('result', result)
if isinstance(inner, dict):
event_id = inner.get('eventId') or inner.get('id')
else:
event_id = result.get('eventId') or result.get('id')
# 输出结果摘要
parts = []
if event_id:
parts.append(f'eventId: {event_id}')
if args.users:
parts.append(f'参与者: {args.users}')
if room_name:
parts.append(f'会议室: {room_name}')
detail_str = f" ({', '.join(parts)})" if parts else ''
print(f' ✓ 日程已创建{detail_str}')
print('\n✅ 完成!')
if __name__ == '__main__':
main()
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
查看今天/明天/本周的日程安排
用法:
python calendar_today_agenda.py # 今天
python calendar_today_agenda.py today # 今天
python calendar_today_agenda.py tomorrow # 明天
python calendar_today_agenda.py week # 本周
python calendar_today_agenda.py --dry-run # 仅显示命令
"""
import sys
import json
import subprocess
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional
TZ = timezone(timedelta(hours=8))
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 get_range(scope: str):
now = datetime.now(TZ)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
if scope == 'today':
return today, today + timedelta(days=1)
elif scope == 'tomorrow':
t = today + timedelta(days=1)
return t, t + timedelta(days=1)
elif scope == 'week':
ws = today - timedelta(days=today.weekday())
return ws, ws + timedelta(days=7)
return today, today + timedelta(days=1)
def fmt_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def fmt_time(iso_str: str) -> str:
if not iso_str:
return '??:??'
try:
for fmt in ('%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S'):
try:
dt = datetime.strptime(iso_str, fmt)
return dt.strftime('%H:%M')
except ValueError:
continue
return iso_str[:16]
except Exception:
return iso_str[:16]
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
scope = args[0] if args else 'today'
if scope not in ('today', 'tomorrow', 'week'):
print(__doc__)
sys.exit(1)
start, end = get_range(scope)
data = run_dws([
'calendar', 'event', 'list',
'--start', fmt_iso(start),
'--end', fmt_iso(end),
'--format', 'json',
], dry_run=dry_run)
if dry_run:
return
events = []
if isinstance(data, list):
events = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
events = inner.get('events', [])
elif isinstance(inner, list):
events = inner
label = {'today': '今天', 'tomorrow': '明天', 'week': '本周'
}.get(scope, scope)
print(f"\n📅 {label}日程 ({start.strftime('%m-%d')} ~ "
f"{end.strftime('%m-%d')})")
print('=' * 50)
if not events:
print(' ✅ 暂无日程,自由安排!')
return
for e in events:
if not isinstance(e, dict):
print(f" 🕐 {e}")
continue
title = e.get('summary') or e.get('title', '无标题')
s = e.get('start', {})
ed = e.get('end', {})
start_t = fmt_time(
s.get('dateTime', '') if isinstance(s, dict) else str(s)
)
end_t = fmt_time(
ed.get('dateTime', '') if isinstance(ed, dict) else str(ed)
)
loc = e.get('location', {})
loc_str = (loc.get('displayName', '')
if isinstance(loc, dict) else str(loc or ''))
line = f" 🕐 {start_t}-{end_t} {title}"
if loc_str:
line += f" 📍{loc_str}"
print(line)
print(f"\n合计: {len(events)} 场日程")
if __name__ == '__main__':
main()
+46
View File
@@ -0,0 +1,46 @@
---
name: dingtalk-chat
description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/拉人进群/改群名/搜索群/群成员管理/@消息/撤回消息/机器人群发/Webhook通知/发图片或文件到群。Distinct from dingtalk-ding(紧急DING消息/短信/电话)、dingtalk-mail(邮件)、dingtalk-edu-group(班级群)。命令前缀:dws chat。
cli_version: ">=0.2.14"
metadata:
category: product
stability: experimental
requires:
bins:
- dws
---
# 钉钉群聊 / 消息 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.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> 命令参考:[chat.md](references/chat.md);表情:[chat-emoji-list.md](references/chat-emoji-list.md);剧本:[01-messaging.md](references/01-messaging.md)。
## 意图表
| 用户说 | 命令 |
|--------|------|
| "发消息给张三" | `dws chat message send --open-dingtalk-id <id> --title "<标题>" --text "<内容>"` |
| "发到XX群" | `dws chat search --query "<群名>"` → `dws chat message send --group <openConversationId> --title "<标题>" --text "<内容>"` |
| "建群" / "拉人进群" | `dws chat group create` / `dws chat group members add` |
| "改群名" / "踢人" | `dws chat group rename` / `dws chat group members remove --yes`(踢人不可逆,确认目标后加 --yes)|
| "@我消息" / "查群聊记录" | `dws chat message list` |
| "用机器人发消息" | `dws chat message send-by-bot --robot-code <code> --group <id> --title "<标题>" --text "<内容>"` |
| "Webhook 推一条" | `dws chat message send-by-webhook --token <token> --title "<标题>" --text "<内容>"` |
| "撤回机器人消息" | `dws chat message recall-by-bot --robot-code <code> --group <openConversationId> --keys <processQueryKey>`(只能撤回机器人发的;撤回普通用户消息开源 dws v1.0.30 暂不支持)|
> **注**:v1.0.30 起 `chat message send / send-by-bot / send-by-webhook` 全部强制 `--title` 必填(单聊群聊都要)。
## 跨产品协作
- 收件人是人名 → 先用 `dingtalk-contact` 或 `dingtalk-aisearch` 拿 `openDingTalkId` / `userId`
- 要发图片/文件 → 先 `dt_media_upload` 上传 → `python scripts/extract_media_id.py "<URL>"` 提取 mediaId → 再用 `--media-id`
- 紧急升级(应用内/短信/电话)→ 切到 `dingtalk-ding`
- 发邮件 → 切到 `dingtalk-mail`
@@ -0,0 +1,15 @@
# 消息沟通
> **安全红线**:收件人搜索返回多条/零条结果、或名称不完全匹配时,**必须列出候选项让用户确认**,严禁自动选择第一条。即使支持机器人消息撤回 `chat message recall-by-bot`,对方可能已读,发错代价高。禁止自行发送"测试消息"验证通道。
> lite recipe 见 `dws-shared/references/best_practices/_common/lite-recipes.md`。
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-group-chat | **优先**:`python scripts/chat_export_messages.py --query "<群名>" --time "<yyyy-MM-dd HH:mm:ss>" [--no-forward] [--limit N] [--output messages.json]`(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
| query-private-chat | **优先**:`python scripts/chat_history_with_user.py --name "<姓名>" --time "<yyyy-MM-dd HH:mm:ss>" [--no-forward] [--limit N] [--output messages.json]`(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list-direct --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 `dingtalk-ding/references/ding.md`)<br>2. `chat message send --group <openConversationId> --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](./chat.md))<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
| send-by-bot | **多群批量优先**:`python scripts/bot_broadcast.py --robot-code <robotCode> --chats <id1>,<id2> --title "<标题>" --text "<内容>"`<br>单群:1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"` |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)<br>用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 |
| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)<br>触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`<br>**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 `dingtalk-contact/references/contact.md` 意图判断)<br>翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`<br>按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start <ISO> --end <ISO>` |
@@ -0,0 +1,209 @@
# 钉钉默认表情列表(emoji 回应可用)
> 本文件列出钉钉支持的所有用户可见默认表情(showType=1),共 199 个。
>
> 使用规则:
> - 用户描述的表情命中下表中的 `name` → 使用 `chat message add-emoji --emoji <name>` 贴 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 |
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more