Compare commits

..
12 Commits
Author SHA1 Message Date
修雨 4c5f1faeb1 feat: align open CLI with dws-wukong via cedar discovery version code (#509)
Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
2026-06-26 21:49:38 +08:00
修雨 5833e71751 ci(mirror): localize README for the Gitee mirror (#513)
The Gitee mirror force-pushes main verbatim, so Chinese users saw a
README whose top install commands point at raw.githubusercontent.com
(hard to reach in China) and a coverage badge that fails to render
(the relative .github/badges/coverage.svg can't be served by Gitee —
gitee raw returns a signed, expiring URL with content-type text/plain).

Add a Gitee-only post-process step: build a gitee-main branch on top of
origin/main and rewrite README.md / README_zh.md before pushing —
(1) raw.githubusercontent.com/<repo>/main -> gitee.com/<repo>/raw/main
(2) the coverage badge -> a shields.io static badge whose percentage is
read from the repo's coverage.svg and colored by threshold.

GitHub's README is untouched; only the Gitee copy is rewritten. The
branch is rebuilt from origin/main every run, so it stays a clean
single-commit delta and never drifts.
2026-06-26 16:55:04 +08:00
修雨 0e690fbe4e fix(install): define $LatestUrl in install.ps1 so version resolution works (#512)
Resolve-LatestVersion referenced $LatestUrl (lines 185/197) but the
variable was never defined, so on the default GitHub path both
Invoke-WebRequest calls failed with a null Uri. With
$ErrorActionPreference = "Stop" the script then hit Write-Err and
exit 1 — closing freshly-launched PowerShell windows instantly
(the reported "闪退"). Every user on the default `latest` path was
affected; the Bash installer was unaffected because it inlines the URL.

Define $LatestUrl = "https://github.com/$Repo/releases/latest", mirroring
the Bash installer. Verified end-to-end with pwsh 7.5: the script now
resolves the latest tag, downloads, checksum-verifies and installs.
2026-06-26 16:27:31 +08:00
Ariand修雨 f7e8106a72 docs(devapp): add image-upload recipe + "discovering commands" to dingtalk-dev skill (#508)
* docs(devapp): add image-upload recipe + "discovering commands" to dingtalk-dev skill

The dingtalk-dev skill could set an app/robot icon via --icon-media-id but
never documented where a mediaId comes from: the dev command set has no
upload command, so a mediaId must be fetched from DingTalk's OpenAPI. Agents
had to guess the flow. The per-resource refs also lacked a uniform pointer to
self-discover commands and params, so they leaned on memory instead of --help
/ schema.

recipes.md: new "上传图片拿 mediaId" recipe — credentials get -> gettoken ->
OpenAPI /media/upload (multipart field `media`, type=image) -> robot config /
app update --icon-media-id -> read back. Includes a curl example and notes the
token TTL (~7200s, rate-limited) and a square-icon hint.

references/*.md: append a Chinese "发现命令" block to each of the 10 product
refs (app, credentials, webapp, permission, member, security, robot, version,
event, connect). Each block shows that group's own `--help` plus
`dws schema dev.app.<group>.<method>` (connect uses `dws schema dev.connect`),
mirroring SKILL.md's MUST DO.

Verified end-to-end on a real app (unifiedAppId via dws dev): uploaded a PNG
through /media/upload, set the robot icon with the returned mediaId, and
`robot get` reflected the new iconMediaId with robotStatus=ONLINE. All 10
`--help` targets and the 9 `dws schema dev.app.*` paths + `dws schema
dev.connect` resolve.

* docs(changelog): note dingtalk-dev mediaId recipe + command discovery (#508)

---------

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-06-25 23:46:48 +08:00
修雨 87a9b5b9be chore: drop dead fork dev-app cruft (feat/dws-devapp) (#507)
Two leftovers referenced the decommissioned wxianfeng fork branch
`feat/dws-devapp`, both now obsolete after the dev-app work landed on main
(v1.0.42) and the installers were repointed to DingTalk-Real-AI (#505):

  - .github/workflows/auto-dev-release.yml — triggered only on push to
    feat/dws-devapp (a branch that does not exist on this repo, so it never
    fires). Its purpose — auto-publishing fork dev-preview releases for
    install-devapp.sh — is gone now that install-devapp.sh pulls stable
    releases from DingTalk-Real-AI.
  - docs/devapp-yulan-command-routing.md — a 2026-06-05 draft design doc
    pinned to the fork branch and the pre-rename `devapp` command tree,
    superseded by the shipped `dws dev` command set and the rewritten
    docs/devapp-agent-install-guide.md.

After this, the repo has zero `wxianfeng` / `feat/dws-devapp` references.
2026-06-25 21:49:10 +08:00
修雨 97678e6441 fix(devapp): drop the fork — repoint dev installer + docs to DingTalk-Real-AI (#505)
* fix(devapp): point dev installer at DingTalk-Real-AI, drop the fork

install-devapp.sh / .ps1 and the robot quickstart still pulled the dev
binary + dingtalk-dev skill from wxianfeng/dingtalk-workspace-cli's
feat/dws-devapp fork branch. The dev-app work has since landed on main and
shipped in stable v1.0.42 under DingTalk-Real-AI, so the fork dependency is
obsolete.

  - DEVAPP_REPO default: wxianfeng/... → DingTalk-Real-AI/...
  - Bootstrap URLs in headers + quickstart: fork feat/dws-devapp → main.
  - Drop "preview/prerelease" wording — releases are now stable; the
    newest-release resolution still works either way.
  - Quickstart China note now points at the standard install.sh Gitee
    mirror (which carries dws dev in v1.0.42); install-devapp.sh pulls its
    binary from github.com, so a gitee-raw script alone would not help China.

Verified: releases?per_page=1 on DingTalk-Real-AI resolves v1.0.42 and the
darwin/​skills assets are present.

Note: docs/devapp-agent-install-guide.md is separately stale (describes the
old source-build flow + the pre-rename `dws devapp` command) and needs its
own rewrite — left out of this change.

* docs(devapp): rewrite agent install guide for binary install + `dws dev`

The guide was stale on two axes:
  - It described the old source-build flow (clone the fork branch + go/make,
    env vars DEVAPP_REPO_URL / DEVAPP_BRANCH / DEVAPP_SOURCE_DIR), but
    install-devapp.sh now downloads a pre-built binary (curl + tar, no
    git/go/make) from DingTalk-Real-AI.
  - Every command used the pre-rename `dws devapp ...`; the command is now
    `dws dev app ...`.

Rewrite against the real `dws dev` tree (verified from the binary):
  - install: DingTalk-Real-AI binary installer + correct env (DEVAPP_REPO /
    DEVAPP_VERSION / DWS_INSTALL_DIR / DWS_NO_SKILLS) + a China Gitee note.
  - skill name corrected to `dingtalk-dev`.
  - commands: `dws dev app {list,get,create,update,enable,disable,delete,
    credentials,permission,member,robot,security,version,webapp,event}` with
    real flags (--confirm-name, --scope-values, --user-ids, --redirect-urls,
    --version-id/--confirmed-sensitive), async robot create via submit/result,
    version publish gated by check-approval.
2026-06-25 21:04:38 +08:00
修雨 67090ae09f docs: add China (Gitee) install for the standalone Skills installer (#504)
The China-mirror section documented the main install.sh and the npm
package, but not the standalone install-skills.sh — even though that
script already honours DWS_GITEE_REPO and auto-falls back to Gitee when
GitHub is unreachable. The Skills install section only showed the GitHub
URL, so China users (and docs curated from this README) had no China
entry point for skills.

Add a "Skills only (Gitee mirror)" item to both China-mirror sections and
a pointer next to the Skills install command, in README.md and README_zh.md.
2026-06-25 21:04:34 +08:00
修雨 6f042f9167 fix(release): Gitee mirror reads real attach ids + dedups duplicate assets (#502)
The verify-replace mirror listed attachments via /releases/{id}, whose
"assets" array omits the attach id. DELETE /attach_files/{id} was therefore
called with an empty id and silently no-op'd, so a stale asset was never
removed — instead a second (correct) copy was uploaded. Gitee then serves the
OLDER attachment by name, so the stale darwin binaries kept winning and failed
install.sh's checksums.txt verification on macOS (国内 install broken).

Fix:
  - List attachments via the dedicated /attach_files endpoint, which DOES
    return the numeric id needed for deletion.
  - Treat duplicates: collect every attach id carrying a given name; when >1,
    delete them all and upload exactly one fresh, correct file. count==1 still
    does the byte-identical skip / stale-replace; count==0 uploads new.

Self-heals the existing v1.0.42 darwin duplicates on the next mirror run.
2026-06-25 20:10:34 +08:00
meng93 78dd4aaa4b Merge pull request #501 from DingTalk-Real-AI/fix/gitee-mirror-verify-replace
fix(release): Gitee mirror verifies content + replaces stale assets
2026-06-25 17:07:23 +08:00
修雨 088a4d67ae fix(release): Gitee mirror verifies content and replaces stale assets
The v1.0.42 Gitee release served darwin-amd64/arm64 binaries that did NOT
match checksums.txt (the macOS binaries are ad-hoc signed and differed
between the GitHub release and the earlier mirror run), so install.sh's
checksum verification failed for China macOS users. The previous skip-if-name-
present logic could not repair this — it skipped the stale assets.

sync-to-gitee.sh now verifies by content: for each artifact it compares the
sha256 of the asset already on Gitee against the local file (downloaded from
the GitHub release), and

  • skips it when byte-identical,
  • deletes + re-uploads it when present but stale,
  • uploads it when missing,

bringing the Gitee release into byte-for-byte agreement with the GitHub
release that checksums.txt describes. Re-running the Sync-release-to-gitee
workflow now self-heals a mismatched mirror.

bash -n + sha256 helper validated locally.
2026-06-25 16:54:46 +08:00
修雨 81f5245c8a Merge pull request #499 from DingTalk-Real-AI/fix/gitee-release-sync-idempotent
fix(release): idempotent Gitee mirror + standalone repair workflow
2026-06-25 16:33:20 +08:00
修雨 c4946c3eaf fix(release): make Gitee mirror idempotent + add standalone repair workflow
The v1.0.42 Release job hit timeout-minutes: 30 mid-upload while mirroring
release assets to Gitee, so the Gitee release ended up missing
dws-windows-arm64.zip and checksums.txt. Root cause + fixes:

- sync-to-gitee.sh now skips assets already attached to the Gitee release,
  so a re-run only uploads what is missing (instead of re-uploading every
  artifact and creating duplicates). It no longer fails when everything is
  already present.
- New workflow sync-release-to-gitee.yml (workflow_dispatch, version input)
  mirrors a published GitHub release's assets to Gitee on its own — it
  downloads the assets from the GitHub release and runs the idempotent sync,
  without running GoReleaser or touching the GitHub release (no outage). Use
  it to repair an incomplete Gitee mirror.
- Bump the Release job timeout 30 -> 60 so a full Gitee upload has room.

bash -n + YAML validated.
2026-06-25 16:21:34 +08:00
172 changed files with 19744 additions and 6476 deletions
-103
View File
@@ -1,103 +0,0 @@
name: Auto Dev Release
# 推到 dev 分支 → 跑测试 → 通过才自动出一个 dev 预览 release。
# install-devapp.sh / .ps1 取 fork 最新 release,所以发完安装链接即最新。
#
# 设计要点:
# - 自包含:测试 + 算版本 + 打 tag + 发 release 全在一个 job,不依赖 tag 触发
# release.yml(GITHUB_TOKEN 推的 tag 本来也不会触发别的 workflow)。
# - 质量门:测试这一步失败,后面发布步骤就不会跑。
# - 不含 npm publish,所以自动发布天然是绿的。
on:
push:
branches:
- feat/dws-devapp
# 串行:两次 push 不会同时算版本号撞车
concurrency:
group: auto-dev-release
cancel-in-progress: false
permissions:
contents: write
jobs:
test-and-release:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
# ---- 质量门:测试不过就不发 ----
- name: Build
run: make build
- name: Test
run: go test -race -count=1 -timeout=5m ./cmd/... ./internal/...
# ---- 算下一个 dev 版本号并打 tag ----
- name: Compute next dev version
id: ver
run: |
set -eu
LATEST=$(git tag -l 'v*-dev.*' --sort=-v:refname | head -1 || true)
if [ -z "$LATEST" ]; then
BASE="v1.0.39"; N=0
else
BASE=$(printf '%s' "$LATEST" | sed -E 's/-dev\.[0-9]+$//')
N=$(printf '%s' "$LATEST" | sed -E 's/.*-dev\.([0-9]+)$/\1/')
fi
NEW="${BASE}-dev.$((N + 1))"
echo "version=$NEW" >> "$GITHUB_OUTPUT"
echo "next dev release: $NEW (prev: ${LATEST:-none})"
- name: Create and push tag
run: |
set -eu
V="${{ steps.ver.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$V"
git push origin "$V"
# ---- 自己发 release(不靠 tag 触发 release.yml)----
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
run: |
set -eu
RCS_VERSION="0.27.0"
curl -fsSL -o /tmp/rcodesign.tar.gz \
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
mkdir -p /tmp/rcodesign
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
rcodesign --version
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post-release packaging
run: ./scripts/release/post-goreleaser.sh
env:
DWS_PACKAGE_VERSION: ${{ steps.ver.outputs.version }}
- name: Upload dws-skills.zip to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ steps.ver.outputs.version }}" dist/dws-skills.zip --clobber
+35 -4
View File
@@ -31,14 +31,45 @@ jobs:
with:
fetch-depth: 0
- name: Push main + tags to Gitee
- name: Push main + tags to Gitee (with README localization)
if: env.GITEE_TOKEN != ''
run: |
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
# 镜像对齐(force:Gitee 始终跟随 GitHub)
git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
git checkout -B gitee-main origin/main
git config user.email "actions@github.com"
git config user.name "github-actions[bot]"
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
done
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
SVG=".github/badges/coverage.svg"
if [ -f "$SVG" ]; then
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
NUM="${PCT%\%}"; INT="${NUM%.*}"
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
done
fi
git add README.md README_zh.md 2>/dev/null || true
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
git push --force --tags "$REMOTE"
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
+4 -1
View File
@@ -12,7 +12,10 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
# idempotent (re-runs only upload missing assets).
timeout-minutes: 60
steps:
- name: Check out repository
@@ -0,0 +1,49 @@
name: Sync release to Gitee
# Manually mirror a published GitHub release's assets to the matching Gitee
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
# step — it does not run GoReleaser and does not touch the GitHub release, so
# there is no release outage. The sync script skips assets already on Gitee, so
# this only uploads what is missing.
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
required: true
type: string
permissions:
contents: read
jobs:
sync-gitee:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Mirror release to Gitee (China)
# Idempotent: uploads only assets not already present on the Gitee release.
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ inputs.version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
+31
View File
@@ -6,6 +6,37 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Added
- **`dingtalk-dev` skill: image-upload → `mediaId` recipe + per-resource command discovery** (`skills/multi/dingtalk-dev/references/`) — documents how to obtain a `mediaId` for app / robot icons via the DingTalk OpenAPI (`credentials get` → `gettoken` → `/media/upload?type=image` → `--icon-media-id` → read back), since the dev command set has no upload command; and adds a "discovering commands" block to all 10 product refs pointing at each group's `--help` and `dws schema dev.app.<group>.<method>` (`dws schema dev.connect` for connect), so agents inspect commands instead of relying on memory.
## [1.0.43] - 2026-06-26
This release aligns the open edition's CLI surface with **dws-wukong** across the communication domain (chat / mail / minutes / todo / calendar / contact / aisearch / live / report / ding) and the structured-office domain (aitable / sheet / drive / wiki / doc), and switches the discovery version code from `bamboo` to `cedar` so the aligned command tree is served from its own discovery config.
### Added
- **`calendar book get|search` and `calendar acl list`** (cedar discovery overrides) — query a specific calendar (primary via `--id primary`), fuzzy-search calendars by name, and list a calendar's access-control entries. Maps to the calendar MCP `get_calendar` / `search_calendar` / `list_acls` tools.
- **`calendar attendee list|add|delete`** (`internal/helpers/calendar_commands.go`) — manage event participants under the wukong-aligned `attendee` naming (equivalent to the legacy `participant` group; calls `get/add/remove_calendar_participant`).
- **`minutes tag list` and `minutes tag query --tag-id`** — list a user's AI-minutes tags and query minutes by tag (`query_user_tag_list` / `query_minutes_by_tag_id`).
- **`minutes list mine|shared|all`** (`internal/helpers/minutes_commands.go`) — list own / shared / all minutes with renamed output fields.
- **`mail folder create|update|delete`, `mail template create|list|get|update|delete`, `mail contact create|list|update|batch-delete`, and `mail message list`** — full mail folder / message-template / contact CRUD plus folder-scoped message listing.
- **`chat file upload`** (`internal/helpers/chat_file.go`) — upload a local file (init/PUT/commit) or a remote URL to a conversation's file space.
- **`todo task add-attachment`** (`internal/helpers/todo_commands.go`) — attach a local file to a todo (multi-step upload).
- **aitable extensions** (`internal/helpers/aitable_extra.go`) — advanced permission / roles, view sub-commands (lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar), section node management, workflow enable/disable, record `upsert` / `share-url` / `history-list` / primary-doc, and field search-options. Helper tools route to the hardcoded `aitable-helper` supplement endpoint.
- **sheet, drive, wiki, doc helper coverage** synced from dws-wukong (`internal/helpers/sheet.go`, `drive.go`, `wiki.go`, `doc.go`).
### Changed
- **Discovery version code `bamboo` → `cedar`** (`internal/market/registry.go`; `discoveryAPIPath = "/cli/discovery/apis/cedar"`) — version codes step by first letter (bamboo → cedar → …); `cedar` carries the dws-wukong alignment. Older binaries keep reading `bamboo`, so the change is isolated to this release line. All test/mock/generator fixtures updated to the cedar path.
- **CLI output envelope aligned with wukong for cross-edition parity** (`internal/app/runner.go`, `internal/compat/registry.go`) — dry-run prints a `DRY-RUN Arguments:` line, successful results carry `success: true`, missing-required-flag wording is unified to `missing required flag(s): --x`, and OutputTransform applies to the response content layer.
- **New flag transforms** (`internal/compat/transform.go`) — `parse_bool` (explicit boolean strings so `--flag false` is honoured) and `attendance_class_check_time` (`HH:mm` → UTC+8 milliseconds for shift check-times).
- **`--calendar-id` accepted on calendar event / participant / room / attachment commands** so calendars other than the primary can be targeted.
### Fixed
- **Client-side validation** for calendar recurrence completeness and attendance schedule / class / group inputs, surfacing input errors before they reach the server.
## [1.0.42] - 2026-06-25
This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds.
+19 -9
View File
@@ -135,6 +135,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
**3. Skills only (Gitee mirror):**
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
```
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
## Upgrade
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
@@ -309,6 +317,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
```
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
**Switching or re-installing with `dws skill setup`:**
@@ -518,25 +528,25 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|---------|---------|:--------:|-------------|-------------|
| Contact | `contact` | 15 | `user` `dept` `label` `relation` | Search users by name / mobile / job-number, batch query, departments, labels & roles, person relations, roster profile & dismissions, current user |
| Chat / IM | `chat` (alias `im`) | 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
| Calendar | `calendar` | 17 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
| Calendar | `calendar` | 23 | `event` `book` `acl` `attendee` `participant` `room` `busy` `attachment` | Events CRUD + suggested times + attachments, calendar books (get / search / primary via `--id primary`), access-control list, attendee management (wukong-aligned naming, `--calendar-id` aware), meeting room booking, free-busy query |
| Todo | `todo` | 16 | `task` `comment` | Create / list / update / done / get / delete tasks, plus task comments |
| Approval | `oa` | 15 | `approval` | Approve / reject / revoke / redirect tasks, pending / initiated / submitted / executed / cc instances, process forms, comments, operation records |
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
| Ding | `ding` | 2 | `message` | Send / recall DING messages |
| Report | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | Create / submit reports, sent & received (inbox / outbox) lists, templates (get / list), statistics, single-entry get |
| AI Tables | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments (prepare-only `upload` + one-shot `upload-file`); datasheet forms; templates |
| Doc | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
| Drive | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | DingTalk drive file ops: list spaces, list / info / download, create folders, one-shot `upload` (three-step composite) or two-phase `upload-info` + `commit`, delete |
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
| Mail | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | List mailboxes, KQL message search, read & send messages, drafts, folders, tags, threads, attachments, address-book user search |
| Sheet | `sheet` | 23 | `range` `filter-view` (top-level: `create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / append, dimension ops, cell merge / unmerge, find / replace, named filter views + sheet-level filters, image write |
| Wiki | `wiki` | 21 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (`create` / `get` / `list` / `search`), members (`add` / `list` / `update`), node tree, docs & files |
| AI Tables | `aitable` | 102 | `base` `table` `record` `field` `view` `section` `advperm` `workflow` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Full CRUD for Bases / datasheets / records / fields / views; node (section) management, advanced permission & roles, automation workflows; record upsert / share-url / history / primary-doc; view lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar; charts & dashboards; import/export; attachments; forms; templates |
| Doc | `doc` | 29 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` `permission` `media` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), permission management, media, upload / download |
| Drive | `drive` | 17 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` `copy` `move` `rename` `search` `permission` | DingTalk drive file ops: list / search / info / download, create folders, upload (one-shot or two-phase), copy / move / rename, permission management (list / mutation / remove), doc-transfer, delete |
| Minutes | `minutes` | 31 | `list` `get` `tag` `update` `mind-graph` `speaker` `permission` `hot-word` `record` `upload` `replace-text` | List AI meeting notes (mine / shared / all), tags (list / query by tag), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker, member permission, hot-word, recording control, upload session |
| Mail | `mail` | 33 | `mailbox` `message` `send` `draft` `folder` `template` `contact` `tag` `thread` `attachment` `user` | List mailboxes, KQL search + folder-scoped message list, read & send messages, drafts, folder CRUD, message templates (CRUD), mail contacts (CRUD), tags, threads, attachments, address-book user search |
| Sheet | `sheet` | 60 | `range` `filter` `filter-view` `cond-format` (+ dimension, float-image, dropdown, csv, merge, find/replace, write-image, …) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / copy / fill / sort / style, dimension ops, filters & filter views, conditional formatting, float images, dropdowns, CSV get/put, cell merge / unmerge, find / replace, image write |
| Wiki | `wiki` | 27 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (create / get / list / search / delete), members (add / list / update / remove), node tree (create / delete / list / search / move / copy / transfer), docs & files |
| DevDoc | `devdoc` | 2 | `article` `error` | Search the DingTalk Open Platform documentation and diagnose API errors |
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
> **331 commands across 18 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **466 commands across 18 products** (after the dws-wukong alignment in 1.0.43). Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
+10
View File
@@ -135,6 +135,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
**3. 单独安装 Skills(Gitee 镜像):**
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
```
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
## 升级
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
@@ -306,6 +314,8 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
```
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
**用 `dws skill setup` 切换或重装:**
+113 -97
View File
@@ -1,4 +1,4 @@
# DevApp 一键安装与 Agent 接入指南
# dws dev 一键安装与 Agent 接入指南
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
@@ -6,45 +6,44 @@
## 一键安装
当前 DevApp 能力在 `feat/dws-devapp` 预览分支上。要安装这个分支里的最新能力,请使用 DevApp 专用安装脚本:
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
```bash
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
### Windows(PowerShell)
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
这个脚本会:
1. 拉取 `wxianfeng/dingtalk-workspace-cli` 的 `feat/dws-devapp` 分支。
2. 使用本地源码构建 `dws`。
3. 安装 `dws` 到默认目录 `~/.local/bin`。
4. 安装 Agent Skill 到本机已检测到的 Agent 目录,只安装通用 `dws` 和 DevApp 专用 `dws-devapp` 两个 skill。
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
> 预览分支安装需要本机已有 `git`、`go` 和 `make`。Go 版本要求以仓库 `go.mod` 为准。
如果 DevApp 能力已经发布到正式 Release,可以改用正式安装命令:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
```
Windows PowerShell 正式安装命令:
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
安装脚本支持这些环境变量:
支持这些环境变量(全部可选):
| 变量 | 说明 |
|---|---|
| `DEVAPP_REPO_URL` | 覆盖源码仓库地址,默认 `https://github.com/wxianfeng/dingtalk-workspace-cli.git` |
| `DEVAPP_BRANCH` | 覆盖安装分支,默认 `feat/dws-devapp` |
| `DEVAPP_SOURCE_DIR` | 使用已有源码目录安装,跳过 clone |
| `DEVAPP_KEEP_SOURCE=1` | 保留临时源码目录,便于调试 |
| `DEVAPP_SKIP_SKILL_SETUP=1` | 跳过自动安装 `dws` 与 `dws-devapp` skill |
| `DEVAPP_SKILL_NAME` | 覆盖 DevApp skill 安装名称,默认 `dws-devapp` |
| `DWS_INSTALL_DIR` | 传给底层 `scripts/install.sh`,覆盖 `dws` 安装目录 |
| `DWS_SKILL_MODE` | 传给底层 `scripts/install.sh`,选择 `mono` 或 `multi` |
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
### 国内加速
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
## 安装后验证
@@ -54,13 +53,13 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
dws version
```
确认 DevApp 命令存在:
确认 `dws dev app` 命令存在:
```bash
dws devapp --help --format json
dws dev app --help --format json
```
如果能看到 `list`、`get`、`create`、`permission`、`robot`、`security`、`version` 等能力,说明 DevApp 已安装成功。
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
确认登录状态:
@@ -77,14 +76,18 @@ dws auth login
登录完成后读取应用列表:
```bash
dws devapp list --format json
dws dev app list --format json
```
## DevApp 是什么
## dws dev 是什么
DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
它让 Agent 可以完成这些工作:
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
- `dws dev doc` — 开放平台开发文档搜索。
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
- 查询、创建、更新、启用、停用、删除开放平台应用。
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
@@ -92,12 +95,12 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
- 查询、申请、移除权限点。
- 管理应用成员。
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
- 创建、查询、更新、启用、停用机器人。
- 异步创建机器人、配置/启停现有机器人。
- 创建版本、发起发布、查询审批和发布状态。
## 给 Agent 使用
安装完成后,可以直接让 Agent 操作 DevApp。
安装完成后,可以直接让 Agent 操作 `dws dev`。
示例:
@@ -114,7 +117,7 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
```
```text
帮我发布这个应用版本,先检查发布前置条件。
帮我发布这个应用版本,先预检是否需要审批。
```
Agent 写操作必须遵循:
@@ -127,34 +130,30 @@ Agent 写操作必须遵循:
## 第一个写操作
推荐用机器人配置作为 smoke test。先 dry-run:
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
提交建号任务(记下返回的 `taskId`):
```bash
dws devapp robot config \
--unified-app-id <unifiedAppId> \
--name "告警机器人" \
--brief "告警通知" \
dws dev app robot submit \
--name "告警助手" \
--robot-name "告警机器人" \
--desc "处理告警通知和事件回调" \
--dry-run \
--format json
```
确认预览无误后执行:
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
```bash
dws devapp robot config \
--unified-app-id <unifiedAppId> \
--name "告警机器人" \
--brief "告警通知" \
--desc "处理告警通知和事件回调" \
--yes \
--format json
dws dev app robot result --task-id <taskId> --format json
```
回读验证:
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
```bash
dws devapp robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
```
## 常用命令
@@ -162,19 +161,21 @@ dws devapp robot get --unified-app-id <unifiedAppId> --format json
### 应用管理
```bash
dws devapp list --format json
dws devapp get --unified-app-id <unifiedAppId> --format json
dws devapp create --name "考勤应用" --dry-run --format json
dws devapp update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
dws devapp inactive --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp active --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp delete --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app list --format json
dws dev app get --unified-app-id <unifiedAppId> --format json
dws dev app create --name "考勤应用" --dry-run --format json
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
```
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
### 凭证查询
```bash
dws devapp credentials get --unified-app-id <unifiedAppId> --format json
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
```
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
@@ -182,68 +183,82 @@ dws devapp credentials get --unified-app-id <unifiedAppId> --format json
### 权限点管理
```bash
dws devapp permission list --unified-app-id <unifiedAppId> --format json
dws devapp permission add --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
dws devapp permission remove --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
dws dev app permission list --unified-app-id <unifiedAppId> --format json
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
```
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
### 机器人配置
### 机器人能力
```bash
dws devapp robot get --unified-app-id <unifiedAppId> --format json
dws devapp robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
dws devapp robot enable --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp robot disable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
dws dev app robot result --task-id <taskId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
```
### 成员与安全
```bash
dws devapp member list --unified-app-id <unifiedAppId> --format json
dws devapp member add --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
dws devapp member remove --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
dws devapp security config --unified-app-id <unifiedAppId> --redirect-url <url> --dry-run --format json
dws devapp security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
dws dev app member list --unified-app-id <unifiedAppId> --format json
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
```
### 网页应用与事件
```bash
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
dws dev app event list --unified-app-id <unifiedAppId> --format json
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
```
### 版本发布
```bash
dws devapp version list --unified-app-id <unifiedAppId> --format json
dws devapp version list --unified-app-id <unifiedAppId> --cursor <nextCursor> --format json
dws devapp version create --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
dws devapp version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version list --unified-app-id <unifiedAppId> --format json
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
```
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
## 安全边界
DevApp 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
默认安全策略:
- 写操作先 dry-run。
- 删除、停用、发布必须由用户确认。
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
- 应用定位优先使用 `agentId`、`unifiedAppId`、`appKey`。
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
## 排障
### `dws devapp` 不存在
### `dws dev app` 不存在
先确认安装的是预览分支源码,而不是正式 Release:
先确认装上的是带 `dws dev` 的版本:
```bash
dws version
dws devapp --help --format json
dws dev app --help --format json
```
如果正式 Release 尚未包含 DevApp,请重新执行本文的一键源码安装命令。
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
### `dws devapp list` 失败
### `dws dev app list` 失败
优先检查登录态:
@@ -254,11 +269,13 @@ dws auth login
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
### 提示"当前用户没有开发者身份"
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
### 页面能操作,但 CLI 或 MCP 提示无权限
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。
先确认当前用户是否满足以下任一条件:
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
- 应用 owner。
- 应用管理员。
@@ -270,18 +287,17 @@ dws auth login
先查当前机器人状态:
```bash
dws devapp robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot get --unified-app-id <unifiedAppId> --format json
```
如果机器人不存在,使用 `robot config` 创建或配置。
如果机器人已存在,继续用 `robot config` 修改配置,或用 `robot enable` 重新启用。
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
## 页面文案建议
用于产品页顶部:
```text
Install DevApp in one command.
Install dws dev in one command.
Let your coding agents manage DingTalk Open Platform apps from the terminal:
create apps, configure robots, apply permissions, manage security settings,
@@ -291,7 +307,7 @@ and publish versions with dry-run safety built in.
中文版本:
```text
一行命令接入 DevApp。
一行命令接入 dws dev。
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -13,9 +13,14 @@
打开终端,整段复制执行:
```bash
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
> ```bash
> DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
> ```
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
### Windows
@@ -23,12 +28,12 @@ curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/fe
打开 PowerShell,整段复制执行:
```powershell
irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
> 能打印出版本号即安装成功(脚本默认装当前最新的预览版)。脚本走 GitHub API 取最新预览版,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
### 登录钉钉
+203
View File
@@ -0,0 +1,203 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/spf13/cobra"
)
// toolMappingParam 描述一个 MCP 参数到 CLI flag + 中文友好名的映射。
type toolMappingParam struct {
Flag string `json:"flag"`
Label string `json:"label"`
Type string `json:"type,omitempty"`
}
// toolMappingEntry 是单个 MCP 工具的映射条目。key 用 RPCName,对齐 SLS 日志的 tool 字段。
type toolMappingEntry struct {
Product string `json:"product"`
CLICommand string `json:"cliCommand"`
DisplayName string `json:"displayName"`
Params map[string]toolMappingParam `json:"params,omitempty"`
}
// toolMapping 是给开放平台日志页渲染用的全量映射契约。
type toolMapping struct {
Version string `json:"version"`
Count int `json:"count"`
Tools map[string]toolMappingEntry `json:"tools"`
}
// newCatalogCommand 提供 `dws catalog export`:把已发现的工具目录投影成
// tool→指令 映射 JSON,供开放平台 MCP/DWS 日志页把 tool/args 渲染成中文友好名。
// 复用 root 注入的带 auth 的 loader(缓存优先;建议先 `dws cache refresh`)。
func newCatalogCommand(loader cli.CatalogLoader) *cobra.Command {
catalogCmd := &cobra.Command{
Use: "catalog",
Short: "导出已发现的工具目录(内部用)",
Hidden: true,
}
var out string
var version string
exportCmd := &cobra.Command{
Use: "export",
Short: "导出 tool→指令 映射 JSON(供开放平台日志页渲染)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
catalog, err := loader.Load(cmd.Context())
if err != nil {
return err
}
mapping := projectToolMapping(catalog, version)
data, err := json.MarshalIndent(mapping, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if strings.TrimSpace(out) == "" {
_, werr := os.Stdout.Write(data)
return werr
}
return os.WriteFile(out, data, 0o644)
},
}
exportCmd.Flags().StringVar(&out, "out", "", "输出文件路径(默认 stdout)")
exportCmd.Flags().StringVar(&version, "version", "dev", "版本号标记")
catalogCmd.AddCommand(exportCmd)
return catalogCmd
}
// projectToolMapping 把 ir.Catalog 投影成 toolMapping 契约。
func projectToolMapping(catalog ir.Catalog, version string) toolMapping {
mapping := toolMapping{Version: version, Tools: make(map[string]toolMappingEntry)}
for _, product := range catalog.Products {
command := ""
if product.CLI != nil {
command = strings.TrimSpace(product.CLI.Command)
}
if command == "" {
command = product.ID
}
for _, tool := range product.Tools {
if tool.Hidden {
continue
}
entry := toolMappingEntry{
Product: command,
CLICommand: tmBuildCLICommand(command, tool),
DisplayName: tmFirstNonEmpty(tool.Title, tmFirstNonEmpty(tmFirstLine(tool.Description), tool.RPCName)),
Params: make(map[string]toolMappingParam),
}
for name, raw := range tmSchemaProperties(tool.InputSchema) {
prop, _ := raw.(map[string]any)
overlay, hasOverlay := tool.FlagOverlay[name]
if hasOverlay && overlay.Hidden {
continue
}
flag := tmKebab(name)
if hasOverlay && strings.TrimSpace(overlay.Alias) != "" {
flag = strings.TrimSpace(overlay.Alias)
}
label := tmMapStr(prop, "title")
if label == "" {
label = tmFirstLine(tmMapStr(prop, "description"))
}
entry.Params[name] = toolMappingParam{
Flag: flag,
Label: label,
Type: tmMapStr(prop, "type"),
}
}
if len(entry.Params) == 0 {
entry.Params = nil
}
mapping.Tools[tool.RPCName] = entry
}
}
mapping.Count = len(mapping.Tools)
return mapping
}
// tmBuildCLICommand 拼出 CLI 命令路径,如 chat + message + list -> "chat message list"。
func tmBuildCLICommand(command string, tool ir.ToolDescriptor) string {
parts := make([]string, 0, 3)
if command != "" {
parts = append(parts, command)
}
if g := strings.TrimSpace(tool.Group); g != "" {
parts = append(parts, g)
}
name := strings.TrimSpace(tool.CLIName)
if name == "" {
name = tool.RPCName
}
parts = append(parts, name)
return strings.Join(parts, " ")
}
func tmSchemaProperties(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
props, _ := schema["properties"].(map[string]any)
return props
}
func tmMapStr(m map[string]any, key string) string {
if m == nil {
return ""
}
s, _ := m[key].(string)
return strings.TrimSpace(s)
}
// tmFirstLine 取第一句中文/换行前的片段,作为长描述的短标签兜底。
func tmFirstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "\n。"); i >= 0 {
return strings.TrimSpace(s[:i])
}
return s
}
func tmFirstNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return strings.TrimSpace(a)
}
return strings.TrimSpace(b)
}
// tmKebab 把 camelCase 参数名转 kebab-case 作为默认 flag。
func tmKebab(s string) string {
var b strings.Builder
for i, r := range s {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('-')
}
b.WriteRune(r - 'A' + 'a')
continue
}
b.WriteRune(r)
}
return b.String()
}
+3 -1
View File
@@ -55,9 +55,11 @@ func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
// `minutes list all` is intentionally provided as a hardcoded helper
// (see internal/helpers/minutes_commands.go) to align with the wukong
// baseline, so it is expected to resolve and is no longer asserted here.
for _, path := range []string{
"chat message list-topic-replies",
"minutes list all",
} {
if cmd := lookupCommand(root, path); cmd != nil {
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
+2 -2
View File
@@ -359,7 +359,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis/bamboo":
case r.URL.Path == "/cli/discovery/apis/cedar":
payload := map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
@@ -433,7 +433,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryT
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis/bamboo":
case r.URL.Path == "/cli/discovery/apis/cedar":
_ = json.NewEncoder(w).Encode(map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
+32
View File
@@ -96,6 +96,7 @@ func Execute() (exitCode int) {
if executed == nil {
executed = root
}
err = rewordRequiredFlagError(err)
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
@@ -114,6 +115,36 @@ func isUnknownCommandError(err error) bool {
return err != nil && strings.Contains(err.Error(), "unknown command")
}
// rewordRequiredFlagError rewrites cobra's default missing-required-flag message
// (`required flag(s) "email" not set`) into the wukong-aligned form
// (`missing required flag(s): --email`). cobra's ValidateRequiredFlags returns
// this error directly (it does not pass through FlagErrorFunc), so it is
// normalised here. The substring "required flag" is preserved for compatibility
// with existing assertions; flag names gain the "--" prefix and quotes are
// dropped so error output matches hardcoded cmdutil.ValidateRequiredFlags.
func rewordRequiredFlagError(err error) error {
if err == nil {
return err
}
const pfx = "required flag(s) "
const sfx = " not set"
msg := err.Error()
if !strings.HasPrefix(msg, pfx) || !strings.HasSuffix(msg, sfx) {
return err
}
mid := strings.TrimSuffix(strings.TrimPrefix(msg, pfx), sfx)
var flags []string
for _, part := range strings.Split(mid, ", ") {
if name := strings.Trim(strings.TrimSpace(part), "\""); name != "" {
flags = append(flags, "--"+name)
}
}
if len(flags) == 0 {
return err
}
return apperrors.NewValidation(fmt.Sprintf("missing required flag(s): %s", strings.Join(flags, ", ")))
}
// flagErrorWithSuggestions provides helpful suggestions for common flag mistakes.
//
// 所有 flag 解析错误都会在 message 末尾追加 "See '<CommandPath> --help' for usage.",
@@ -330,6 +361,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
newCatalogCommand(loader),
newConfigCommand(),
newDoctorCommand(),
newCompletionCommand(root),
+2 -2
View File
@@ -24,7 +24,7 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/cli/discovery/apis/bamboo":
case "/cli/discovery/apis/cedar":
_ = json.NewEncoder(w).Encode(market.ListResponse{
Metadata: market.ListMetadata{Count: 2},
Servers: []market.ServerEnvelope{
@@ -146,7 +146,7 @@ func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
},
},
})
case "/cli/discovery/apis/bamboo":
case "/cli/discovery/apis/cedar":
marketHits.Add(1)
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
default:
+18
View File
@@ -17,6 +17,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -333,6 +334,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
if invocation.DryRun {
// Emit a wukong-aligned human-readable preview on stderr so the dry-run
// surface advertises the resolved MCP arguments without polluting the
// stdout payload (which stays valid JSON in --format json mode). Mirrors
// wukong's "Arguments: {...}" dry-run line; stderr keeps it out of the
// machine-readable channel.
if argsJSON, err := json.Marshal(invocation.Params); err == nil {
fmt.Fprintf(os.Stderr, "DRY-RUN Arguments: %s\n", argsJSON)
}
return executor.Result{
Invocation: invocation,
Response: map[string]any{
@@ -492,6 +501,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
invocation.Implemented = true
// Align with wukong's response envelope: stamp a top-level success=true on
// map payloads that don't already carry a success flag. Business errors
// (success=false) are intercepted above, so reaching here means the call
// succeeded. Additive only — existing keys are never overwritten.
if callResult.Content != nil {
if _, has := callResult.Content["success"]; !has {
callResult.Content["success"] = true
}
}
response := map[string]any{
"endpoint": transport.RedactURL(endpoint),
"content": callResult.Content,
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"encoding/json"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// attendanceScheduleInnerRequired are the fields every scheduleVOS item must
// carry; the backend rejects partial items with an opaque error, so the CLI
// validates them up front (mirrors wukong's attendance.go).
var attendanceScheduleInnerRequired = []string{"userId", "workDate", "classId", "isRest"}
var attendanceGroupTypes = map[string]bool{"FIXED": true, "TURN": true, "NONE": true}
var attendanceApproveTypes = map[string]bool{
"overtime": true, "trip": true, "travel": true, "business_trip": true,
"business-trip": true, "out": true, "leave": true, "patch": true,
"repair_check": true, "repair-check": true,
}
// installAttendanceHook wires attendance-specific PreRunE validators that
// mirror wukong's client-side checks (inner-JSON required fields, group type,
// FIXED conditional requirements, group-update no-op). No-op for other
// products / tools. Preserves any PreRunE NewDirectCommand already installed.
func installAttendanceHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil || strings.TrimSpace(canonicalProduct) != "attendance" {
return
}
var validate func(*cobra.Command) error
switch toolName {
case "generateTurnSchedule":
validate = validateAttendanceScheduleImport
case "create_class_setting":
validate = validateAttendanceClassCreate
case "create_group_setting":
validate = validateAttendanceGroupCreate
case "update_group_setting":
validate = validateAttendanceGroupUpdate
case "update_group_member":
validate = validateAttendanceUpdateMembers
case "save_self_setting":
validate = validateAttendanceSelfSettingSave
case "query_at_approve_template":
validate = validateAttendanceApproveTemplates
default:
return
}
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return validate(c)
}
}
func attFlagString(cmd *cobra.Command, names ...string) string {
for _, n := range names {
if cmd.Flags().Lookup(n) == nil {
continue
}
if v, err := cmd.Flags().GetString(n); err == nil && strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func validateAttendanceScheduleImport(cmd *cobra.Command) error {
raw := attFlagString(cmd, "scheduleVOS", "schedules")
if raw == "" {
return nil // empty is owned by the required-flag check
}
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil // malformed JSON is owned by a separate check
}
if len(items) == 0 {
return apperrors.NewValidation("--scheduleVOS requires at least one schedule entry (empty array not allowed)")
}
for _, item := range items {
for _, f := range attendanceScheduleInnerRequired {
if _, ok := item[f]; !ok {
return apperrors.NewValidation("missing required field: " + f + "(--scheduleVOS 每个排班项必填)")
}
}
}
return nil
}
func validateAttendanceClassCreate(cmd *cobra.Command) error {
raw := attFlagString(cmd, "class-vo", "TopAtClassVO")
if raw == "" {
return nil
}
var vo map[string]any
if err := json.Unmarshal([]byte(raw), &vo); err != nil {
return nil
}
if _, ok := vo["sections"]; !ok {
return apperrors.NewValidation("missing required field: sections(班次时段,--class-vo 内必填)")
}
return nil
}
func validateAttendanceGroupCreate(cmd *cobra.Command) error {
typ := strings.TrimSpace(attFlagString(cmd, "type"))
if typ != "" && !attendanceGroupTypes[typ] {
return apperrors.NewValidation("考勤组类型不合法:--type 应为 FIXED / TURN / NONE 之一")
}
if typ == "FIXED" {
var vo map[string]any
if raw := attFlagString(cmd, "group-vo", "groupVO"); raw != "" {
_ = json.Unmarshal([]byte(raw), &vo)
}
if vo == nil {
vo = map[string]any{}
}
if _, ok := vo["workDayClassList"]; !ok {
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 workDayClassList(工作日班次列表)")
}
if _, ok := vo["defaultClassId"]; !ok {
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 defaultClassId(默认班次 ID)")
}
}
return nil
}
func validateAttendanceGroupUpdate(cmd *cobra.Command) error {
if v := strings.TrimSpace(attFlagString(cmd, "enable-outside-check")); v != "" && v != "true" && v != "false" {
return apperrors.NewValidation("--enable-outside-check must be true or false")
}
for _, f := range []string{"name", "type", "owner", "enable-outside-check", "classIds", "group-vo"} {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
return nil
}
}
return apperrors.NewValidation("至少需要指定一个修改项(--name / --type / --owner / --enable-outside-check / --classIds / --group-vo)")
}
func validateAttendanceUpdateMembers(cmd *cobra.Command) error {
for _, f := range []string{"add-users", "remove-users", "add-extra-users", "remove-extra-users", "add-depts", "remove-depts"} {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
return nil
}
}
return apperrors.NewValidation("至少需要指定一个变更项(--add-users / --remove-users / --add-extra-users / --remove-extra-users / --add-depts / --remove-depts)")
}
func validateAttendanceSelfSettingSave(cmd *cobra.Command) error {
hasField := false
cmd.Flags().Visit(func(f *pflag.Flag) {
switch f.Name {
case "setting-scene", "user", "yes", "format", "debug", "verbose", "dry-run",
"client-id", "client-secret", "fields", "jq", "mock", "timeout":
// control / identity flags, not setting fields
default:
hasField = true
}
})
if !hasField {
return apperrors.NewValidation("至少需要指定一个设置项(--setting-scene / --user 之外的任一字段)")
}
return nil
}
func validateAttendanceApproveTemplates(cmd *cobra.Command) error {
typ := strings.TrimSpace(attFlagString(cmd, "type"))
if typ != "" && !attendanceApproveTypes[typ] {
return apperrors.NewValidation("无效的审批类型:--type 应为 overtime / leave / patch / trip(travel) / business_trip 之一")
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// calendarRecurrenceTools are the calendar leaves whose recurrence fields must
// be supplied as a complete set (the MCP backend does not merge partial
// recurrence, so a partial update would silently overwrite the rule). Mirrors
// wukong's calendar.go event create/update validation.
var calendarRecurrenceTools = map[string]bool{
"create_calendar_event": true,
"update_calendar_event": true,
}
// calendarRecurrenceFlags is the full set of --recurrence-* flags; touching any
// of them requires the core structural fields to be present.
var calendarRecurrenceFlags = []string{
"recurrence-type", "recurrence-interval", "recurrence-range-type",
"recurrence-count", "recurrence-end-date", "recurrence-days-of-week",
"recurrence-day-of-month", "recurrence-month", "recurrence-week-index",
"recurrence-first-day-of-week",
}
// installCalendarHook wires calendar-specific PreRunE validators onto leaf
// commands emitted by BuildDynamicCommands. No-op for non-calendar products and
// calendar tools without extra client-side checks. The hook chain preserves the
// PreRunE that NewDirectCommand already installed by invoking it first.
func installCalendarHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil || strings.TrimSpace(canonicalProduct) != "calendar" {
return
}
if !calendarRecurrenceTools[toolName] {
return
}
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return validateCalendarRecurrence(c)
}
}
// validateCalendarRecurrence refuses a partial recurrence structure. If any
// --recurrence-* flag is set, recurrence-type / interval / range-type must be
// present, and weekly / relativeMonthly patterns require days-of-week. Error
// wording carries the kebab flag names so the messages match wukong and the
// auto-test substring assertions (days-of-week / recurrence-type).
func validateCalendarRecurrence(cmd *cobra.Command) error {
if cmd == nil {
return nil
}
used := false
for _, f := range calendarRecurrenceFlags {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
used = true
break
}
}
if !used {
return nil
}
recType := strings.TrimSpace(calendarFlagString(cmd, "recurrence-type"))
if recType == "" {
return apperrors.NewValidation(
"recurrence 结构不完整:使用任一 --recurrence-* 时必须整体重传完整循环字段" +
"(至少 --recurrence-type / --recurrence-interval / --recurrence-range-type," +
"MCP 不合并部分字段)")
}
if !calendarFlagSet(cmd, "recurrence-interval") {
return apperrors.NewValidation(
"recurrence 结构不完整:缺少 --recurrence-interval(循环间隔,recurrence 整体必填)")
}
if !calendarFlagSet(cmd, "recurrence-range-type") {
return apperrors.NewValidation(
"recurrence 结构不完整:缺少 --recurrence-range-type(循环范围类型,recurrence 整体必填)")
}
if recType == "weekly" || recType == "relativeMonthly" {
if strings.TrimSpace(calendarFlagString(cmd, "recurrence-days-of-week")) == "" {
return apperrors.NewValidation(
"weekly / relativeMonthly 循环必须提供 --recurrence-days-of-week (daysOfWeek)")
}
}
return nil
}
func calendarFlagString(cmd *cobra.Command, name string) string {
if cmd.Flags().Lookup(name) == nil {
return ""
}
v, _ := cmd.Flags().GetString(name)
return v
}
// calendarFlagSet reports whether a flag was explicitly provided by the user,
// tolerating both string and int (--recurrence-interval) flag kinds.
func calendarFlagSet(cmd *cobra.Command, name string) bool {
fl := cmd.Flags().Lookup(name)
if fl == nil {
return false
}
return cmd.Flags().Changed(name)
}
+9
View File
@@ -239,6 +239,15 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
// todo_hooks.go for the full rationale). No-op for non-todo.
installTodoHook(cmd, canonicalProduct, toolName)
// §calendar-hook: reject partial recurrence on event create/update
// (see calendar_hooks.go). No-op for non-calendar.
installCalendarHook(cmd, canonicalProduct, toolName)
// §attendance-hook: inner-JSON required fields, group type and
// FIXED conditional checks (see attendance_hooks.go). No-op for
// non-attendance.
installAttendanceHook(cmd, canonicalProduct, toolName)
// §1.4: Add to the right parent group
attachToGroup(rootCmd, override.Group, groupCmds, cmd)
}
+11 -2
View File
@@ -336,7 +336,16 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
Response: resp,
}
if route.OutputTransform != nil && result.Response != nil {
result.Response = route.OutputTransform(result.Response)
// Shape the MCP content payload (the actual data), not the
// {endpoint, content} runtime envelope, so rename/drop/table
// paths resolve against response fields (e.g. result.items)
// rather than the wrapper. Falls back to the whole Response
// when no content map is present (degraded/echo paths).
if content, ok := result.Response["content"].(map[string]any); ok {
result.Response["content"] = route.OutputTransform(content)
} else {
result.Response = route.OutputTransform(result.Response)
}
}
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
}
@@ -862,7 +871,7 @@ func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[st
if _, ok := existing[binding.Property]; ok {
continue
}
return nil, apperrors.NewValidation(fmt.Sprintf("--%s is required", primaryName))
return nil, apperrors.NewValidation(fmt.Sprintf("missing required flag: --%s is required", primaryName))
}
if !anyChanged {
continue
+76 -1
View File
@@ -30,7 +30,7 @@ import (
// ApplyTransform applies a named transform rule to a value.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
// json_parse_strict, enum_map, file_read, invert_bool, string_to_int64.
// json_parse_strict, enum_map, file_read, invert_bool, parse_bool, string_to_int64.
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
switch strings.TrimSpace(transform) {
case "":
@@ -49,6 +49,10 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
return transformFileRead(value)
case "invert_bool":
return transformInvertBool(value)
case "parse_bool":
return transformParseBool(value)
case "attendance_class_check_time":
return transformAttendanceClassCheckTime(value)
case "string_to_int64":
return transformStringToInt64(value)
default:
@@ -79,6 +83,77 @@ func transformInvertBool(value any) (any, error) {
}
}
// transformParseBool coerces a CLI string flag into a real JSON boolean so the
// MCP body carries `false`/`true` (not the string "false"/"true" or a swallowed
// zero value). Used by envelope flags that are semantically boolean but must be
// declared as string flags to accept an explicit `false` on the command line
// (cobra bool flags drop the space-form value). Unknown tokens pass through
// unchanged so upstream validators own the error wording.
func transformParseBool(value any) (any, error) {
switch v := value.(type) {
case bool:
return v, nil
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on":
return true, nil
case "false", "0", "no", "off":
return false, nil
}
return value, nil
default:
return value, nil
}
}
// transformAttendanceClassCheckTime parses a class-VO JSON string and converts
// every "HH:mm" checkTime under sections[*].times[*] and
// setting.topRestTimeList[*] into a Unix-millis number (1970-01-01 HH:mm in
// UTC+8), mirroring wukong's convertClassCheckTime. The MCP backend expects the
// numeric form; the envelope cannot express this nested walk, so it lives here.
func transformAttendanceClassCheckTime(value any) (any, error) {
parsed, err := transformJSONParseStrict(value)
if err != nil {
return nil, err
}
classVO, ok := parsed.(map[string]any)
if !ok {
return parsed, nil
}
cst := time.FixedZone("CST", 8*3600)
convertOne := func(obj map[string]any) {
if ct, ok := obj["checkTime"].(string); ok {
ct = strings.TrimSpace(ct)
if t, err := time.ParseInLocation("2006-01-02 15:04", "1970-01-01 "+ct, cst); err == nil {
obj["checkTime"] = float64(t.UnixMilli())
}
}
}
if sections, ok := classVO["sections"].([]any); ok {
for _, sec := range sections {
if secMap, ok := sec.(map[string]any); ok {
if times, ok := secMap["times"].([]any); ok {
for _, t := range times {
if tMap, ok := t.(map[string]any); ok {
convertOne(tMap)
}
}
}
}
}
}
if setting, ok := classVO["setting"].(map[string]any); ok {
if restList, ok := setting["topRestTimeList"].([]any); ok {
for _, item := range restList {
if itemMap, ok := item.(map[string]any); ok {
convertOne(itemMap)
}
}
}
}
return classVO, nil
}
func transformISO8601ToMillis(value any) (any, error) {
s, ok := toString(value)
if !ok {
+2 -2
View File
@@ -13,13 +13,13 @@ import (
)
// newTestMCPServer returns an httptest.Server that handles both market registry
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/bamboo
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/cedar
// succeeds, and mcpOK controls whether initialize+tools/list succeed.
func newTestMCPServer(t *testing.T, marketOK, mcpOK bool) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
if !marketOK {
http.Error(w, "market unavailable", http.StatusInternalServerError)
return
+1 -1
View File
@@ -259,7 +259,7 @@ func newDocsMCPGateway(expectations []docsServerExpectation) *httptest.Server {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
+49 -3
View File
@@ -110,6 +110,7 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
newAitableFieldCreateCommand(runner),
newAitableFieldUpdateCommand(runner),
newAitableFieldDeleteCommand(runner),
newAitableFieldSearchOptionsCommand(runner),
newAitableFieldListAlias(runner),
)
@@ -129,7 +130,13 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
newAitableRecordCreateCommand(runner),
newAitableRecordUpdateCommand(runner),
newAitableRecordBatchUpdateCommand(runner),
newAitableRecordQueryEmptyCommand(runner),
newAitableRecordDeleteCommand(runner),
newAitableRecordHistoryListCommand(runner),
newAitableRecordShareURLCommand(runner),
newAitableRecordUpsertCommand(runner),
newAitableRecordPrimaryDocGetCommand(runner),
newAitableRecordPrimaryDocCreateCommand(runner),
newAitableRecordListAlias(runner),
)
@@ -251,6 +258,7 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
newAitableDashboardUpdateCommand(runner),
newAitableDashboardDeleteCommand(runner),
newAitableDashboardConfigExampleCommand(runner),
newAitableDashboardArrangeCommand(runner),
dashboardShare,
)
@@ -264,15 +272,53 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
viewGet := newAitableViewGetCommand(runner)
viewGet.AddCommand(
newAitableViewGetCardCommand(runner),
newAitableViewGetTimebarCommand(runner),
newAitableViewGetAggregateCommand(runner),
newAitableViewGetFilterCommand(runner),
newAitableViewGetSortCommand(runner),
newAitableViewGetGroupCommand(runner),
newAitableViewGetVisibleFieldsCommand(runner),
newAitableViewGetFieldWidthsCommand(runner),
newAitableViewGetLockCommand(runner),
newAitableViewGetFrozenColsCommand(runner),
newAitableViewGetRowHeightCommand(runner),
newAitableViewGetFillColorRuleCommand(runner),
)
viewUpdate := newAitableViewUpdateCommand(runner)
viewUpdate.AddCommand(
newAitableViewUpdateCardCommand(runner),
newAitableViewUpdateTimebarCommand(runner),
newAitableViewUpdateAggregateCommand(runner),
newAitableViewUpdateFieldWidthsCommand(runner),
newAitableViewUpdateVisibleFieldsCommand(runner),
newAitableViewUpdateFilterCommand(runner),
newAitableViewUpdateSortCommand(runner),
newAitableViewUpdateGroupCommand(runner),
newAitableViewUpdateNameCommand(runner),
newAitableViewUpdateFrozenColsCommand(runner),
newAitableViewUpdateRowHeightCommand(runner),
newAitableViewUpdateFillColorRuleCommand(runner),
)
view.AddCommand(
newAitableViewGetCommand(runner),
viewGet,
newAitableViewListCommand(runner),
newAitableViewCreateCommand(runner),
newAitableViewUpdateCommand(runner),
viewUpdate,
newAitableViewDeleteCommand(runner),
newAitableViewLockCommand(runner),
newAitableViewDuplicateCommand(runner),
)
root.AddCommand(base, table, field, record, newAitableFormCommand(runner), template, attachment, export, importCmd, dashboard, chart, view)
root.AddCommand(
base, table, field, record, newAitableFormCommand(runner),
newAitableWorkflowCommand(runner),
template, attachment, export, importCmd, dashboard, chart, view,
newAitableAdvpermCommand(runner),
newAitableSectionCommand(runner),
)
// 顶层别名:dws aitable search/list/create/info → base search/list/create/get
// 每个 alias 复用现有 constructor,独立 cobra.Command 实例(避免与 base.* 共享 flag 指针)
+51 -10
View File
@@ -130,7 +130,33 @@ func newAitableBaseCreateCommand(runner executor.Runner) *cobra.Command {
if folderID := aitableStringFlag(cmd, "folder-id"); folderID != "" {
params["folderId"] = folderID
}
return runAitableTool(cmd, runner, "create_base", params)
// dry-run 或带模板:保持单步 create_base 语义。
if commandDryRun(cmd) {
return runAitableTool(cmd, runner, "create_base", params)
}
result, err := runAitableProductToolResult(cmd, runner, "aitable", "create_base", params)
if err != nil {
return err
}
// 默认表由服务端按 scenario 决定:wukong 场景建、openClaw 不建。
// 为与 wukong 行为对齐,无模板时在 CLI 侧兜底补建一张默认表,
// 使新 base 立即可用(含 tableId)。输出仍保持原 create_base 结果,
// 不把内部补建步骤暴露给调用方。
if _, hasTemplate := params["templateId"]; !hasTemplate {
if baseID := findStringDeep(result.Response, "baseId", "baseID"); baseID != "" {
if _, terr := runAitableProductToolResult(cmd, runner, "aitable", "create_table", map[string]any{
"baseId": baseID,
"tableName": "表格1",
"fields": []any{map[string]any{
"fieldName": "标题",
"type": "text",
}},
}); terr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: created base %s but default table creation failed: %v\n", baseID, terr)
}
}
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
@@ -1440,6 +1466,9 @@ func newAitableViewUpdateCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return err
}
if err := normalizeAitableViewConfigBlock(cmd, config); err != nil {
return err
}
params["config"] = config
}
if _, hasName := params["newViewName"]; !hasName {
@@ -1499,6 +1528,14 @@ func runAitableFormTool(cmd *cobra.Command, runner executor.Runner, tool string,
}
func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
result, err := runAitableProductToolResult(cmd, runner, product, tool, params)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func runAitableProductToolResult(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) (executor.Result, error) {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
product,
@@ -1509,9 +1546,9 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
if invocation.DryRun {
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
return executor.Result{}, err
}
return writeCommandPayload(cmd, result)
return result, nil
}
var lastErr error
@@ -1523,7 +1560,7 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
select {
case <-cmd.Context().Done():
timer.Stop()
return cmd.Context().Err()
return executor.Result{}, cmd.Context().Err()
case <-timer.C:
}
}
@@ -1531,16 +1568,16 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
result, err := runner.Run(cmd.Context(), invocation)
lastErr = err
if err == nil {
return writeCommandPayload(cmd, result)
return result, nil
}
if !aitableErrorRetryable(err) {
return err
return executor.Result{}, err
}
}
if lastErr != nil {
return lastErr
return executor.Result{}, lastErr
}
return nil
return executor.Result{}, nil
}
const aitableHelperMaxRetries = 3
@@ -1906,11 +1943,15 @@ func normalizeAitableSort(items []any) []any {
}
func parseAitableJSONArray(raw, flagName string) ([]any, error) {
var value []any
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--%s JSON parse failed: %v", flagName, err))
}
return value, nil
arr, ok := value.([]any)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("--%s must be a JSON array / 数组, got %T", flagName, value))
}
return arr, nil
}
func parseAitableJSONObject(raw, flagName string) (map[string]any, error) {
+52
View File
@@ -1393,3 +1393,55 @@ func singleAitableRecord(t *testing.T, value any) map[string]any {
}
return record
}
// base create 无模板时,CLI 兜底补建一张默认表(与 wukong 场景行为对齐:
// 服务端对 openClaw 不建默认表,CLI 侧补齐使新 base 立即含 tableId)。
func TestAitableBaseCreateAddsDefaultTableWithoutTemplate(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"data": map[string]any{"baseId": "BASE_001"}},
{"data": map[string]any{"tableId": "TABLE_001"}},
}}
cmd := newAitableBaseCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--name", "项目跟踪"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 2 {
t.Fatalf("calls = %d, want create_base + create_table", len(runner.calls))
}
if got := runner.calls[0].Tool; got != "create_base" {
t.Fatalf("first tool = %q, want create_base", got)
}
if got := runner.calls[1].Tool; got != "create_table" {
t.Fatalf("second tool = %q, want create_table", got)
}
if got := runner.calls[1].Params["baseId"]; got != "BASE_001" {
t.Fatalf("create_table baseId = %#v, want BASE_001", got)
}
}
// base create 带 --template-id 时不补建默认表(模板自带结构)。
func TestAitableBaseCreateWithTemplateSkipsDefaultTable(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"data": map[string]any{"baseId": "BASE_001"}},
}}
cmd := newAitableBaseCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--name", "项目跟踪", "--template-id", "TPL_001"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 1 {
t.Fatalf("calls = %d, want only create_base", len(runner.calls))
}
if got := runner.calls[0].Tool; got != "create_base" {
t.Fatalf("tool = %q, want create_base", got)
}
}
File diff suppressed because it is too large Load Diff
+420
View File
@@ -0,0 +1,420 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func executeAitableExtraCommand(t *testing.T, cmd *cobra.Command, args ...string) {
t.Helper()
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
}
type aitableSequencedRunner struct {
calls []executor.Invocation
responses []map[string]any
}
func (r *aitableSequencedRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls = append(r.calls, invocation)
var response map[string]any
if idx := len(r.calls) - 1; idx >= 0 && idx < len(r.responses) {
response = r.responses[idx]
}
return executor.Result{Invocation: invocation, Response: response}, nil
}
func TestAitableFieldSearchOptionsRoutesToAitable(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableFieldSearchOptionsCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--field-id", "FIELD_001",
"--keyword", "已",
"--limit", "10",
)
if got := runner.last.CanonicalProduct; got != "aitable" {
t.Fatalf("CanonicalProduct = %q, want aitable", got)
}
if got := runner.last.Tool; got != "search_field_options" {
t.Fatalf("Tool = %q, want search_field_options", got)
}
if got := runner.last.Params["keyword"]; got != "已" {
t.Fatalf("keyword = %#v, want 已", got)
}
if got := runner.last.Params["limit"]; got != 10 {
t.Fatalf("limit = %#v, want 10", got)
}
}
func TestAitableViewGetFieldWidthsProjectsCustomWidthMap(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{{
"content": map[string]any{
"status": "success",
"data": map[string]any{"views": []any{map[string]any{
"viewId": "VIEW_001",
"viewType": "Grid",
"custom": map[string]any{
"widthMap": map[string]any{"FIELD_001": 240},
},
}}},
},
}}}
cmd := newAitableViewGetFieldWidthsCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("output JSON parse error = %v\nstdout:\n%s", err, out.String())
}
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("data = %#v, want object", payload["data"])
}
if got := data["FIELD_001"]; got != float64(240) {
t.Fatalf("FIELD_001 width = %#v, want 240", got)
}
}
func TestAitableViewUpdateCardDispatchesGalleryConfig(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"content": map[string]any{
"status": "success",
"data": map[string]any{"views": []any{map[string]any{
"viewId": "VIEW_001",
"viewType": "Gallery",
}}},
}},
{"content": map[string]any{"status": "success"}},
}}
cmd := newAitableViewUpdateCardCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--cover-mode", "custom",
"--cover-field-id", "FIELD_001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 2 {
t.Fatalf("calls = %d, want 2", len(runner.calls))
}
update := runner.calls[1]
if update.Tool != "update_view" {
t.Fatalf("second tool = %q, want update_view", update.Tool)
}
config, ok := update.Params["config"].(map[string]any)
if !ok {
t.Fatalf("config = %#v, want object", update.Params["config"])
}
card, ok := config["galleryCard"].(map[string]any)
if !ok {
t.Fatalf("galleryCard = %#v, want object", config["galleryCard"])
}
if got := card["coverMode"]; got != "custom" {
t.Fatalf("coverMode = %#v, want custom", got)
}
}
func TestAitableViewUpdateConfigRoutedKeyHintsSubcommand(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--config", `{"frozenColCount":2}`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if !strings.Contains(errOut.String(), "frozen-cols") {
t.Fatalf("stderr missing frozen-cols hint:\n%s", errOut.String())
}
}
func TestAitableRecordHistoryListRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordHistoryListCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--record-id", "REC_001",
"--offset", "10",
"--limit", "30",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "query_record_history" {
t.Fatalf("Tool = %q, want query_record_history", got)
}
if got := runner.last.Params["recordId"]; got != "REC_001" {
t.Fatalf("recordId = %#v, want REC_001", got)
}
if got := runner.last.Params["offset"]; got != 10 {
t.Fatalf("offset = %#v, want 10", got)
}
if got := runner.last.Params["limit"]; got != 30 {
t.Fatalf("limit = %#v, want 30", got)
}
}
func TestAitableRecordUpsertAcceptsFieldsAlias(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordUpsertCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--fields", `[{"recordId":"REC_001","cells":{"fld":"updated"}},{"cells":{"fld":"new"}}]`,
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "record_upsert" {
t.Fatalf("Tool = %q, want record_upsert", got)
}
records, ok := runner.last.Params["records"].([]any)
if !ok {
t.Fatalf("records type = %T, want []any", runner.last.Params["records"])
}
if len(records) != 2 {
t.Fatalf("records len = %d, want 2", len(records))
}
}
func TestAitableViewExtraCommandsRouteToExpectedTools(t *testing.T) {
t.Parallel()
t.Run("lock unlock", func(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewLockCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--off",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "lock_or_unlock_view" {
t.Fatalf("Tool = %q, want lock_or_unlock_view", got)
}
if got := runner.last.Params["action"]; got != "unlock" {
t.Fatalf("action = %#v, want unlock", got)
}
})
t.Run("fill color rule", func(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewUpdateFillColorRuleCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--json", `[]`,
)
if got := runner.last.CanonicalProduct; got != "aitable" {
t.Fatalf("CanonicalProduct = %q, want aitable", got)
}
if got := runner.last.Tool; got != "set_view_fill_color_rule" {
t.Fatalf("Tool = %q, want set_view_fill_color_rule", got)
}
if formats, ok := runner.last.Params["conditionalFormats"].([]any); !ok || len(formats) != 0 {
t.Fatalf("conditionalFormats = %#v, want empty []any", runner.last.Params["conditionalFormats"])
}
})
}
func TestAitableWorkflowListRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableWorkflowListCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--limit", "50",
"--offset", "100",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "list_workflows" {
t.Fatalf("Tool = %q, want list_workflows", got)
}
if got := runner.last.Params["limit"]; got != 50 {
t.Fatalf("limit = %#v, want 50", got)
}
if got := runner.last.Params["offset"]; got != 100 {
t.Fatalf("offset = %#v, want 100", got)
}
}
func TestAitableRecordQueryEmptyRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordQueryEmptyCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--limit", "50",
"--cursor", "CUR_001",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "query_empty_records" {
t.Fatalf("Tool = %q, want query_empty_records", got)
}
if got := runner.last.Params["limit"]; got != 50 {
t.Fatalf("limit = %#v, want 50", got)
}
if got := runner.last.Params["cursor"]; got != "CUR_001" {
t.Fatalf("cursor = %#v, want CUR_001", got)
}
}
func TestAitableRecordQueryEmptyRejectsOutOfRangeLimit(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordQueryEmptyCommand(runner)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--limit", "200",
})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error for --limit 200, got nil")
}
if runner.last.Tool != "" {
t.Fatalf("runner should not be called on invalid limit, got tool %q", runner.last.Tool)
}
}
func TestAitableDashboardArrangeRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableDashboardArrangeCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--dashboard-id", "DASH_001",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "align_dashboard" {
t.Fatalf("Tool = %q, want align_dashboard", got)
}
if got := runner.last.Params["dashboardId"]; got != "DASH_001" {
t.Fatalf("dashboardId = %#v, want DASH_001", got)
}
}
func TestAitableAdvpermRoleCreateParsesSubRoles(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableAdvpermRoleCreateCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--name", "市场可读",
"--sub-roles", `[{"targetId":"TABLE_001","targetType":"sheet","authLevel":"read"}]`,
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "create_role" {
t.Fatalf("Tool = %q, want create_role", got)
}
subRoles, ok := runner.last.Params["subRoles"].([]any)
if !ok || len(subRoles) != 1 {
t.Fatalf("subRoles = %#v, want single-item []any", runner.last.Params["subRoles"])
}
}
func TestAitableSectionMoveNodeAllowsRootParent(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableSectionMoveNodeCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--node-id", "NODE_001",
"--new-parent-section-id", "",
"--target-index", "0",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "move_nsheet_node" {
t.Fatalf("Tool = %q, want move_nsheet_node", got)
}
if got := runner.last.Params["newParentSectionId"]; got != "" {
t.Fatalf("newParentSectionId = %#v, want empty string", got)
}
if got := runner.last.Params["targetIndex"]; got != 0 {
t.Fatalf("targetIndex = %#v, want 0", got)
}
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler { return calendarHandler{} })
}
// calendarHandler contributes the `calendar attendee list|add|delete` group.
// wukong renamed the calendar participant commands to "attendee" (former name:
// participant); the envelope still exposes them under "participant". These
// leaves call the same MCP tools (get/add/remove_calendar_participant) so the
// wukong command surface is aligned without dropping the legacy participant
// path. MergeCommandTree folds the attendee group into the calendar tree.
type calendarHandler struct{}
func (calendarHandler) Name() string { return "calendar" }
func (calendarHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "calendar",
Short: i18n.T("日历"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
attendee := &cobra.Command{
Use: "attendee",
Short: i18n.T("参会人管理(与 participant 等价,对齐 wukong 命名)"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
attendee.AddCommand(
newCalendarAttendeeListCommand(runner),
newCalendarAttendeeAddCommand(runner),
newCalendarAttendeeDeleteCommand(runner),
)
root.AddCommand(attendee)
return root
}
func newCalendarAttendeeListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list", Short: i18n.T("查询日程参会人"),
Example: " dws calendar attendee list --event <eventId>", Args: cobra.NoArgs, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
if eventID == "" {
return apperrors.NewValidation("missing required flag(s): --event")
}
params := map[string]any{"eventId": eventID}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
params["calendarId"] = v
}
return runCalendarTool(cmd, runner, "get_calendar_participants", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选, 默认主日历)"))
return cmd
}
func newCalendarAttendeeAddCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add", Short: i18n.T("添加日程参会人"),
Example: " dws calendar attendee add --event <eventId> --users userId1,userId2", Args: cobra.NoArgs, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
users := strings.TrimSpace(firstNonEmptyFlag(cmd, "users", "attendees", "user-ids"))
if eventID == "" {
return apperrors.NewValidation("missing required flag(s): --event")
}
if users == "" {
return apperrors.NewValidation("missing required flag(s): --users")
}
params := map[string]any{"eventId": eventID, "attendeesToAdd": csvToList(users)}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "optional")); v != "" {
params["optional"] = v
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
params["calendarId"] = v
}
return runCalendarTool(cmd, runner, "add_calendar_participant", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
cmd.Flags().String("users", "", i18n.T("参会人 userId 列表,逗号分隔 (必填)"))
cmd.Flags().String("optional", "", i18n.T("是否可选参会人 (可选)"))
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选)"))
return cmd
}
func newCalendarAttendeeDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete", Short: i18n.T("移除日程参会人"),
Example: " dws calendar attendee delete --event <eventId> --users userId1", Args: cobra.NoArgs, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
users := strings.TrimSpace(firstNonEmptyFlag(cmd, "users", "attendees", "user-ids"))
if eventID == "" {
return apperrors.NewValidation("missing required flag(s): --event")
}
if users == "" {
return apperrors.NewValidation("missing required flag(s): --users")
}
params := map[string]any{"eventId": eventID, "attendeesToRemove": csvToList(users)}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
params["calendarId"] = v
}
return runCalendarTool(cmd, runner, "remove_calendar_participant", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
cmd.Flags().String("users", "", i18n.T("参会人 userId 列表,逗号分隔 (必填)"))
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选)"))
return cmd
}
func runCalendarTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "calendar", tool, params)
if commandDryRun(cmd) {
return writeCommandPayload(cmd, inv)
}
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func csvToList(s string) []any {
parts := strings.Split(s, ",")
out := make([]any, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+1 -1
View File
@@ -121,7 +121,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
newChatBotSearchCommand(runner),
)
root.AddCommand(message, group, bot)
root.AddCommand(message, group, bot, newChatFileGroup(runner))
return root
}
+206
View File
@@ -0,0 +1,206 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"os"
"path/filepath"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
// newChatFileGroup builds `dws chat file upload`. wukong implements it as a
// multi-step upload to the conversation file space: for --file it does
// init_conversation_file_upload (im) -> HTTP PUT -> commit_conversation_file_upload
// (im); for --url it calls upload_conversation_file_by_url (chat). The envelope
// cannot express the local pipeline, so it lives here. Wired into the chat
// handler (see chat.go).
func newChatFileGroup(runner executor.Runner) *cobra.Command {
file := &cobra.Command{
Use: "file",
Short: "会话文件上传",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
file.AddCommand(newChatFileUploadCommand(runner))
return file
}
func newChatFileUploadCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件或 URL 文件到会话文件空间",
Example: " dws chat file upload --group <openConversationId> --file ./report.pdf\n" +
" dws chat file upload --user <userId> --url https://example.com/a.pdf --file-name a.pdf",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
target, err := chatConversationTargetArgs(cmd)
if err != nil {
return err
}
filePath := strings.TrimSpace(firstNonEmptyFlag(cmd, "file", "file-path"))
fileURL := strings.TrimSpace(firstNonEmptyFlag(cmd, "url"))
if filePath == "" && fileURL == "" {
return apperrors.NewValidation("--file or --url is required")
}
if filePath != "" && fileURL != "" {
return apperrors.NewValidation("--file and --url are mutually exclusive")
}
fileName := strings.TrimSpace(firstNonEmptyFlag(cmd, "file-name"))
md5v := strings.TrimSpace(firstNonEmptyFlag(cmd, "md5"))
uuid := strings.TrimSpace(firstNonEmptyFlag(cmd, "uuid"))
// URL path: server pulls the file (chat server).
if fileURL != "" {
if fileName == "" {
fileName = filepath.Base(fileURL)
}
params := cloneStringAnyMap(target)
params["fileUrl"] = fileURL
params["fileName"] = fileName
if md5v != "" {
params["md5"] = md5v
}
if uuid != "" {
params["uuid"] = uuid
}
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "chat", "upload_conversation_file_by_url", params)
if commandDryRun(cmd) {
return writeCommandPayload(cmd, inv)
}
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
// Local path: init (im) -> HTTP PUT -> commit (im).
fi, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation("cannot read file " + filePath + ": " + err.Error())
}
if fi.IsDir() {
return apperrors.NewValidation(filePath + " is a directory, not a file")
}
if fileName == "" {
fileName = filepath.Base(filePath)
}
fileSize := fi.Size()
if md5v == "" {
if md5v, err = fileMD5Hex(filePath); err != nil {
return err
}
}
if commandDryRun(cmd) {
preview := cloneStringAnyMap(target)
preview["fileName"] = fileName
preview["fileSize"] = fileSize
preview["md5"] = md5v
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", preview))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
initParams := cloneStringAnyMap(target)
initParams["fileName"] = fileName
initParams["fileSize"] = fileSize
initParams["md5"] = md5v
initRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", initParams))
if err != nil {
return err
}
resourceURL := findStringDeep(initRes.Response, "resourceUrl", "resourceURL", "url")
if resourceURL == "" {
resourceURL = findFirstInStringArrayDeep(initRes.Response, "resourceUrls", "resourceURLs")
}
uploadKey := findStringDeep(initRes.Response, "uploadKey", "key")
if resourceURL == "" || uploadKey == "" {
return apperrors.NewAPI("incomplete upload credentials: resourceUrl=" + resourceURL + " uploadKey=" + uploadKey)
}
headers := findHeadersDeep(initRes.Response, "headers", "ossHeaders")
if err := httpPutLocalFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
commitParams := cloneStringAnyMap(target)
commitParams["uploadKey"] = uploadKey
commitParams["fileName"] = fileName
commitParams["fileSize"] = fileSize
commitParams["md5"] = md5v
if uuid != "" {
commitParams["uuid"] = uuid
}
commitRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "commit_conversation_file_upload", commitParams))
if err != nil {
return err
}
return writeCommandPayload(cmd, commitRes)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)")
cmd.Flags().String("conversation-id", "", "--group 的别名")
cmd.Flags().String("id", "", "--group 的别名")
cmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)")
cmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)")
cmd.Flags().String("file", "", "本地文件路径(与 --url 二选一)")
cmd.Flags().String("file-path", "", "--file 的别名")
cmd.Flags().String("url", "", "远程文件 URL(与 --file 二选一,服务端代传)")
cmd.Flags().String("file-name", "", "文件名(可选)")
cmd.Flags().String("md5", "", "文件 MD5(可选,本地不传自动计算)")
cmd.Flags().String("uuid", "", "幂等 UUID(可选)")
return cmd
}
func chatConversationTargetArgs(cmd *cobra.Command) (map[string]any, error) {
group := strings.TrimSpace(firstNonEmptyFlag(cmd, "group", "conversation-id", "id"))
user := strings.TrimSpace(firstNonEmptyFlag(cmd, "user"))
openDingTalkID := strings.TrimSpace(firstNonEmptyFlag(cmd, "open-dingtalk-id"))
if group == "" && user == "" && openDingTalkID == "" {
return nil, apperrors.NewValidation("需指定会话目标:--group(群聊)或 --user / --open-dingtalk-id(单聊)之一")
}
m := map[string]any{}
if group != "" {
m["openConversationId"] = group
}
if user != "" {
m["userId"] = user
}
if openDingTalkID != "" {
m["openDingTalkId"] = openDingTalkID
}
return m, nil
}
func cloneStringAnyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in)+4)
for k, v := range in {
out[k] = v
}
return out
}
+124 -2
View File
@@ -85,6 +85,7 @@ func (docHandler) Command(runner executor.Runner) *cobra.Command {
newDocPermissionAddCommand(runner),
newDocPermissionUpdateCommand(runner),
newDocPermissionListCommand(runner),
newDocPermissionRemoveCommand(runner),
)
export := &cobra.Command{
@@ -174,6 +175,7 @@ func newDocSearchCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive search or dws wiki node search")
params := map[string]any{}
if keyword := docFlagOrFallback(cmd, "query", "keyword"); keyword != "" {
params["keyword"] = keyword
@@ -222,6 +224,7 @@ func newDocListCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive list or dws wiki node list")
params := map[string]any{}
if folder := docFlagOrFallback(cmd, "folder", "parent-id", "node", "file-id", "nodee"); folder != "" {
params["folderId"] = normalizeDocNodeID(folder)
@@ -274,6 +277,9 @@ func newDocReadCommand(runner executor.Runner) *cobra.Command {
if output := docStringFlag(cmd, "output"); output != "" {
params["__output__"] = output
}
if format == "jsonml" {
return runDocReadJSONML(cmd, runner, params)
}
return runDocTool(cmd, runner, "doc", "get_document_content", params)
},
}
@@ -814,6 +820,7 @@ func newDocFileCreateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "wiki node create")
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
@@ -850,6 +857,7 @@ func newDocFolderCreateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "wiki node create --type folder")
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
@@ -889,6 +897,7 @@ func newDocTransferCommand(runner executor.Runner, use, tool string) *cobra.Comm
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive "+use)
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
@@ -919,6 +928,7 @@ func newDocRenameCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive rename")
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
@@ -1467,6 +1477,56 @@ func docInvocationResult(cmd *cobra.Command, runner executor.Runner, product, to
return runner.Run(cmd.Context(), invocation)
}
func runDocReadJSONML(cmd *cobra.Command, runner executor.Runner, params map[string]any) error {
outputPath, _ := params["__output__"].(string)
result, err := docInvocationResult(cmd, runner, "doc", "get_document_content", params)
if err != nil {
return err
}
if !result.Invocation.Implemented {
return writeCommandPayload(cmd, result)
}
payload := normalizeDocReadJSONMLResult(result)
if outputPath != "" {
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSONML output: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return fmt.Errorf("failed to write output file %s: %w", outputPath, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "[INFO] JSONML 已写入 %s\n", outputPath)
return nil
}
return writeCommandPayload(cmd, payload)
}
func normalizeDocReadJSONMLResult(result executor.Result) map[string]any {
content := result.Response
if nested, ok := result.Response["content"].(map[string]any); ok {
content = nested
}
out := map[string]any{}
for k, v := range content {
if k == "content" {
continue
}
out[k] = v
}
if raw, ok := out["jsonml"].(string); ok {
var decoded any
if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
out["jsonml"] = decoded
}
}
if revision, ok := out["version"]; ok {
if _, exists := out["revision"]; !exists {
out["revision"] = revision
}
}
return out
}
func addDocNodeFlags(cmd *cobra.Command) {
cmd.Flags().String("node", "", i18n.T("文档 nodeId / URL"))
addDocHiddenStringFlag(cmd, "url", "--node alias")
@@ -2082,6 +2142,7 @@ func newDocPermissionAddCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive permission add")
return runDocPermissionMutation(cmd, runner, "add_permission")
},
}
@@ -2091,6 +2152,7 @@ func newDocPermissionAddCommand(runner executor.Runner) *cobra.Command {
addDocHiddenStringFlag(cmd, "users", "--user alias")
cmd.Flags().String("role", "", i18n.T("权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)"))
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填,辅助构造返回的 docUrl)"))
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
@@ -2108,6 +2170,7 @@ func newDocPermissionUpdateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive permission update")
return runDocPermissionMutation(cmd, runner, "update_permission")
},
}
@@ -2118,6 +2181,7 @@ func newDocPermissionUpdateCommand(runner executor.Runner) *cobra.Command {
addDocHiddenStringFlag(cmd, "uid", "--user alias")
cmd.Flags().String("role", "", i18n.T("新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)"))
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
@@ -2135,6 +2199,7 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive permission list")
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
@@ -2157,7 +2222,7 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
}
params["filterRoleIds"] = roles
}
if v, _ := cmd.Flags().GetString("workspace"); v != "" {
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
params["workspaceId"] = v
}
if commandDryRun(cmd) {
@@ -2183,6 +2248,59 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("filter-role", "", i18n.T("按角色过滤(逗号分隔): OWNER / MANAGER / EDITOR / DOWNLOADER / READER"))
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
func newDocPermissionRemoveCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: i18n.T("移除文档协作者权限"),
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive permission remove")
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
}
rawUsers := docFlagOrFallback(cmd, "users", "user", "uid")
if strings.TrimSpace(rawUsers) == "" {
return apperrors.NewValidation("--users is required")
}
userIDs, err := parseDocPermissionUsers(rawUsers)
if err != nil {
return err
}
params := map[string]any{
"nodeId": nodeID,
"userIds": userIDs,
}
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
params["workspaceId"] = v
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "remove_permission", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "remove_permission", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
addDocNodeFlags(cmd)
cmd.Flags().String("users", "", i18n.T("被移除用户 userId 列表,逗号分隔,单次最多 30 (必填)"))
addDocHiddenStringFlag(cmd, "user", "--users alias")
addDocHiddenStringFlag(cmd, "uid", "--users alias")
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
@@ -2217,7 +2335,7 @@ func runDocPermissionMutation(cmd *cobra.Command, runner executor.Runner, mcpToo
"roleId": role,
"userIds": userIDs,
}
if v, _ := cmd.Flags().GetString("workspace"); v != "" {
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
params["workspaceId"] = v
}
if commandDryRun(cmd) {
@@ -2234,6 +2352,10 @@ func runDocPermissionMutation(cmd *cobra.Command, runner executor.Runner, mcpToo
return writeCommandPayload(cmd, result)
}
func docDeprecatedNotice(cmd *cobra.Command, replacement string) {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: deprecated: use dws %s instead.\n", replacement)
}
// TRANSITIONAL: 等 mse 把 delete_document 加入 doc toolOverrides(含
// destructive_hint: true)后,本 helper 可删除——CLI discovery 会自动
// 生成等价命令。工单:plan/mse-yuyuan-patch.md 改动 2.2。
+29
View File
@@ -96,6 +96,35 @@ func TestDocPermissionListLimitAliases(t *testing.T) {
}
}
func TestDocPermissionRemoveRoutesToRemovePermission(t *testing.T) {
t.Parallel()
runner := &docCommandRunner{}
cmd := newDocTestRoot(runner)
_, errOut, err := executeDocCommand(t, cmd,
"permission", "rm",
"--node", "NODE_001",
"--users", "uid1,uid2",
"--workspace", "WS_001",
)
if err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut)
}
if runner.last.Tool != "remove_permission" {
t.Fatalf("tool = %q, want remove_permission", runner.last.Tool)
}
if got := runner.last.Params["nodeId"]; got != "NODE_001" {
t.Fatalf("nodeId = %#v, want NODE_001", got)
}
users, ok := runner.last.Params["userIds"].([]string)
if !ok || strings.Join(users, ",") != "uid1,uid2" {
t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
}
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
t.Fatalf("workspaceId = %#v, want WS_001", got)
}
}
func TestDocPermissionListMaxresultsRejected(t *testing.T) {
t.Parallel()
+468
View File
@@ -79,6 +79,12 @@ func (driveHandler) Command(runner executor.Runner) *cobra.Command {
newDriveCommitCommand(runner),
newDriveUploadCommand(runner),
newDriveDeleteCommand(runner),
newDriveSearchCommand(runner),
newDriveCopyCommand(runner),
newDriveMoveCommand(runner),
newDriveRenameCommand(runner),
newDrivePermissionCommand(runner),
newDriveFolderCommand(runner),
)
return root
}
@@ -98,6 +104,17 @@ func newDriveListCommand(runner executor.Runner) *cobra.Command {
if maxResults <= 0 {
maxResults = 20
}
if workspaceID := driveFlagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
params := map[string]any{"workspaceId": workspaceID}
if folderID := driveFlagOrFallback(cmd, "folder", "parent-id"); folderID != "" {
params["folderId"] = normalizeDocNodeID(folderID)
}
if maxResults > 0 {
params["pageSize"] = maxResults
}
addDriveStringParam(cmd, params, "pageToken", "cursor", "next-token")
return runDriveInvocation(cmd, runner, "doc", "list_nodes", params)
}
params := map[string]any{"maxResults": float64(maxResults)}
addDriveStringParam(cmd, params, "spaceId", "space-id")
if parentID := driveFlagOrFallback(cmd, "folder", "parent-id"); parentID != "" {
@@ -124,6 +141,8 @@ func newDriveListCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("space-id", "", "空间 ID,不传则使用「我的文件」对应 spaceId (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则列出空间根目录 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
cmd.Flags().String("workspace", "", "文档空间/知识库 ID,传入则路由到文档空间")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
cmd.Flags().String("cursor", "", "分页游标,首次不传 (可选)")
addDriveHiddenStringFlag(cmd, "next-token", "--cursor 的兼容别名")
cmd.Flags().String("order-by", "", "排序字段: createTime|modifyTime|name (可选)")
@@ -147,6 +166,7 @@ spaceType 筛选规则:
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(cmd.ErrOrStderr(), "warning: deprecated: use dws wiki space list instead.")
params := map[string]any{}
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
@@ -380,6 +400,9 @@ func newDriveUploadCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("mime-type", "", "文件 MIME 类型,不传则自动推断 (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则上传到空间根目录 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
cmd.Flags().String("workspace", "", "目标知识库 ID,传入时路由到文档空间上传")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
cmd.Flags().Bool("convert", false, "是否转换为钉钉在线文档(文档空间上传时生效)")
return cmd
}
@@ -388,6 +411,9 @@ func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
if strings.TrimSpace(filePath) == "" {
return apperrors.NewValidation("--file is required")
}
if workspaceID := driveFlagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
return runDriveUploadToDoc(cmd, runner, workspaceID)
}
absPath, err := filepath.Abs(filePath)
if err != nil {
@@ -493,6 +519,93 @@ func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
return writeCommandPayload(cmd, result)
}
func runDriveUploadToDoc(cmd *cobra.Command, runner executor.Runner, workspaceID string) error {
filePath, _ := cmd.Flags().GetString("file")
absPath, err := filepath.Abs(filePath)
if err != nil {
return apperrors.NewValidation("无法解析文件路径: " + err.Error())
}
fi, err := os.Stat(absPath)
if err != nil {
return apperrors.NewValidation("文件不存在或无法读取: " + absPath)
}
if fi.IsDir() {
return apperrors.NewValidation("--file 不能是目录: " + absPath)
}
fileSize := fi.Size()
if fileSize <= 0 {
return apperrors.NewValidation("文件为空")
}
fileName := driveFlagOrFallback(cmd, "file-name", "name")
if fileName == "" {
fileName = filepath.Base(absPath)
}
folderID := driveFlagOrFallback(cmd, "folder", "parent-id")
if folderID != "" {
folderID = normalizeDocNodeID(folderID)
}
step1Params := map[string]any{"workspaceId": workspaceID}
if folderID != "" {
step1Params["folderId"] = folderID
}
commitParams := map[string]any{
"name": fileName,
"fileSize": float64(fileSize),
"workspaceId": workspaceID,
}
if folderID != "" {
commitParams["folderId"] = folderID
}
if convert, _ := cmd.Flags().GetBool("convert"); convert {
commitParams["convertToOnlineDoc"] = true
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, map[string]any{
"dry_run": true,
"step_1_get_file_upload_info": executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
),
"step_2_http_put_oss": "PUT file bytes to resourceUrl with returned headers",
"step_3_commit_uploaded_file": executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
),
"file": absPath,
"name": fileName,
"size": fileSize,
})
}
fmt.Fprintf(cmd.ErrOrStderr(), "[1/3] 获取文档空间上传凭证 %s (%d 字节)...\n", fileName, fileSize)
step1Result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
))
if err != nil {
return fmt.Errorf("获取上传凭证失败: %w", err)
}
resourceURL, uploadKey, headers, err := extractDocFileUploadInfo(step1Result.Response)
if err != nil {
return err
}
fmt.Fprintln(cmd.ErrOrStderr(), "[2/3] 上传文件到 OSS...")
if err := httpPutDriveFile(cmd.Context(), resourceURL, headers, absPath, fileSize); err != nil {
return err
}
fmt.Fprintln(cmd.ErrOrStderr(), "[3/3] 提交文件入库...")
commitParams["uploadKey"] = uploadKey
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
))
if err != nil {
return fmt.Errorf("提交文件入库失败: %w", err)
}
return writeCommandPayload(cmd, result)
}
// ── delete (drive surface routed to doc MCP server) ────────
func newDriveDeleteCommand(runner executor.Runner) *cobra.Command {
@@ -528,6 +641,303 @@ func newDriveDeleteCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func newDriveSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "搜索文件(聚合钉盘和文档空间)",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
keyword := driveFlagOrFallback(cmd, "query", "keyword")
if keyword == "" {
return apperrors.NewValidation("--query is required")
}
target := driveStringFlag(cmd, "target")
driveParams := map[string]any{"keyword": keyword}
if target != "" && target != "all" {
driveParams["searchTarget"] = target
}
addDriveStringSliceParam(cmd, driveParams, "fileTypes", "file-types")
addDriveStringSliceParam(cmd, driveParams, "extensions", "extensions")
addDriveStringSliceParam(cmd, driveParams, "creatorUserIds", "creator-uids")
addDriveInt64Param(cmd, driveParams, "createdTimeFrom", "created-from")
addDriveInt64Param(cmd, driveParams, "createdTimeTo", "created-to")
addDriveInt64Param(cmd, driveParams, "modifiedTimeFrom", "modified-from")
addDriveInt64Param(cmd, driveParams, "modifiedTimeTo", "modified-to")
if pageSize := driveIntFlagOrFallback(cmd, "limit", "page-size"); pageSize > 0 {
driveParams["pageSize"] = float64(pageSize)
}
addDriveStringParam(cmd, driveParams, "pageToken", "cursor", "page-token")
if target == "file" || target == "space" {
return runDriveInvocation(cmd, runner, "drive", "search_files", driveParams)
}
driveResult, driveErr := driveInvocationResult(cmd, runner, "drive", "search_files", driveParams)
docParams := map[string]any{"keyword": keyword}
if pageSize := driveIntFlagOrFallback(cmd, "limit", "page-size"); pageSize > 0 {
docParams["pageSize"] = pageSize
}
if extensions, ok := driveParams["extensions"]; ok {
docParams["extensions"] = extensions
}
docResult, docErr := driveInvocationResult(cmd, runner, "doc", "search_documents", docParams)
if driveErr != nil && docErr != nil {
return fmt.Errorf("aggregated search failed: drive: %v; doc: %v", driveErr, docErr)
}
result := map[string]any{}
if driveErr == nil {
result["drive_results"] = driveResult.Response
}
if docErr == nil {
result["doc_results"] = docResult.Response
}
return writeCommandPayload(cmd, map[string]any{"success": true, "result": result})
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("query", "", "搜索关键词 (必填)")
addDriveHiddenStringFlag(cmd, "keyword", "--query 的兼容别名")
cmd.Flags().String("target", "", "搜索范围: all(默认) / file / space")
cmd.Flags().StringSlice("file-types", nil, "按文件内容类型过滤")
cmd.Flags().StringSlice("extensions", nil, "按文件扩展名过滤")
cmd.Flags().StringSlice("creator-uids", nil, "按创建者 userId 过滤")
cmd.Flags().Int64("created-from", 0, "创建时间起始毫秒时间戳")
cmd.Flags().Int64("created-to", 0, "创建时间截止毫秒时间戳")
cmd.Flags().Int64("modified-from", 0, "修改时间起始毫秒时间戳")
cmd.Flags().Int64("modified-to", 0, "修改时间截止毫秒时间戳")
cmd.Flags().Int("limit", 0, "每页返回数量")
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("cursor", "", "分页游标")
addDriveHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
return cmd
}
func newDriveCopyCommand(runner executor.Runner) *cobra.Command {
return newDriveDocTransferCommand(runner, "copy", "copy_document")
}
func newDriveMoveCommand(runner executor.Runner) *cobra.Command {
return newDriveDocTransferCommand(runner, "move", "move_document")
}
func newDriveDocTransferCommand(runner executor.Runner, use, tool string) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: use + " 文档空间节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
params := map[string]any{"nodeId": normalizeDocNodeID(nodeID)}
if folder := driveFlagOrFallback(cmd, "folder", "parent-id", "parent-node-id", "parent-folder-id"); folder != "" {
params["targetFolderId"] = normalizeDocNodeID(folder)
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", tool, params)
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().String("folder", "", "目标文件夹 nodeId")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
addDriveHiddenStringFlag(cmd, "parent-node-id", "--folder 的兼容别名")
addDriveHiddenStringFlag(cmd, "parent-folder-id", "--folder 的兼容别名")
cmd.Flags().String("workspace", "", "目标知识库 ID")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
return cmd
}
func newDriveRenameCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "rename",
Short: "重命名文档空间节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
name, err := driveRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
}
return runDriveInvocation(cmd, runner, "doc", "rename_document", map[string]any{
"nodeId": normalizeDocNodeID(nodeID),
"newName": name,
})
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().String("name", "", "新名称 (必填)")
addDriveHiddenStringFlag(cmd, "title", "--name 的兼容别名")
return cmd
}
func newDrivePermissionCommand(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "permission",
Aliases: []string{"perm"},
Short: "文档空间节点权限管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
root.AddCommand(
newDrivePermissionMutationCommand(runner, "add", "add_permission", true),
newDrivePermissionMutationCommand(runner, "update", "update_permission", true),
newDrivePermissionListCommand(runner),
newDrivePermissionRemoveCommand(runner),
)
preferLegacyLeaf(root)
return root
}
func newDrivePermissionMutationCommand(runner executor.Runner, use, tool string, requireRole bool) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: use + " 文档空间节点权限",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
rawUsers := driveFlagOrFallback(cmd, "users", "user", "uid")
if rawUsers == "" {
return apperrors.NewValidation("--users is required")
}
userIDs, err := parseDocPermissionUsers(rawUsers)
if err != nil {
return err
}
params := map[string]any{
"nodeId": normalizeDocNodeID(nodeID),
"userIds": userIDs,
}
if requireRole {
rawRole, err := driveRequiredFlag(cmd, "role")
if err != nil {
return err
}
role, ok := normalizeDocPermissionRole(rawRole)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("invalid --role: %s", rawRole))
}
params["roleId"] = role
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", tool, params)
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
addDriveHiddenStringFlag(cmd, "user", "--users 的兼容别名")
addDriveHiddenStringFlag(cmd, "uid", "--users 的兼容别名")
if requireRole {
cmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
}
cmd.Flags().String("workspace", "", "知识库 ID (选填)")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
return cmd
}
func newDrivePermissionListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查询文档空间节点协作者",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
params := map[string]any{"nodeId": normalizeDocNodeID(nodeID)}
if limit := driveIntFlagOrFallback(cmd, "limit", "max-results", "page-size"); limit > 0 {
params["maxResults"] = limit
}
if filterRole := driveStringFlag(cmd, "filter-role"); filterRole != "" {
params["filterRoleIds"] = parseDriveRoleList(filterRole)
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", "list_permission", params)
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().Int("limit", 30, "返回成员数上限")
cmd.Flags().Int("max-results", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("max-results")
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("filter-role", "", "按角色过滤,逗号分隔")
cmd.Flags().String("workspace", "", "知识库 ID (选填)")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
return cmd
}
func newDrivePermissionRemoveCommand(runner executor.Runner) *cobra.Command {
cmd := newDrivePermissionMutationCommand(runner, "remove", "remove_permission", false)
cmd.Aliases = []string{"rm"}
return cmd
}
func newDriveFolderCommand(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "folder",
Short: "文档空间文件夹兼容入口(deprecated)",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
create := &cobra.Command{
Use: "create",
Short: "创建文档空间文件夹(deprecated)",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(cmd.ErrOrStderr(), "warning: deprecated: use dws wiki node create --type folder instead.")
name, err := driveRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
}
params := map[string]any{"name": name}
if folder := driveFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
params["folderId"] = normalizeDocNodeID(folder)
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", "create_folder", params)
},
}
preferLegacyLeaf(root)
preferLegacyLeaf(create)
create.Flags().String("name", "", "文件夹名称 (必填)")
addDriveHiddenStringFlag(create, "title", "--name 的兼容别名")
create.Flags().String("folder", "", "父文件夹 nodeId 或 URL")
addDriveHiddenStringFlag(create, "parent-id", "--folder 的兼容别名")
create.Flags().String("workspace", "", "目标知识库 ID")
addDriveHiddenStringFlag(create, "workspace-id", "--workspace 的兼容别名")
root.AddCommand(create)
root.Hidden = true
return root
}
func runDriveInfo(cmd *cobra.Command, runner executor.Runner, params map[string]any) error {
result, err := driveInvocationResult(cmd, runner, "drive", "get_file_info", params)
if err != nil {
@@ -814,6 +1224,64 @@ func addDriveStringParam(cmd *cobra.Command, params map[string]any, paramName st
}
}
func addDriveStringSliceParam(cmd *cobra.Command, params map[string]any, paramName, flag string) {
if !cmd.Flags().Changed(flag) {
return
}
values, err := cmd.Flags().GetStringSlice(flag)
if err != nil || len(values) == 0 {
return
}
cleaned := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
if item := strings.TrimSpace(part); item != "" {
cleaned = append(cleaned, item)
}
}
}
if len(cleaned) > 0 {
params[paramName] = cleaned
}
}
func addDriveInt64Param(cmd *cobra.Command, params map[string]any, paramName, flag string) {
if !cmd.Flags().Changed(flag) {
return
}
value, err := cmd.Flags().GetInt64(flag)
if err == nil && value > 0 {
params[paramName] = value
}
}
func addDriveDocNodeFlags(cmd *cobra.Command) {
cmd.Flags().String("node", "", "节点 ID 或 URL (必填)")
addDriveHiddenStringFlag(cmd, "url", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "id", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "node-id", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "doc-id", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
}
func parseDriveRoleList(raw string) []string {
roles := make([]string, 0)
for _, part := range strings.Split(raw, ",") {
role := strings.ToUpper(strings.TrimSpace(part))
if role == "" {
continue
}
if normalized, ok := normalizeDocPermissionRole(role); ok {
roles = append(roles, normalized)
continue
}
if role == "OWNER" {
roles = append(roles, role)
}
}
return roles
}
func addDriveHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
+108
View File
@@ -21,19 +21,24 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
type driveCommandRunner struct {
calls int
all []executor.Invocation
last executor.Invocation
result executor.Result
err error
}
func (r *driveCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls++
r.last = invocation
r.all = append(r.all, invocation)
if r.err != nil {
return executor.Result{}, r.err
}
@@ -65,6 +70,109 @@ func TestDriveListPageSizeAliasMapsMaxResults(t *testing.T) {
}
}
func TestDriveListWorkspaceRoutesToDocListNodes(t *testing.T) {
t.Parallel()
runner := &driveCommandRunner{}
cmd := newDriveListCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--workspace-id", "WS_001", "--folder", "FOLDER_001", "--limit", "10"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "doc" {
t.Fatalf("product = %q, want doc", runner.last.CanonicalProduct)
}
if runner.last.Tool != "list_nodes" {
t.Fatalf("tool = %q, want list_nodes", runner.last.Tool)
}
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
t.Fatalf("workspaceId = %#v, want WS_001", got)
}
if got := runner.last.Params["folderId"]; got != "FOLDER_001" {
t.Fatalf("folderId = %#v, want FOLDER_001", got)
}
if got := runner.last.Params["pageSize"]; got != 10 {
t.Fatalf("pageSize = %#v, want 10", got)
}
}
func TestDriveCopyAliasesRouteToDoc(t *testing.T) {
t.Parallel()
runner := &driveCommandRunner{}
cmd := newDriveCopyCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--file-id", "NODE_001", "--parent-id", "FOLDER_001", "--workspace-id", "WS_001"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "copy_document" {
t.Fatalf("invocation = %#v, want doc copy_document", runner.last)
}
if got := runner.last.Params["nodeId"]; got != "NODE_001" {
t.Fatalf("nodeId = %#v, want NODE_001", got)
}
if got := runner.last.Params["targetFolderId"]; got != "FOLDER_001" {
t.Fatalf("targetFolderId = %#v, want FOLDER_001", got)
}
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
t.Fatalf("workspaceId = %#v, want WS_001", got)
}
}
func TestDrivePermissionRemoveRoutesToDoc(t *testing.T) {
t.Parallel()
runner := &driveCommandRunner{}
cmd := newDrivePermissionCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"remove", "--node", "NODE_001", "--users", "uid1,uid2"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "remove_permission" {
t.Fatalf("invocation = %#v, want doc remove_permission", runner.last)
}
users, ok := runner.last.Params["userIds"].([]string)
if !ok || strings.Join(users, ",") != "uid1,uid2" {
t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
}
}
func TestDriveSearchAggregatesDriveAndDoc(t *testing.T) {
t.Parallel()
runner := &driveCommandRunner{}
cmd := newDriveSearchCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--query", "报告", "--extensions", "pdf,docx", "--limit", "5"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.all) != 2 {
t.Fatalf("calls = %d, want 2", len(runner.all))
}
if runner.all[0].CanonicalProduct != "drive" || runner.all[0].Tool != "search_files" {
t.Fatalf("first invocation = %#v, want drive search_files", runner.all[0])
}
if runner.all[1].CanonicalProduct != "doc" || runner.all[1].Tool != "search_documents" {
t.Fatalf("second invocation = %#v, want doc search_documents", runner.all[1])
}
}
func TestDriveDownloadOutputDirectoryUsesServerFileName(t *testing.T) {
t.Parallel()
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler { return mailHandler{} })
}
// mailHandler contributes the hardcoded `mail message list` leaf. The MCP
// backend has no dedicated "list by folder" tool — wukong implements it by
// calling search_emails with a synthesised KQL query (folderId:<id>). The
// envelope/discovery layer cannot express that query construction (pipeline
// $flag templates resolve raw flag values and do not support string
// interpolation), so it lives here as a helper leaf. MergeCommandTree folds
// this single leaf into the envelope-driven mail tree without disturbing the
// other mail commands.
type mailHandler struct{}
func (mailHandler) Name() string { return "mail" }
func (mailHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "mail",
Short: i18n.T("邮箱"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
message := &cobra.Command{
Use: "message",
Short: i18n.T("邮件管理"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
message.AddCommand(newMailMessageListCommand(runner))
root.AddCommand(message)
return root
}
// newMailMessageListCommand builds `mail message list`, listing emails in a
// folder via search_emails with query=folderId:<id> (default inbox=2).
func newMailMessageListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: i18n.T("列出文件夹中的邮件"),
Example: " dws mail message list --email user@company.com # 默认列出收件箱\n" +
" dws mail message list --email user@company.com --folder-id 1 --limit 50",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
email := strings.TrimSpace(firstNonEmptyFlag(cmd, "email"))
if email == "" {
return apperrors.NewValidation("missing required flag(s): --email")
}
folderID := strings.TrimSpace(firstNonEmptyFlag(cmd, "folder-id", "folder"))
if folderID == "" {
folderID = "2" // inbox
}
params := map[string]any{
"email": email,
"query": "folderId:" + folderID,
}
if size := strings.TrimSpace(firstNonEmptyFlag(cmd, "limit", "size", "page-size")); size != "" {
params["size"] = size
}
if cursor := strings.TrimSpace(firstNonEmptyFlag(cmd, "cursor")); cursor != "" {
params["cursor"] = cursor
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "mail", "search_emails", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "mail", "search_emails", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("email", "", i18n.T("邮件所属邮箱地址 (必填)"))
cmd.Flags().String("folder-id", "", i18n.T("文件夹 ID (可选, 默认收件箱 2)"))
cmd.Flags().String("folder", "", i18n.T("--folder-id 的别名"))
cmd.Flags().String("limit", "", i18n.T("每页返回数量 (可选)"))
cmd.Flags().String("size", "", i18n.T("--limit 的别名"))
cmd.Flags().String("page-size", "", i18n.T("--limit 的别名"))
cmd.Flags().String("cursor", "", i18n.T("分页游标 (可选)"))
return cmd
}
// firstNonEmptyFlag returns the first non-empty string flag value among names.
func firstNonEmptyFlag(cmd *cobra.Command, names ...string) string {
for _, n := range names {
if v, err := cmd.Flags().GetString(n); err == nil && strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+159
View File
@@ -0,0 +1,159 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler { return minutesHandler{} })
}
// minutesHandler contributes the hardcoded `minutes list mine|shared|all`
// leaves. wukong lists minutes through a single tool
// (list_by_keyword_and_time_range) distinguished by belongingConditionId, and
// renames the raw MCP response fields (minutesDetails -> itemList,
// hasNext -> hasMore) for a stable CLI contract. The envelope/discovery layer
// maps these to older split tools and its outputFormat.rename cannot reach the
// nested result, so the canonical behaviour lives here. MergeCommandTree folds
// these leaves into the envelope-driven minutes tree.
type minutesHandler struct{}
func (minutesHandler) Name() string { return "minutes" }
func (minutesHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "minutes",
Short: i18n.T("听记"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
list := &cobra.Command{
Use: "list",
Short: i18n.T("听记列表"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
list.AddCommand(
newMinutesListCommand(runner, "mine", "created", i18n.T("查询我创建的听记列表")),
newMinutesListCommand(runner, "shared", "shared", i18n.T("查询他人共享给我的听记列表")),
newMinutesListCommand(runner, "all", "noLimit", i18n.T("查询我有权限访问的所有听记列表")),
)
root.AddCommand(list)
return root
}
func newMinutesListCommand(runner executor.Runner, use, belonging, short string) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
params := map[string]any{"belongingConditionId": belonging}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "limit", "max")); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil {
params["maxResults"] = n
}
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "query", "keyword")); v != "" {
params["keyword"] = v
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "cursor", "next-token", "offset")); v != "" {
params["nextToken"] = v
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "start")); v != "" {
ms, err := parseISOToMillis("start", v)
if err != nil {
return err
}
params["createTimeStart"] = float64(ms)
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "end")); v != "" {
ms, err := parseISOToMillis("end", v)
if err != nil {
return err
}
params["createTimeEnd"] = float64(ms)
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "minutes", "list_by_keyword_and_time_range", params,
)
if commandDryRun(cmd) {
return writeCommandPayload(cmd, inv)
}
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
renameMinutesListFields(result.Response)
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("limit", "", i18n.T("每页返回数量 (可选)"))
cmd.Flags().String("max", "", i18n.T("--limit 的别名"))
cmd.Flags().String("cursor", "", i18n.T("分页游标 (可选)"))
cmd.Flags().String("query", "", i18n.T("关键字筛选 (可选)"))
cmd.Flags().String("start", "", i18n.T("起始时间 ISO-8601 (可选)"))
cmd.Flags().String("end", "", i18n.T("结束时间 ISO-8601 (可选)"))
return cmd
}
// renameMinutesListFields renames the raw list fields inside the MCP response
// content (content.result.{minutesDetails->itemList, hasNext->hasMore}) so the
// CLI contract matches wukong. Safe no-op when the shape differs.
func renameMinutesListFields(resp map[string]any) {
if resp == nil {
return
}
content, ok := resp["content"].(map[string]any)
if !ok {
return
}
target := content
if inner, ok := content["result"].(map[string]any); ok {
target = inner
}
if v, ok := target["minutesDetails"]; ok {
target["itemList"] = v
delete(target, "minutesDetails")
}
if v, ok := target["hasNext"]; ok {
target["hasMore"] = v
delete(target, "hasNext")
}
}
func parseISOToMillis(label, s string) (int64, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UnixMilli(), nil
}
}
return 0, apperrors.NewValidation("--" + label + " 时间格式无效,请用 ISO-8601 (如 2026-03-10T14:00:00+08:00)")
}
File diff suppressed because it is too large Load Diff
+465
View File
@@ -0,0 +1,465 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"encoding/json"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// sheet range update 单元格结构的客户端校验,与 wukong 产品层 validateComplexValueCell
// 系列保持一致:拦住缺 type / richText 与整格 hyperlink 共存 / hyperlink.text 与
// cell.text 不一致 / 非法 dataValidation 等非法输入,避免把脏 payload 发到服务端。
var sheetComplexValueStyleFields = map[string]string{
"bold": "bool",
"italic": "bool",
"underline": "bool",
"strike": "bool",
"color": "string",
"size": "number",
}
func sheetValidateComplexValueStyle(style map[string]any, path string) error {
for k, v := range style {
kind, ok := sheetComplexValueStyleFields[k]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s 含未知字段 %q(合法字段: bold/italic/underline/strike/color/size)", path, k))
}
switch kind {
case "bool":
if _, ok := v.(bool); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为 boolean", path, k))
}
case "string":
if _, ok := v.(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为字符串(如 \"#FF0000\")", path, k))
}
case "number":
if _, ok := v.(float64); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为数字", path, k))
}
}
}
return nil
}
func sheetValidateRichTextItem(item map[string]any, path string) error {
typeRaw, ok := item["type"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: text/link/attachment/image)", path))
}
typeVal, ok := typeRaw.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
}
switch typeVal {
case "text":
if _, ok := item["text"].(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=text 必须包含 text 字符串字段", path))
}
case "link":
if _, ok := item["text"].(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=link 必须包含 text 字符串字段(显示文字)", path))
}
if s, ok := item["link"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=link 必须包含非空 link 字符串字段(超链接 URL)", path))
}
case "attachment":
if _, ok := item["text"].(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含 text 字符串字段(显示文件名)", path))
}
if s, ok := item["resourceId"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含非空 resourceId 字符串字段(通过 dws sheet media-upload 获取)", path))
}
if s, ok := item["mimeType"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含非空 mimeType 字符串字段", path))
}
case "image":
if s, ok := item["resourceId"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=image 必须包含非空 resourceId 字符串字段(通过 dws sheet media-upload 获取)", path))
}
if s, ok := item["resourceUrl"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=image 必须包含非空 resourceUrl 字符串字段", path))
}
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: text/link/attachment/image)", path, typeVal))
}
if styleRaw, exists := item["style"]; exists {
if typeVal != "text" && typeVal != "link" {
return apperrors.NewValidation(fmt.Sprintf("%s: style 字段仅 type=text / link 子项支持", path))
}
style, ok := styleRaw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.style 必须为 object", path))
}
if err := sheetValidateComplexValueStyle(style, path+".style"); err != nil {
return err
}
}
return nil
}
func sheetValidateCellHyperlink(raw any, path string) error {
if raw == nil {
return nil
}
hyperlink, ok := raw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s 必须为 object 或 null", path))
}
typeRaw, ok := hyperlink["type"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: path / sheet / range / none)", path))
}
typeVal, ok := typeRaw.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
}
switch typeVal {
case "path", "sheet", "range":
linkRaw, ok := hyperlink["link"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 link 字段", path))
}
link, ok := linkRaw.(string)
if !ok || strings.TrimSpace(link) == "" {
return apperrors.NewValidation(fmt.Sprintf("%s.link 必须为非空字符串", path))
}
if textRaw, exists := hyperlink["text"]; exists {
if _, ok := textRaw.(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.text 必须为字符串", path))
}
}
case "none":
// type=none 表示显式清除单元格级超链接,不需其他字段
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: path / sheet / range / none)", path, typeVal))
}
return nil
}
func sheetValidateDataValidation(dvRaw any, path string) error {
dv, ok := dvRaw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s 必须为 object", path))
}
dvType, ok := dv["type"].(string)
if !ok || dvType == "" {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为非空字符串(合法值: dropdown / checkbox / none)", path))
}
switch dvType {
case "dropdown":
optionsRaw, ok := dv["options"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=dropdown 必须包含 options 数组", path))
}
options, ok := optionsRaw.([]any)
if !ok || len(options) == 0 {
return apperrors.NewValidation(fmt.Sprintf("%s.options 必须为非空数组", path))
}
for i, opt := range options {
optMap, ok := opt.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i))
}
val, ok := optMap["value"].(string)
if !ok || val == "" {
return apperrors.NewValidation(fmt.Sprintf("%s.options[%d].value 必须为非空字符串", path, i))
}
}
case "checkbox":
if c, exists := dv["checked"]; exists {
if _, ok := c.(bool); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.checked 必须为 boolean", path))
}
}
case "none":
// type=none 表示显式清除单元格 DV,不需其他字段
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: dropdown / checkbox / none)", path, dvType))
}
return nil
}
// sheet range read 回读投影:MCP 框架会按 schema 把 dataValidation / hyperlink
// 填成"全字段 null"的空壳,导致清除后回读仍带着这些 key。与 wukong
// callMCPToolCellInfos 一致:递归找到 cells,把全 null 的空壳 delete 掉,
// 让"清除后回读不应再有该 key"的语义成立。
func sheetCleanCellInfos(v any) {
switch t := v.(type) {
case map[string]any:
if cellsRaw, ok := t["cells"]; ok {
sheetStripCellsMeta(cellsRaw)
}
for _, child := range t {
sheetCleanCellInfos(child)
}
case []any:
for _, child := range t {
sheetCleanCellInfos(child)
}
}
}
func sheetStripCellsMeta(cellsRaw any) {
rows, ok := cellsRaw.([]any)
if !ok {
return
}
for _, row := range rows {
rowSlice, ok := row.([]any)
if !ok {
continue
}
for _, cell := range rowSlice {
cellMap, ok := cell.(map[string]any)
if !ok {
continue
}
if dv, ok := cellMap["dataValidation"]; ok && sheetIsEmptyMetaShell(dv) {
delete(cellMap, "dataValidation")
}
if hl, ok := cellMap["hyperlink"]; ok && sheetIsEmptyMetaShell(hl) {
delete(cellMap, "hyperlink")
}
}
}
}
// sheet info 坐标投影:只向 agent 暴露 A1/UI 语义的 nonEmptyRange
// (range / lastCell / lastRow / lastColumn),并清掉服务端的 legacy 0-based
// 字段。与 wukong normalizeSheetInfoCoordinatesForAgent 一致。
func sheetNormalizeInfoCoordinates(v any) {
switch t := v.(type) {
case map[string]any:
_, hasNonEmpty := t["nonEmptyRange"]
_, hasLegacyRow := t["lastNonEmptyRow"]
if hasNonEmpty || hasLegacyRow {
sheetApplyNonEmptyRange(t)
}
for _, child := range t {
sheetNormalizeInfoCoordinates(child)
}
case []any:
for _, child := range t {
sheetNormalizeInfoCoordinates(child)
}
}
}
func sheetApplyNonEmptyRange(sheet map[string]any) {
if ner := sheetNormalizeNonEmptyRangeObject(sheet["nonEmptyRange"]); ner != nil {
sheet["nonEmptyRange"] = ner
} else if ner := sheetBuildNonEmptyRangeFromLegacy(sheet); ner != nil {
sheet["nonEmptyRange"] = ner
} else {
sheet["nonEmptyRange"] = nil
}
delete(sheet, "lastNonEmptyRow")
delete(sheet, "lastNonEmptyColumn")
delete(sheet, "lastNonEmptyIndexBase")
delete(sheet, "lastNonEmptyRowNumber")
delete(sheet, "lastNonEmptyColumnLetter")
delete(sheet, "nonEmptyRangeA1")
}
func sheetNormalizeNonEmptyRangeObject(v any) map[string]any {
ner, ok := v.(map[string]any)
if !ok {
return nil
}
rangeValue, hasRange := ner["range"].(string)
lastCell, hasLastCell := ner["lastCell"].(string)
lastRow, hasLastRow := sheetNonNegativeJSONInt(ner["lastRow"])
lastColumn, hasLastColumn := ner["lastColumn"].(string)
if hasRange && hasLastCell && hasLastRow && hasLastColumn {
return map[string]any{
"range": rangeValue,
"lastCell": lastCell,
"lastRow": lastRow,
"lastColumn": lastColumn,
}
}
return nil
}
func sheetBuildNonEmptyRangeFromLegacy(sheet map[string]any) map[string]any {
row, hasRow := sheetNonNegativeJSONInt(sheet["lastNonEmptyRow"])
col, hasCol := sheetNonNegativeJSONInt(sheet["lastNonEmptyColumn"])
if !hasRow || !hasCol {
return nil
}
lastColumnLetter := sheetColumnLetterFromZeroBased(col)
lastRowNumber := row + 1
lastCellA1 := fmt.Sprintf("%s%d", lastColumnLetter, lastRowNumber)
return map[string]any{
"range": "A1:" + lastCellA1,
"lastCell": lastCellA1,
"lastRow": lastRowNumber,
"lastColumn": lastColumnLetter,
}
}
func sheetNonNegativeJSONInt(v any) (int, bool) {
switch n := v.(type) {
case int:
return n, n >= 0
case int64:
if n < 0 {
return 0, false
}
return int(n), true
case float64:
if n < 0 {
return 0, false
}
i := int(n)
if float64(i) != n {
return 0, false
}
return i, true
case json.Number:
i, err := n.Int64()
if err != nil || i < 0 {
return 0, false
}
return int(i), true
default:
return 0, false
}
}
func sheetColumnLetterFromZeroBased(index int) string {
if index < 0 {
return ""
}
index++
var b strings.Builder
for index > 0 {
index--
b.WriteByte(byte('A' + index%26))
index /= 26
}
letters := []byte(b.String())
for i, j := 0, len(letters)-1; i < j; i, j = i+1, j-1 {
letters[i], letters[j] = letters[j], letters[i]
}
return string(letters)
}
// sheetIsEmptyMetaShell 判断 metadata 块是否为全 null 空壳(MCP 框架按 schema 填充的)。
func sheetIsEmptyMetaShell(v any) bool {
if v == nil {
return true
}
m, ok := v.(map[string]any)
if !ok {
return false
}
for _, vv := range m {
if vv != nil {
return false
}
}
return true
}
func sheetValidateComplexValueCell(cell map[string]any, path string) error {
if dvRaw, exists := cell["dataValidation"]; exists {
if err := sheetValidateDataValidation(dvRaw, path+".dataValidation"); err != nil {
return err
}
}
if hyperlinkRaw, exists := cell["hyperlink"]; exists {
if err := sheetValidateCellHyperlink(hyperlinkRaw, path+".hyperlink"); err != nil {
return err
}
}
typeRaw, hasType := cell["type"]
// 没有 type 时,必须有 metadata 字段(不写值,只更新元数据)
if !hasType {
_, hasDV := cell["dataValidation"]
_, hasCS := cell["cellStyles"]
_, hasHyperlink := cell["hyperlink"]
if hasDV || hasCS || hasHyperlink {
return nil
}
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: text/richText)", path))
}
typeVal, ok := typeRaw.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
}
switch typeVal {
case "text":
var cellText string
hasCellText := false
if t, exists := cell["text"]; exists {
textValue, ok := t.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.text 必须为字符串(text=\"\" 表示清空 cell)", path))
}
cellText = textValue
hasCellText = true
}
if styleRaw, exists := cell["style"]; exists {
style, ok := styleRaw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.style 必须为 object", path))
}
if err := sheetValidateComplexValueStyle(style, path+".style"); err != nil {
return err
}
}
if hyperlinkRaw, exists := cell["hyperlink"]; exists && hyperlinkRaw != nil {
hyperlink, _ := hyperlinkRaw.(map[string]any)
if hyperlinkText, ok := hyperlink["text"].(string); ok && hasCellText && hyperlinkText != cellText {
return apperrors.NewValidation(fmt.Sprintf("%s.hyperlink.text 与 %s.text 不一致,请只传 cell.text 或保持两者相同", path, path))
}
}
case "richText":
if _, hasHyperlink := cell["hyperlink"]; hasHyperlink {
return apperrors.NewValidation(fmt.Sprintf("%s: cell-level hyperlink 不能与 type=richText 同时使用;整格链接用 hyperlink,片段链接用 richText.texts[].type=link", path))
}
textsRaw, ok := cell["texts"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=richText 必须包含 texts 数组", path))
}
texts, ok := textsRaw.([]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.texts 必须为数组", path))
}
if len(texts) == 0 {
return apperrors.NewValidation(fmt.Sprintf("%s.texts 不能为空数组", path))
}
for i, item := range texts {
itemMap, ok := item.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.texts[%d] 必须为 object", path, i))
}
if err := sheetValidateRichTextItem(itemMap, fmt.Sprintf("%s.texts[%d]", path, i)); err != nil {
return err
}
}
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: text / richText;不再支持 number/boolean/null,数字布尔请用 {type:text,text:\"...\"} 字符串形式)", path, typeVal))
}
return nil
}
+217
View File
@@ -0,0 +1,217 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
type sheetCommandRunner struct {
last executor.Invocation
calls []executor.Invocation
}
func (r *sheetCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.last = invocation
r.calls = append(r.calls, invocation)
return executor.Result{Invocation: invocation}, nil
}
func executeSheetCommand(t *testing.T, runner *sheetCommandRunner, args ...string) {
t.Helper()
cmd := sheetHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
}
func TestSheetCreateCallsCreateWorkspaceSheet(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner, "create", "--name", "销售数据", "--folder", "FOLDER_001", "--workspace", "WS_001")
if runner.last.Tool != "create_workspace_sheet" {
t.Fatalf("tool = %q, want create_workspace_sheet", runner.last.Tool)
}
if got := runner.last.Params["name"]; got != "销售数据" {
t.Fatalf("name = %#v", got)
}
if got := runner.last.Params["folderId"]; got != "FOLDER_001" {
t.Fatalf("folderId = %#v", got)
}
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
t.Fatalf("workspaceId = %#v", got)
}
}
func TestSheetRangeUpdateCallsSetCellRange(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"range", "update",
"--node", "NODE_001",
"--sheet-id", "SHEET_001",
"--range", "A1:B1",
"--values", `[[{"type":"text","text":"姓名"},{"type":"text","text":"分数"}]]`,
)
if runner.last.Tool != "set_cell_range" {
t.Fatalf("tool = %q, want set_cell_range", runner.last.Tool)
}
if got := runner.last.Params["rangeAddress"]; got != "A1:B1" {
t.Fatalf("rangeAddress = %#v", got)
}
cells, ok := runner.last.Params["cells"].([][]any)
if !ok || len(cells) != 1 || len(cells[0]) != 2 {
t.Fatalf("cells = %#v", runner.last.Params["cells"])
}
}
func TestSheetRangeSetStyleExpandsScalarStyle(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"range", "set-style",
"--node", "NODE_001",
"--sheet-id", "SHEET_001",
"--range", "A1:B2",
"--bg-color", "#FFF2CC",
"--font-weight", "bold",
)
if runner.last.Tool != "update_range" {
t.Fatalf("tool = %q, want update_range", runner.last.Tool)
}
bg, ok := runner.last.Params["backgroundColors"].([][]string)
if !ok || len(bg) != 2 || len(bg[0]) != 2 || bg[1][1] != "#FFF2CC" {
t.Fatalf("backgroundColors = %#v", runner.last.Params["backgroundColors"])
}
weights, ok := runner.last.Params["fontWeights"].([][]string)
if !ok || weights[0][0] != "bold" {
t.Fatalf("fontWeights = %#v", runner.last.Params["fontWeights"])
}
}
func TestSheetFilterViewUpdateCriteriaCallsSetCriteria(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"filter-view", "update-criteria",
"--node", "NODE_001",
"--sheet-id", "SHEET_001",
"--filter-view-id", "FV_001",
"--column", "2",
"--filter-criteria", `{"filterType":"values","visibleValues":["销售部"]}`,
)
if runner.last.Tool != "set_filter_view_criteria" {
t.Fatalf("tool = %q, want set_filter_view_criteria", runner.last.Tool)
}
if got := runner.last.Params["filterViewId"]; got != "FV_001" {
t.Fatalf("filterViewId = %#v", got)
}
if got := runner.last.Params["column"]; got != 2 {
t.Fatalf("column = %#v", got)
}
}
func TestSheetCondFormatCreateExpandsCondition(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"cond-format", "create",
"--node", "NODE_001",
"--sheet-id", "SHEET_001",
"--ranges", `["A1:A10"]`,
"--condition", `{"numberCondition":{"operator":"greater","value1":"80"}}`,
"--cell-style", `{"backgroundColor":"#FFCDD2"}`,
)
if runner.last.Tool != "create_cond_format" {
t.Fatalf("tool = %q, want create_cond_format", runner.last.Tool)
}
if _, ok := runner.last.Params["numberCondition"]; !ok {
t.Fatalf("numberCondition missing from %#v", runner.last.Params)
}
if _, ok := runner.last.Params["cellStyle"]; !ok {
t.Fatalf("cellStyle missing from %#v", runner.last.Params)
}
}
func TestSheetCSVCommandsAcceptFileIDAlias(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"csv-get",
"--file-id", "NODE_001",
"--sheet-id", "SHEET_001",
"--range", "A1:B2",
)
if runner.last.Tool != "get_range_as_csv" {
t.Fatalf("tool = %q, want get_range_as_csv", runner.last.Tool)
}
if got := runner.last.Params["nodeId"]; got != "NODE_001" {
t.Fatalf("csv-get nodeId = %#v", got)
}
executeSheetCommand(t, runner,
"csv-put",
"--file-id", "NODE_002",
"--sheet-id", "SHEET_002",
"--csv", "a,b\n1,2",
"--start-cell", "A1",
)
if runner.last.Tool != "set_range_from_csv" {
t.Fatalf("tool = %q, want set_range_from_csv", runner.last.Tool)
}
if got := runner.last.Params["nodeId"]; got != "NODE_002" {
t.Fatalf("csv-put nodeId = %#v", got)
}
}
func TestSheetReplaceAllowsEmptyReplacement(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"replace",
"--node", "NODE_001",
"--sheet-id", "SHEET_001",
"--find", "临时",
"--replacement", "",
)
if runner.last.Tool != "replace_all" {
t.Fatalf("tool = %q, want replace_all", runner.last.Tool)
}
if got := runner.last.Params["replaceText"]; got != "" {
t.Fatalf("replaceText = %#v, want empty string", got)
}
}
+1
View File
@@ -69,6 +69,7 @@ func (todoHandler) Command(runner executor.Runner) *cobra.Command {
newTodoTaskDoneCommand(runner),
newTodoTaskGetCommand(runner),
newTodoTaskDeleteCommand(runner),
newTodoAddAttachmentCommand(runner),
)
root.AddCommand(task)
return root
+328
View File
@@ -0,0 +1,328 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
// newTodoAddAttachmentCommand builds the hardcoded `todo task add-attachment`
// leaf. The MCP backend exposes attachment upload as a multi-step orchestration
// that the envelope/pipeline layer cannot express (no upload step, local file
// IO): init upload credentials -> HTTP PUT the local file -> commit -> add.
// wukong implements it in code; this is the open-edition equivalent. It is
// wired into the existing todo handler's task group (see todo.go).
func newTodoAddAttachmentCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add-attachment",
Short: i18n.T("上传待办附件"),
Long: i18n.T("上传本地文件作为待办附件(init → 上传 → commit → add 四步)。会真实上传文件,请确认待办存在。"),
Example: " dws todo task add-attachment --task-id <taskId> --file-path <filePath>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
taskID := strings.TrimSpace(firstNonEmptyFlag(cmd, "task-id", "id"))
filePath := strings.TrimSpace(firstNonEmptyFlag(cmd, "file-path", "file"))
if taskID == "" {
return apperrors.NewValidation("missing required flag(s): --task-id")
}
if filePath == "" {
return apperrors.NewValidation("missing required flag(s): --file-path")
}
fi, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("cannot read file %s: %v", filePath, err))
}
if fi.IsDir() {
return apperrors.NewValidation(fmt.Sprintf("%s is a directory, not a file", filePath))
}
fileName := filepath.Base(filePath)
fileType := strings.TrimPrefix(filepath.Ext(fileName), ".")
fileSize := fi.Size()
md5Hex, err := fileMD5Hex(filePath)
if err != nil {
return err
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "add_todo_attachment", map[string]any{
"todoAttachmentAddRequest": map[string]any{
"taskId": taskID,
"fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
},
}))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
// 1) init upload credentials
initRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "init_todo_file_upload", map[string]any{
"todoAttachmentInitUploadInfoRequest": map[string]any{
"fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
},
}))
if err != nil {
return err
}
resourceURL := findStringDeep(initRes.Response, "resourceUrl", "resourceURL", "url")
if resourceURL == "" {
resourceURL = findFirstInStringArrayDeep(initRes.Response, "resourceUrls", "resourceURLs")
}
uploadKey := findStringDeep(initRes.Response, "uploadKey", "key")
if resourceURL == "" || uploadKey == "" {
return apperrors.NewAPI(fmt.Sprintf("incomplete upload credentials: resourceUrl=%q uploadKey=%q", resourceURL, uploadKey))
}
headers := findHeadersDeep(initRes.Response, "headers", "ossHeaders")
// 2) PUT the local file to the resource URL
if err := httpPutLocalFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
// 3) commit upload
commitRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "commit_todo_file_upload", map[string]any{
"todoAttachmentCommitUploadInfoRequest": map[string]any{
"uploadKey": uploadKey, "fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
},
}))
if err != nil {
return err
}
dentryID := findInt64Deep(commitRes.Response, "dentryId", "dentryID")
spaceID := findInt64Deep(commitRes.Response, "spaceId", "spaceID")
if dentryID == 0 || spaceID == 0 {
return apperrors.NewAPI("uploaded file response missing dentryId or spaceId")
}
// 4) add attachment to the todo
addRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "add_todo_attachment", map[string]any{
"todoAttachmentAddRequest": map[string]any{
"taskId": taskID,
"attachmentList": []any{map[string]any{
"fileId": strconv.FormatInt(dentryID, 10),
"fileName": fileName,
"fileSize": fileSize,
"spaceId": strconv.FormatInt(spaceID, 10),
"fileType": fileType,
}},
},
}))
if err != nil {
return err
}
return writeCommandPayload(cmd, addRes)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)"))
cmd.Flags().String("id", "", i18n.T("--task-id 的别名"))
cmd.Flags().String("file-path", "", i18n.T("本地文件路径 (必填)"))
cmd.Flags().String("file", "", i18n.T("--file-path 的别名"))
return cmd
}
func fileMD5Hex(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("cannot open file %s: %v", path, err))
}
defer f.Close()
h := md5.New()
if _, err := io.Copy(h, f); err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("md5 of %s: %v", path, err))
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func httpPutLocalFile(ctx context.Context, url string, headers map[string]string, path string, size int64) error {
f, err := os.Open(path)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("cannot open file %s: %v", path, err))
}
defer f.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, f)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("build PUT request: %v", err))
}
req.ContentLength = size
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return apperrors.NewAPI(fmt.Sprintf("upload PUT failed: %v", err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return apperrors.NewAPI(fmt.Sprintf("upload PUT returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
}
return nil
}
// findStringDeep recursively searches a decoded JSON value for the first
// non-empty string value under any of the given keys.
func findStringDeep(v any, keys ...string) string {
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if s, ok := scalarString(t[k]); ok && s != "" {
return s
}
}
for _, child := range t {
if s := findStringDeep(child, keys...); s != "" {
return s
}
}
case []any:
for _, child := range t {
if s := findStringDeep(child, keys...); s != "" {
return s
}
}
}
return ""
}
// findFirstInStringArrayDeep recursively finds the first non-empty string in an
// array stored under any of the given keys (e.g. "resourceUrls").
func findFirstInStringArrayDeep(v any, keys ...string) string {
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if arr, ok := t[k].([]any); ok {
for _, e := range arr {
if s, ok := scalarString(e); ok && s != "" {
return s
}
}
}
}
for _, child := range t {
if s := findFirstInStringArrayDeep(child, keys...); s != "" {
return s
}
}
case []any:
for _, child := range t {
if s := findFirstInStringArrayDeep(child, keys...); s != "" {
return s
}
}
}
return ""
}
func findInt64Deep(v any, keys ...string) int64 {
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if n, ok := scalarInt64(t[k]); ok && n != 0 {
return n
}
}
for _, child := range t {
if n := findInt64Deep(child, keys...); n != 0 {
return n
}
}
case []any:
for _, child := range t {
if n := findInt64Deep(child, keys...); n != 0 {
return n
}
}
}
return 0
}
func findHeadersDeep(v any, keys ...string) map[string]string {
out := map[string]string{}
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if h, ok := t[k].(map[string]any); ok {
for name, val := range h {
if s, ok := scalarString(val); ok && s != "" {
out[name] = s
}
}
}
}
if len(out) == 0 {
for _, child := range t {
if h := findHeadersDeep(child, keys...); len(h) > 0 {
return h
}
}
}
}
return out
}
func scalarString(v any) (string, bool) {
switch s := v.(type) {
case string:
return s, true
case json.Number:
return s.String(), true
case float64:
return strconv.FormatFloat(s, 'f', -1, 64), true
}
return "", false
}
func scalarInt64(v any) (int64, bool) {
switch n := v.(type) {
case json.Number:
if i, err := n.Int64(); err == nil {
return i, true
}
if f, err := n.Float64(); err == nil {
return int64(f), true
}
case float64:
return int64(n), true
case int64:
return n, true
case string:
if i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64); err == nil {
return i, true
}
}
return 0, false
}
+297 -1
View File
@@ -14,6 +14,8 @@
package helpers
import (
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
@@ -64,6 +66,7 @@ func (wikiHandler) Command(runner executor.Runner) *cobra.Command {
newWikiSpaceGetCommand(runner),
newWikiSpaceListCommand(runner),
newWikiSpaceSearchCommand(runner),
newWikiSpaceDeleteCommand(runner),
)
root.AddCommand(space)
@@ -81,6 +84,7 @@ func (wikiHandler) Command(runner executor.Runner) *cobra.Command {
newWikiMemberAddCommand(runner),
newWikiMemberUpdateCommand(runner),
newWikiMemberListCommand(runner),
newWikiMemberRemoveCommand(runner),
)
root.AddCommand(member)
return root
@@ -150,6 +154,20 @@ func newWikiSpaceListCommand(runner executor.Runner) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
params := map[string]any{}
if spaceType := wikiFlagOrFallback(cmd, "type"); spaceType != "" {
if spaceType == "orgSpace" || spaceType == "mySpace" {
driveParams := map[string]any{"spaceType": spaceType}
if limit := wikiFlagOrFallback(cmd, "limit", "page-size"); limit != "" {
if n, err := strconv.Atoi(limit); err == nil {
driveParams["maxResults"] = n
} else {
driveParams["maxResults"] = limit
}
}
if pageToken := wikiFlagOrFallback(cmd, "cursor", "page-token"); pageToken != "" {
driveParams["nextToken"] = pageToken
}
return runWikiProductTool(cmd, runner, "drive", "list_spaces", driveParams)
}
params["wikiSpaceType"] = spaceType
}
if limit := wikiFlagOrFallback(cmd, "limit", "page-size"); limit != "" {
@@ -211,6 +229,32 @@ func newWikiSpaceSearchCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func newWikiSpaceDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: "删除知识库",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
if !confirmDeletePrompt(cmd, "知识库", workspaceID) {
return nil
}
return runWikiTool(cmd, runner, "delete_wikiSpace", map[string]any{
"workspaceId": workspaceID,
})
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
addWikiHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
return cmd
}
func newWikiMemberAddCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add",
@@ -313,10 +357,233 @@ func newWikiMemberListCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func newWikiMemberRemoveCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "移除知识库成员",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
user, err := wikiRequiredFlagOrFallback(cmd, "users", "user", "uid")
if err != nil {
return err
}
return runWikiTool(cmd, runner, "remove_member", map[string]any{
"workspaceId": workspaceID,
"userIds": wikiCSV(user),
})
},
}
preferLegacyLeaf(cmd)
addWikiMemberListWorkspaceFlag(cmd)
cmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
addWikiHiddenStringFlag(cmd, "user", "--users 的兼容别名")
addWikiHiddenStringFlag(cmd, "uid", "--users 的兼容别名")
return cmd
}
func newWikiNodeListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "列出知识库节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
params := map[string]any{"workspaceId": workspaceID}
if folder := wikiFlagOrFallback(cmd, "folder", "node", "parent-id"); folder != "" {
params["folderId"] = normalizeDocNodeID(folder)
}
if limit := wikiIntFlagOrFallback(cmd, "limit", "page-size"); limit > 0 {
params["pageSize"] = limit
}
if cursor := wikiFlagOrFallback(cmd, "cursor", "page-token"); cursor != "" {
params["pageToken"] = cursor
}
return runWikiProductTool(cmd, runner, "doc", "list_nodes", params)
},
}
preferLegacyLeaf(cmd)
addWikiNodeWorkspaceFlag(cmd)
cmd.Flags().String("folder", "", "父节点 nodeId")
addWikiHiddenStringFlag(cmd, "node", "--folder 的兼容别名")
addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
cmd.Flags().Int("limit", 0, "每页数量")
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("cursor", "", "分页游标")
addWikiHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
return cmd
}
func newWikiNodeCreateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "在知识库中创建节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
name, err := wikiRequiredFlag(cmd, "name")
if err != nil {
return err
}
params := map[string]any{
"workspaceId": workspaceID,
"name": name,
}
if nodeType := wikiFlagOrFallback(cmd, "type"); nodeType != "" {
params["type"] = nodeType
}
if folder := wikiFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
params["folderId"] = normalizeDocNodeID(folder)
}
return runWikiProductTool(cmd, runner, "doc", "create_file", params)
},
}
preferLegacyLeaf(cmd)
addWikiNodeWorkspaceFlag(cmd)
cmd.Flags().String("name", "", "节点名称 (必填)")
cmd.Flags().String("type", "adoc", "节点类型: adoc / asheet / folder / axls")
cmd.Flags().String("folder", "", "父节点 nodeId")
addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
return cmd
}
func newWikiNodeCopyCommand(runner executor.Runner) *cobra.Command {
return newWikiNodeTransferCommand(runner, "copy", "copy_document")
}
func newWikiNodeMoveCommand(runner executor.Runner) *cobra.Command {
return newWikiNodeTransferCommand(runner, "move", "move_document")
}
func newWikiNodeTransferCommand(runner executor.Runner, use, tool string) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: use + " 知识库节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
nodeID, err := wikiRequiredFlagOrFallback(cmd, "node", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
params := map[string]any{
"nodeId": normalizeDocNodeID(nodeID),
"workspaceId": workspaceID,
}
if folder := wikiFlagOrFallback(cmd, "folder", "parent-id", "parent-node-id", "parent-folder-id"); folder != "" {
params["targetFolderId"] = normalizeDocNodeID(folder)
}
return runWikiProductTool(cmd, runner, "doc", tool, params)
},
}
preferLegacyLeaf(cmd)
addWikiNodeWorkspaceFlag(cmd)
addWikiNodeIDFlags(cmd)
cmd.Flags().String("folder", "", "目标文件夹 nodeId")
addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
addWikiHiddenStringFlag(cmd, "parent-node-id", "--folder 的兼容别名")
addWikiHiddenStringFlag(cmd, "parent-folder-id", "--folder 的兼容别名")
return cmd
}
func newWikiNodeDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: "删除知识库节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if _, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId"); err != nil {
return err
}
nodeID, err := wikiRequiredFlagOrFallback(cmd, "node", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if !confirmDeletePrompt(cmd, "知识库节点", nodeID) {
return nil
}
return runWikiProductTool(cmd, runner, "doc", "delete_document", map[string]any{
"nodeId": normalizeDocNodeID(nodeID),
})
},
}
preferLegacyLeaf(cmd)
addWikiNodeWorkspaceFlag(cmd)
addWikiNodeIDFlags(cmd)
cmd.Flags().BoolP("yes", "y", false, "跳过确认直接删除")
return cmd
}
func newWikiNodeSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "在知识库中搜索节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
query := wikiFlagOrFallback(cmd, "query", "keyword")
if query == "" {
return apperrors.NewValidation("--query is required")
}
params := map[string]any{
"keyword": query,
"workspaceIds": []string{workspaceID},
}
if values, _ := cmd.Flags().GetStringSlice("extensions"); len(values) > 0 {
params["extensions"] = splitWikiStringSlice(values)
}
if limit := wikiIntFlagOrFallback(cmd, "limit"); limit > 0 {
params["pageSize"] = limit
}
if cursor := wikiFlagOrFallback(cmd, "cursor", "page-token"); cursor != "" {
params["pageToken"] = cursor
}
return runWikiProductTool(cmd, runner, "doc", "search_documents", params)
},
}
preferLegacyLeaf(cmd)
addWikiNodeWorkspaceFlag(cmd)
cmd.Flags().String("query", "", "搜索关键词 (必填)")
addWikiHiddenStringFlag(cmd, "keyword", "--query 的兼容别名")
cmd.Flags().StringSlice("extensions", nil, "按扩展名过滤,逗号分隔")
cmd.Flags().Int("limit", 0, "每页数量")
cmd.Flags().String("cursor", "", "分页游标")
addWikiHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
return cmd
}
func runWikiTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
return runWikiProductTool(cmd, runner, "wiki", tool, params)
}
func runWikiProductTool(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"wiki",
product,
tool,
params,
)
@@ -344,6 +611,19 @@ func addWikiMemberListWorkspaceFlag(cmd *cobra.Command) {
addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
}
func addWikiNodeWorkspaceFlag(cmd *cobra.Command) {
cmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
addWikiHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
}
func addWikiNodeIDFlags(cmd *cobra.Command) {
cmd.Flags().String("node", "", "节点 ID 或 URL (必填)")
addWikiHiddenStringFlag(cmd, "node-id", "--node 的兼容别名")
addWikiHiddenStringFlag(cmd, "doc-id", "--node 的兼容别名")
addWikiHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
}
func addWikiHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
@@ -397,3 +677,19 @@ func wikiCSV(raw string) []string {
}
return values
}
func splitWikiStringSlice(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
if item := strings.TrimSpace(part); item != "" {
out = append(out, item)
}
}
}
return out
}
func wikiUnsupportedCommand(name string) error {
return fmt.Errorf("unsupported wiki command: %s", name)
}
+8 -3
View File
@@ -38,15 +38,19 @@ func addWikiProxyCommands(root *cobra.Command, runner executor.Runner) {
newWikiProxyLeaf(runner, "search", wikiProxyTargetSpace, []string{"space", "search"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "create", wikiProxyTargetSpace, []string{"space", "create"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "get", wikiProxyTargetSpace, []string{"space", "get"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "delete", wikiProxyTargetSpace, []string{"space", "delete"}, wikiProxyOptions{}),
)
node := newWikiProxyGroup("node", "知识库节点兼容入口")
node.AddCommand(
newWikiProxyLeaf(runner, "list", wikiProxyTargetDoc, []string{"list"}, wikiProxyOptions{}),
newWikiNodeListCommand(runner),
newWikiProxyLeaf(runner, "read", wikiProxyTargetDoc, []string{"read"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "info", wikiProxyTargetDoc, []string{"info"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "create", wikiProxyTargetDoc, []string{"create"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "search", wikiProxyTargetDoc, []string{"search"}, wikiProxyOptions{workspaceToWorkspaceIDs: true}),
newWikiNodeCreateCommand(runner),
newWikiNodeCopyCommand(runner),
newWikiNodeMoveCommand(runner),
newWikiNodeDeleteCommand(runner),
newWikiNodeSearchCommand(runner),
)
file := newWikiProxyGroup("file", "知识库文件兼容入口")
@@ -164,6 +168,7 @@ func newWikiProxySpaceTargetRoot(runner executor.Runner) *cobra.Command {
newWikiSpaceGetCommand(runner),
newWikiSpaceListCommand(runner),
newWikiSpaceSearchCommand(runner),
newWikiSpaceDeleteCommand(runner),
)
root.AddCommand(space)
return root
+89
View File
@@ -16,6 +16,7 @@ package helpers
import (
"bytes"
"context"
"reflect"
"strconv"
"strings"
"testing"
@@ -259,6 +260,94 @@ func TestWikiMemberAddUsesWorkspaceIDAlias(t *testing.T) {
}
}
func TestWikiSpaceListDriveTypesRouteToDrive(t *testing.T) {
t.Parallel()
runner := &wikiCommandRunner{}
cmd := wikiHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"space", "list", "--type", "orgSpace", "--limit", "10"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "drive" {
t.Fatalf("product = %q, want drive", runner.last.CanonicalProduct)
}
if runner.last.Tool != "list_spaces" {
t.Fatalf("tool = %q, want list_spaces", runner.last.Tool)
}
if got := runner.last.Params["spaceType"]; got != "orgSpace" {
t.Fatalf("spaceType = %#v, want orgSpace", got)
}
if got := runner.last.Params["maxResults"]; got != 10 {
t.Fatalf("maxResults = %#v, want 10", got)
}
}
func TestWikiNodeSearchRoutesToDoc(t *testing.T) {
t.Parallel()
runner := &wikiCommandRunner{}
cmd := wikiHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"node", "search",
"--workspace-id", "WS_001",
"--keyword", "方案",
"--extensions", "adoc,asheet",
"--limit", "5",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "search_documents" {
t.Fatalf("invocation = %#v, want doc search_documents", runner.last)
}
if got := runner.last.Params["keyword"]; got != "方案" {
t.Fatalf("keyword = %#v, want 方案", got)
}
if got := runner.last.Params["workspaceIds"]; !reflect.DeepEqual(got, []string{"WS_001"}) {
t.Fatalf("workspaceIds = %#v, want []string{WS_001}", got)
}
if got := runner.last.Params["extensions"]; !reflect.DeepEqual(got, []string{"adoc", "asheet"}) {
t.Fatalf("extensions = %#v, want adoc/asheet", got)
}
if got := runner.last.Params["pageSize"]; got != 5 {
t.Fatalf("pageSize = %#v, want 5", got)
}
}
func TestWikiMemberRemoveRoutesToWiki(t *testing.T) {
t.Parallel()
runner := &wikiCommandRunner{}
cmd := wikiHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"member", "remove", "--workspace-id", "WS_001", "--user", "uid1,uid2"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "wiki" || runner.last.Tool != "remove_member" {
t.Fatalf("invocation = %#v, want wiki remove_member", runner.last)
}
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
t.Fatalf("workspaceId = %#v, want WS_001", got)
}
users, ok := runner.last.Params["userIds"].([]string)
if !ok || strings.Join(users, ",") != "uid1,uid2" {
t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
}
}
func TestWikiMemberUpdateAcceptsWukongUsersAlias(t *testing.T) {
t.Parallel()
+5 -3
View File
@@ -36,9 +36,11 @@ const (
// discoveryAPIPath is the path appended to BaseURL when fetching the
// MCP server list. Kept as a single constant so the version-coded
// segment (".../bamboo") lives in one place and future version bumps
// only touch here. See registry_test.go for the matching fixture path.
discoveryAPIPath = "/cli/discovery/apis/bamboo"
// segment (".../cedar") lives in one place and future version bumps
// only touch here. Version codes step by first letter (bamboo -> cedar
// -> ...); cedar carries the dws-wukong alignment. See registry_test.go
// for the matching fixture path.
discoveryAPIPath = "/cli/discovery/apis/cedar"
)
type Client struct {
+2 -2
View File
@@ -262,7 +262,7 @@ func TestFetchServers(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cli/discovery/apis/bamboo" {
if r.URL.Path != "/cli/discovery/apis/cedar" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
payload := ListResponse{
@@ -299,7 +299,7 @@ func TestFetchServersFollowsNextCursor(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cli/discovery/apis/bamboo" {
if r.URL.Path != "/cli/discovery/apis/cedar" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
+9 -1
View File
@@ -20,6 +20,8 @@ package edition
// their environment.
const DefaultOSSClawType = "openClaw"
const openAitableHelperEndpoint = "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95"
// defaultHooks returns the open-source edition defaults.
//
// MergeHeaders is the only hook that ships with behaviour: it pins the
@@ -43,10 +45,16 @@ func defaultHooks() *Hooks {
func openSupplementServers() []ServerInfo {
return []ServerInfo{
{
ID: "aitable-helper",
Name: "AI 多维表(辅助)",
Endpoint: openAitableHelperEndpoint,
Prefixes: []string{"form", "share_form"},
},
{
ID: "aitable-form",
Name: "AI 多维表(表单)",
Endpoint: "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95",
Endpoint: openAitableHelperEndpoint,
Prefixes: []string{"form", "share_form"},
},
}
+24
View File
@@ -34,3 +34,27 @@ func TestClawTypeUsesOverlayValue(t *testing.T) {
t.Fatalf("ClawType() = %q, want overlay value %q", got, "wukong")
}
}
func TestOpenSupplementServersIncludeAitableHelperAlias(t *testing.T) {
servers := openSupplementServers()
byID := make(map[string]ServerInfo, len(servers))
for _, server := range servers {
byID[server.ID] = server
}
helper, ok := byID["aitable-helper"]
if !ok {
t.Fatalf("openSupplementServers() missing aitable-helper: %#v", servers)
}
if helper.Endpoint != openAitableHelperEndpoint {
t.Fatalf("aitable-helper endpoint = %q, want %q", helper.Endpoint, openAitableHelperEndpoint)
}
form, ok := byID["aitable-form"]
if !ok {
t.Fatalf("openSupplementServers() missing aitable-form: %#v", servers)
}
if form.Endpoint != openAitableHelperEndpoint {
t.Fatalf("aitable-form endpoint = %q, want %q", form.Endpoint, openAitableHelperEndpoint)
}
}
+10 -10
View File
@@ -1,22 +1,22 @@
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# One-command installer for the dws Dev preview on native Windows (PowerShell).
# Downloads the dev binary (dws.exe) + dingtalk-dev skill from the fork's GitHub Releases.
# One-command installer for dws dev on native Windows (PowerShell).
# Downloads the dev binary (dws.exe) + dingtalk-dev skill from the DingTalk-Real-AI GitHub Releases.
#
# Usage:
# irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
# irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
#
# Env (all optional):
# DEVAPP_REPO fork holding dev releases (default: wxianfeng/dingtalk-workspace-cli)
# DEVAPP_VERSION pin a dev release tag (default: latest release on the fork)
# DEVAPP_REPO repo holding dev releases (default: DingTalk-Real-AI/dingtalk-workspace-cli)
# DEVAPP_VERSION pin a release tag (default: latest release)
# DWS_ARCH architecture override (amd64 or arm64)
# DWS_INSTALL_DIR binary dir (default: ~/.local/bin)
# DWS_NO_SKILLS set 1 to skip the dev skill
$ErrorActionPreference = "Stop"
$Repo = if ($env:DEVAPP_REPO) { $env:DEVAPP_REPO } else { "wxianfeng/dingtalk-workspace-cli" }
$Repo = if ($env:DEVAPP_REPO) { $env:DEVAPP_REPO } else { "DingTalk-Real-AI/dingtalk-workspace-cli" }
$Version = $env:DEVAPP_VERSION
$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
@@ -40,15 +40,15 @@ function Get-Arch {
}
}
# GitHub's /releases/latest excludes prereleases; read the releases list (newest
# first) and take the top tag — the dev preview is published as a prerelease.
# Read the releases list (newest first) and take the top tag, so this also works
# if a release is ever published as a prerelease (which /releases/latest skips).
if (-not $Version) {
try {
$rel = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases?per_page=1" `
-Headers @{ "User-Agent" = "dws-devapp-installer" } -UseBasicParsing
$Version = $rel[0].tag_name
} catch {}
if (-not $Version) { Die "No release found on $Repo. Push a dev tag (e.g. v1.0.39-dev.1) to trigger CI, or set DEVAPP_VERSION." }
if (-not $Version) { Die "No release found on $Repo. Set DEVAPP_VERSION to a published release tag." }
}
$arch = Get-Arch
@@ -56,7 +56,7 @@ $tmp = Join-Path $env:TEMP ("dws-dev-" + [System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
Write-Host ""
Say "dws Dev preview installer (Windows, pre-built binary)"
Say "dws dev installer (Windows, pre-built binary)"
Say "Repo: $Repo"
Say "Version: $Version"
Say "Target: windows/$arch"
+10 -10
View File
@@ -2,21 +2,21 @@
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# One-command installer for the dws Dev preview — pre-built binary, no build tools.
# Downloads the dev binary + dingtalk-dev skill from the fork's GitHub Releases.
# One-command installer for dws dev — pre-built binary, no build tools.
# Downloads the dev binary + dingtalk-dev skill from the DingTalk-Real-AI GitHub Releases.
# Requires only curl + tar (no go / make / git).
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
# curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
#
# Env (all optional):
# DEVAPP_REPO fork holding dev releases (default: wxianfeng/dingtalk-workspace-cli)
# DEVAPP_VERSION pin a dev release tag (default: latest release on the fork)
# DEVAPP_REPO repo holding dev releases (default: DingTalk-Real-AI/dingtalk-workspace-cli)
# DEVAPP_VERSION pin a release tag (default: latest release)
# DWS_INSTALL_DIR binary dir (default: ~/.local/bin)
# DWS_NO_SKILLS set 1 to skip the dev skill
set -eu
DEVAPP_REPO="${DEVAPP_REPO:-wxianfeng/dingtalk-workspace-cli}"
DEVAPP_REPO="${DEVAPP_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
DEVAPP_VERSION="${DEVAPP_VERSION:-}"
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
NO_SKILLS="${DWS_NO_SKILLS:-0}"
@@ -45,8 +45,8 @@ detect_arch() {
esac
}
# GitHub's /releases/latest excludes prereleases, so read the releases list
# (newest first) and take the top tag — the dev preview is published as a prerelease.
# Read the releases list (newest first) and take the top tag, so this also works
# if a release is ever published as a prerelease (which /releases/latest skips).
# Prefer `gh` CLI (authenticated, 5 000 req/h) over raw curl (60 req/h, easily rate-limited).
resolve_version() {
[ -n "$DEVAPP_VERSION" ] && return 0
@@ -68,7 +68,7 @@ resolve_version() {
DEVAPP_VERSION="$(grep -m1 '"tag_name"' "$_tmpfile" | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')"
rm -f "$_tmpfile"
[ -n "$DEVAPP_VERSION" ] || err "No release found on ${DEVAPP_REPO}. Push a dev tag (e.g. v1.0.39-dev.1) to trigger CI, or set DEVAPP_VERSION."
[ -n "$DEVAPP_VERSION" ] || err "No release found on ${DEVAPP_REPO}. Set DEVAPP_VERSION to a published release tag."
}
install_skill() {
@@ -104,7 +104,7 @@ main() {
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT INT TERM
printf '\n'
say "dws Dev preview installer (pre-built binary)"
say "dws dev installer (pre-built binary)"
say "Repo: ${DEVAPP_REPO}"
say "Version: ${DEVAPP_VERSION}"
say "Target: ${os}/${arch}"
+2
View File
@@ -27,6 +27,8 @@ $ErrorActionPreference = "Stop"
$Repo = "DingTalk-Real-AI/dingtalk-workspace-cli"
$BinName = "dws"
# GitHub "latest release" URL; Resolve-LatestVersion follows its redirect to get the tag.
$LatestUrl = "https://github.com/$Repo/releases/latest"
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via the Gitee API.
$GiteeRepo = if ($env:DWS_GITEE_REPO) { $env:DWS_GITEE_REPO } else { "" }
# Auto-fallback Gitee mirror used when GitHub is unreachable (see Resolve-Source).
+83 -11
View File
@@ -62,23 +62,95 @@ fi
[ -n "$release_id" ] || { echo "❌ Could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" >&2; exit 1; }
echo " Gitee release id = ${release_id}"
# ── Upload each artifact as a release attachment ──────────────────────────────
# ── Mirror each artifact, verifying content so re-runs self-heal ─────────────
# Pull the current attachment list (name + attach id + download url) so we can,
# per file:
# • skip it when it is already on Gitee, unique, AND byte-identical,
# • REPLACE it when present but stale (different bytes) — e.g. the darwin
# binaries are re-signed and so differ between GitHub and a prior mirror
# run, which breaks install.sh's checksums.txt verification on macOS,
# • DEDUP it when the same name has >1 attachment — a prior run that failed to
# delete (see below) left the old copy *and* an extra upload; Gitee then
# serves the OLDER one by name, so the stale binary wins. We delete every
# copy and upload one fresh.
# • upload it when missing.
# This brings the Gitee release into byte-for-byte agreement with $DIST_DIR,
# which the caller fills from the GitHub release whose checksums.txt is what
# install.sh verifies against.
#
# We list attachments via the dedicated /attach_files endpoint, NOT the release
# detail (/releases/{id}) endpoint: the latter's "assets" array omits the attach
# id, so DELETE /attach_files/{id} was previously called with an empty id and
# silently no-op'd — leaving stale + duplicate darwin binaries on Gitee.
assets_map="$(curl -fsSL "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
| python3 -c 'import json,sys
try:
data=json.load(sys.stdin)
rows=data if isinstance(data,list) else data.get("attach_files",[])
for a in rows:
n=a.get("name",""); i=a.get("id",""); u=a.get("browser_download_url","")
if n and i!="":
print("%s\t%s\t%s" % (n, i, u))
except Exception:
pass' 2>/dev/null || true)"
sha256_of() { # sha256 of a file ($1) or, with no arg, of stdin
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}';
else shasum -a 256 ${1:+"$1"} | awk '{print $1}'; fi
}
gitee_attach() { # upload file $1; success when the response carries a download url
printf '%s' "$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
-F "access_token=${GITEE_TOKEN}" -F "file=@${1}" 2>/dev/null || true)" \
| grep -q '"browser_download_url"'
}
gitee_delete() { # delete attachment by id $1
curl -fsSL -X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
>/dev/null 2>&1 || true
}
uploaded=0
replaced=0
skipped=0
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
[ -f "$f" ] || continue
fn="$(basename "$f")"
echo " ⬆ ${fn}"
resp="$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
-F "access_token=${GITEE_TOKEN}" \
-F "file=@${f}" 2>/dev/null || true)"
if printf '%s' "$resp" | grep -q '"browser_download_url"'; then
uploaded=$((uploaded + 1))
else
echo " ⚠ upload may have failed for ${fn}: ${resp}" >&2
local_sha="$(sha256_of "$f")"
# Every attach id currently carrying this name (may be >1 from a botched run).
ids="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $2}')"
aurl="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $3; exit}')"
count="$(printf '%s' "$ids" | grep -c . || true)"
if [ "$count" -eq 0 ]; then
echo " ⬆ ${fn} (new)"
if gitee_attach "$f"; then uploaded=$((uploaded + 1)); else echo " ⚠ upload may have failed for ${fn}" >&2; fi
continue
fi
if [ "$count" -eq 1 ]; then
gitee_sha="$(curl -fsSL "$aurl" 2>/dev/null | sha256_of || true)"
if [ "$gitee_sha" = "$local_sha" ]; then
echo " ✓ ${fn} already correct on Gitee — skip"
skipped=$((skipped + 1))
continue
fi
echo " ↻ ${fn} differs on Gitee (stale) — deleting + re-uploading"
else
echo " ↻ ${fn} has ${count} copies on Gitee (dup) — deleting all + re-uploading one"
fi
# Delete every copy, then upload exactly one fresh, correct file.
printf '%s\n' "$ids" | while read -r aid; do
[ -n "$aid" ] && gitee_delete "$aid"
done
if gitee_attach "$f"; then replaced=$((replaced + 1)); else echo " ⚠ re-upload may have failed for ${fn}" >&2; fi
done
[ "$uploaded" -gt 0 ] || { echo "❌ No artifacts uploaded. Did the build (goreleaser) run?" >&2; exit 1; }
echo "✅ Uploaded ${uploaded} asset(s) to Gitee release ${VERSION}."
if [ "$uploaded" -eq 0 ] && [ "$replaced" -eq 0 ] && [ "$skipped" -eq 0 ]; then
echo "❌ No artifacts found to mirror. Did the build (goreleaser) run / were assets downloaded into ${DIST_DIR}?" >&2
exit 1
fi
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped} (already correct)."
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
+280 -39
View File
@@ -7,9 +7,15 @@
| 资源 | URI 格式 |
|------|----------|
| Base 文档 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` |
| 指定数据表 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}` |
| 指定数据表+视图 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}` |
| 模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` |
> **操作后请返回文档 URI**:每次执行 base list/search/create/get 操作后,从返回数据中提取 `baseId`,拼接为 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 返回给用户。
> **操作后请返回文档 URI**:返回链接时必须带上当前操作的数据表 tableId,让用户点击后直接看到目标数据表,而不是落在空白的默认表。
> - 已知 tableId + viewId 时(view create 返回、view get 中提取):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}`
> - 已知 tableId 时(table create 返回、base get 中提取、record 操作所用的 tableId):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}`
> - 仅有 baseId、无明确 tableId 时(如 base list/search):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}`
>
> 补充:如果 URL 不是来自 `aitable` 命令返回,而是用户直接贴的原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe,确认是 `able` 后再按 AI 表格处理。
## 命令索引表
@@ -19,10 +25,9 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` |
| `base search` | 搜索 Base;不传关键词时列出最近 Base | — | 可选 `--query`;不传时走 list_bases |
| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 |
| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID |
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId |
| `base copy` | 复制 Base 到目标文件夹 | `--base-id` `--target-folder-id` | 目标必须是 `dws doc folder create/list` 返回的文档文件夹 `nodeId`;不要传钉盘数字 `dentryId`,也不要用手工新建 base/table 代替 |
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId;**默认新建的 base 自带一个空白「数据表」(含 3 行空记录)和一个空白仪表盘**,如需干净的空 base,传 `--template-id 1743` |
| `base update` | 更新 Base 名称 | `--base-id` `--name` | — |
| `base delete` | 删除 Base | `--base-id` | 不可逆 |
@@ -31,8 +36,8 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `table get` | 获取表结构(字段+视图目录) | `--base-id` | 不传 `--table-ids` 返回全部表 |
| `table create` | 创建数据表 | `--base-id` `--name` | `--fields` 可选;不传时创建空字段表 |
| `table update` | 重命名表 | `--base-id` `--table-id` `--name` | — |
| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 |
| `table update` | 修改表名 / 备注 / 行命名规则 | `--base-id` `--table-id` + 三选一(`--name` / `--description` / `--record-name-key`) | `--record-name-key` 是固定枚举(如 task/project/event/customer/ji_lu 等),非字段 ID |
| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 |
### field (字段管理) → 详见 [aitable-field.md](./aitable/aitable-field.md)、[field-properties](./aitable/aitable-field-properties.md)
@@ -44,6 +49,30 @@
| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 |
| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 |
#### 搜索字段选项
```
Usage:
dws aitable field search-options [flags]
Example:
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID>
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --keyword 已完成
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --limit 100
Flags:
--base-id string Base ID (必填)
--field-id string 目标字段 ID,必须是 singleSelect / multipleSelect 类型 (必填)
--keyword string 模糊搜索关键词,大小写不敏感、contains 匹配 option name;不传返回全部
--limit int 返回的最大 option 数量,默认 3000(全量),最大 3000
--table-id string Table ID (必填)
```
仅适用于 **singleSelect / multipleSelect** 字段。其他类型(text/number/date/...)调用会返回错误。
适用场景:
- options 较多,只想要含某关键词的子集(避免 `field get` 拉取整个字段配置带回所有 options)。
- 写入 record 前预览选项 id ↔ name 的映射,确认要使用的选项确实存在。
> **写 record 时**:`record create / update` 对 singleSelect/multipleSelect 可直接传 option **name**,不需要用本命令。本命令主要用于 **filter** 写法(filters 优先用 option **id**)或选项较多需要精确定位时。
### record (记录管理)
| 命令 | 用途 | 必读 reference | 路由提醒 |
@@ -51,36 +80,66 @@
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference |
| `record get` | 按 ID 取记录(`record query --record-ids` 的窄别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 已知 recordId 时首选;必填 `--record-ids`(单次最多 100 条);未暴露 filters/sort/query/cursor/limit |
| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 |
| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;`--records` 是 `[{recordId,cells},...]` 数组;同一组值批量更新也用此命令展开 records |
| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;`--records` 是 `[{recordId,cells},...]` 数组 |
| `record batch-update` | 批量更新(同一 cells 应用到多条 recordId) | [aitable-record-update.md](./aitable/aitable-record-update.md)、[aitable-cell-value.md](./aitable/aitable-cell-value.md) | 适合"统一标记完成/统一改负责人"等共享 patch 场景;`--cells` 是 JSON object(key=fieldId,value 按字段类型见 cell-value.md),与 record update 的单条 cells 结构完全一致;必填 `--record-ids` `--cells`;单次最多 100 条 |
| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 |
| `record history-list` | 查询单条记录的变更历史 | [aitable-record-history.md](./aitable/aitable-record-history.md) | 必填 `--record-id`;分页 `--offset --limit`,limit 范围 [1,50] 默认 20 |
| `record query-empty` | 查询完全没填用户字段的空行 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 一页扫描 `--limit` [1,100] 默认 100;扫完前需用 `--cursor` 翻页(nextCursor 为空才表扫完) |
| `record share-url` | 批量获取记录分享链接 | [aitable-record-share.md](./aitable/aitable-record-share.md) | 必填 `--record-ids`(CSV,单次最多 20 条);可选 `--view-id` 带视图上下文 |
| `record upsert` | 批量创建或更新(按 recordId 是否存在自动拆分) | [aitable-record-upsert.md](./aitable/aitable-record-upsert.md) | --records 同 record update 格式;带 recordId 走 update,不带走 create;单次最多 100 |
| `record primary-doc-get` | 查询记录的主键文档 nodeId | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 返回的 nodeId 可直接用于 `dws doc read/update --node` |
| `record primary-doc-create` | 为记录创建主键文档(幂等) | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | fieldId 必须是 primaryDoc 类型;已存在则返回已有 nodeId |
### view (视图管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `view get` | 获取视图配置 | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
| `view list` | 列出全部视图(`view get` 不传 `--view-ids` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价;只需视图列表时优先 |
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;可选 `--name` 指定视图名称(未传时自动生成)、`--config` 传初始配置 JSON |
| `view update` | 更新视图(**调整字段顺序的入口**) | `--base-id` `--table-id` `--view-id` | `visibleFieldIds` 重排字段顺序 |
| `view get` | 获取视图配置(不传子命令) | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
| `view get <attr>` | 获取视图某个属性 | `--view-id` | 12 个:card/timebar/aggregate/filter/sort/group/visible-fields/field-widths(详见 [aitable-view-config.md](./aitable/aitable-view-config.md))+ lock/frozen-cols/row-height/fill-color-rule(详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)) |
| `view list` | 列出全部视图(`view get` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价 |
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;**Gantt 创建后必须 `view update timebar` 绑定日期字段** |
| `view update` | 整体更新视图 / 多属性合并更新 | `--base-id` `--table-id` `--view-id` | 可传 `--name --desc --config '{...}'`,**`--config` 路径继续保留** |
| `view update <attr>` | 按属性局部更新(推荐)| `--view-id` + typed flag / `--json` | 12 个:card/timebar/aggregate/field-widths/visible-fields/filter/sort/group/name + frozen-cols/row-height/fill-color-rule |
| `view lock [--off]` | 锁定/解锁视图 | `--base-id` `--table-id` `--view-id` | 默认锁定;`--off` 解锁。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view duplicate` | 复制视图 | `--base-id` `--table-id` `--view-id` | 可选 `--new-name`;保留源视图全部配置。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view delete` | 删除视图 | `--base-id` `--table-id` `--view-id` | 不可删最后一个/锁定视图 |
> **"移动字段/调整字段顺序"** 在 AI 表格里没有 `field reorder` 命令,必须通过 `view update --config '{"visibleFieldIds":[...]}'` 完成。
> **优先用 `view get <attr>` / `view update <attr>` 子命令**:每个属性独立命令,typed flag 友好,agent 不必拼 JSON。**`view update --config '{...}'` 仍可用**,适合一次性多属性更新或脚本场景。
> **view update --config 支持的 key 白名单**(传入其他 key 会报错):
> - `visibleFieldIds` — 视图可见字段列表及顺序(首列字段必须保留在第一位)
> - `filter` — 筛选规则**数组**(⚠️ 注意是数组 `[...]`,不是对象 `{...}`)
> - `sort` — 排序规则**数组**
> - `group` — 分组规则**数组**
> - `fieldWidths` — 列宽映射(仅 Grid 视图有效)
>
> **filter/sort/group 必须传数组格式**,不要和 `record query --filters`(对象格式)混淆。详见 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md) § view update 章节。
> CLI 会自动容错(对象→数组 wrap),但建议直接使用正确格式。
>
> 不支持 `formInfo`、`requiredFields`、`conditionalRules` 等 FormDesigner 高级配置,这些 key 会被服务端忽略。
> **属性按 attr 分类,决定该读哪份子文档**:
> - card / timebar / aggregate / filter / sort / group / visible-fields / field-widths → [aitable-view-config.md](./aitable/aitable-view-config.md)
> - lock / frozen-cols / row-height / fill-color-rule / duplicate → [aitable-view-extras.md](./aitable/aitable-view-extras.md)
> 后一类**不能**塞进 `view update --config '{...}'`,必须用各自专属子命令;如果错传 `flags` / `frozenColCount` / `cellHeight` / `conditionalFormats` 等 key 进 `--config`,CLI 会在 stderr 提示应改用的命令。
### 表单视图 → 详见 [aitable-form.md](./aitable/aitable-form.md)
> **`view update --config` 支持的 9 个 key**:
> `visibleFieldIds` / `filter` / `sort` / `group` / `fieldWidths`(Grid) / `aggregate`(Grid) / `kanbanCard`(Kanban) / `ganttTimebar`(Gantt) / `galleryCard`(Gallery)。
> filter/sort/group 必须传**数组**格式(与 `record query --filters` 的对象格式不同;CLI 会自动容错)。其他 key 会被服务端忽略并打 warning。
悟空命令面不暴露 `form` 命令组;表单按 `viewType=FormDesigner` 的视图处理,创建/查看/更新/删除都使用 `view` 命令。
### form (表单管理) → 详见 [aitable-form.md](./aitable/aitable-form.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 详情见 [aitable-form.md](./aitable/aitable-form.md) |
| `form get` | 按 viewId 取单个表单详情 | `--base-id` `--table-id` `--view-id` | — |
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | — |
| `form update` | 更新表单配置 | `--base-id` `--table-id` `--view-id` | title/name/description 至少一项 |
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` | 不可逆 |
| `form field list/update/hide` | 表单字段管理 | — | 详情见子文档 |
| `form questions create/delete` | 题目管理(=field create/delete) | — | 详情见子文档 |
| `form share get/update` | 表单分享配置 | — | 详情见子文档 |
> **创建表单**有两种等价方式:`form create --name "..."`(推荐)或 `view create --view-type FormDesigner --name "..."`。
### workflow (自动化工作流) → 详见 [aitable-workflow.md](./aitable/aitable-workflow.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
> **当前不支持通过 CLI 新建/修改/删除工作流**,请去 AI 表格 Web 端(数据表页面 → 自动化)配置。
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
@@ -88,6 +147,7 @@
|------|------|
| `dashboard get/create/update/delete` | 仪表盘管理 |
| `dashboard config-example` | 查看仪表盘配置模板 |
| `dashboard arrange` | 自动重排仪表盘图表布局(智能填满网格,避免空缺) |
| `chart get/create/update/delete` | 图表管理 |
| `chart widgets-example` | 查看图表 widgets 配置模板 |
@@ -111,15 +171,162 @@
|------|------|----------|
| `template search` | 搜索模板 | `--query` |
## 评测执行硬约束
### advperm (高级权限/自定义角色) → 详见 [aitable-advperm.md](./aitable/aitable-advperm.md)
- 多轮任务必须执行到用户要求的最后一步;不要只回复"现在开始/下一步执行",也不要在创建 base/table/field 后提前结束。
- 每个写操作后用 `base get`、`table get`、`field get`、`record query` 或对应 `view get/list` 读回验证真实 ID 与结果。
- 字段批量 JSON 推荐 `fieldName`;CLI 兼容 `name`,但 skill 生成时不要主动使用 `name`。字段类型统一用小写/规范值,如 `text`、`number`、`singleSelect`、`attachment`。
- 成员/负责人字段类型使用 `user`,不要生成 `member`。
- 复制 AI 表格必须调用 `dws aitable base copy --base-id <BASE_ID> --target-folder-id <FOLDER_NODE_ID> --format json`。目标目录必须是 `dws doc folder create` 或 `dws doc list` 返回的文档文件夹 `nodeId`;不要传 `drive list` 返回的数字 `dentryId`,不要用新建 base/table 的手工方式代替 `base copy`。
- 用户未指定目标文件夹时:先 `dws doc info --node <BASE_ID> --format json` 取 `workspaceId`,再 `dws doc folder create --workspace <WORKSPACE_ID> --name "AI表格副本" --format json` 创建目标文件夹,最后把返回的 `nodeId` 传给 `base copy`。
- 导入 Excel/CSV 前先用 `find` 或 `ls` 确认真实文件路径;遇到中文文件名乱码或路径不匹配时,重新查找实际文件,不要停在解释阶段。
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `advperm enable` | 开启 Base 高级权限总开关 | `--base-id` | 不开启时角色规则不生效 |
| `advperm disable` | 关闭 Base 高级权限总开关(高危) | `--base-id` `--yes` | 关闭后全员回退默认权限 |
| `advperm role-list` | 列出 Base 下所有角色 | `--base-id` | 同时返回自定义角色和系统角色;`roleType == "custom"` 是自定义,前缀 `system_` 是系统角色 |
| `advperm role-get` | 获取单角色完整配置 | `--base-id` `--role-id` | 含 subRoles 与字段/行级规则 |
| `advperm role-create` | 创建自定义角色 | `--base-id` `--name` | 可选 `--sub-roles` 同时指定子角色权限规则 |
| `advperm role-update` | 增量更新自定义角色(PATCH) | `--base-id` `--role-id` | 未传字段不变;`--sub-roles` 按 (targetId,targetType) 合并 |
| `advperm role-delete` | 删除自定义角色 | `--base-id` `--role-id` `--yes` | 不可逆;系统角色禁删;**调用者必须是该 AI 表格的管理员/Owner**,非管理员会得到 401 AUTH_ERROR |
> **角色 CRUD 已全支持**:create/get/list/update/delete 都可走 CLI。
> 所有写命令(enable/disable/role-create/role-update/role-delete)需要 Base 管理员权限;非管理员只能调 `role-list` / `role-get`(只读)。
> "角色 ↔ 成员"绑定当前 CLI 不支持,仍需在 AI 表格 Web 端 → Base 设置 → 高级权限面板手动完成。
### section (文件夹与节点管理)
> 用于在 Base 的导航树中组织 table / dashboard / 表单视图 / 文档等节点(类似文件夹)。
> 操作前建议先用 `section list-nodes` 拿到 nodeId / sectionId 与父级关系。
#### 创建文件夹
```
Usage:
dws aitable section create [flags]
Example:
dws aitable section create --base-id <BASE_ID> --name 我的文件夹
dws aitable section create --base-id <BASE_ID> --name 子文件夹 --parent-section-id <SECTION_ID> --index 0
Flags:
--base-id string Base ID (必填)
--name string 文件夹名称 (必填)
--parent-section-id string 父文件夹 ID;不传或空字符串表示创建在 Base 根目录下
--index int 在父文件夹下的目标位置(0-based);不传则追加到末尾
```
返回 `data.sectionId` 与 `data.name`。
#### 重命名文件夹
```
Usage:
dws aitable section rename [flags]
Example:
dws aitable section rename --base-id <BASE_ID> --section-id <SECTION_ID> --new-name 新名称
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
--new-name string 新的文件夹名称 (必填)
```
#### 删除文件夹
```
Usage:
dws aitable section delete [flags]
Example:
dws aitable section delete --base-id <BASE_ID> --section-id <SECTION_ID>
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
```
> **注意**:删除不可逆;删除前可先用 `section list-empty` 确认是否为空文件夹。
#### 调整文件夹顺序
```
Usage:
dws aitable section reorder [flags]
Example:
dws aitable section reorder --base-id <BASE_ID> --section-id <SECTION_ID> --target-index 0
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
--target-index int 目标位置(0-based)(必填)
```
> 在**当前父文件夹下**调整展示顺序。跨父级移动请用 `section move-node`。
#### 列出空文件夹
```
Usage:
dws aitable section list-empty [flags]
Example:
dws aitable section list-empty --base-id <BASE_ID>
Flags:
--base-id string Base ID (必填)
```
返回 `data.items: [{sectionId, name, parentSectionId}]` 与 `data.total`,用于清理或诊断导航树(parentSectionId 为空串表示在根目录下)。
#### 列出全部节点
```
Usage:
dws aitable section list-nodes [flags]
Example:
dws aitable section list-nodes --base-id <BASE_ID>
Flags:
--base-id string Base ID (必填)
```
返回 `data.items: [{nodeId, nodeType, parentSectionId, name?}]` 与 `data.total`,涵盖文件夹 / AI 表格 / 表单视图 / 仪表盘 / 文档 / 查询视图。
> **与其他命令的关联**:是 `section move-node` / `section reorder` 的前置定位命令——先用它拿到 nodeId 与 parentSectionId。
#### 移动节点
```
Usage:
dws aitable section move-node [flags]
Example:
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id <SECTION_ID>
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id "" --target-index 0
Flags:
--base-id string Base ID (必填)
--node-id string 要移动的节点 ID(文件夹/AI表格/表单视图/仪表盘/文档/查询视图)(必填)
--new-parent-section-id string 目标父文件夹 ID;空字符串表示移到 Base 根目录 (必填)
--target-index int Base 内节点的全局位置(0-based);不传则不调整
```
> 服务端自动识别节点类型,无需区分文件夹与非文件夹。返回 `data.nodeId / newParentSectionId / nodeType`。
> 对文件夹节点带 `--target-index` 时会先 move 再 reorder,中间失败会返回 `MOVE_OK_REORDER_FAILED`,可用 `section reorder` 重试。
## 复杂操作
### 仪表盘 / 图表(建议顺序)
```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`,用于分享状态判断。
### 导出数据(两阶段轮询)
`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
```bash
# 第一步:创建任务(按 scope 传必要参数)
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`
- `scope=table`:必须 `table-id`
- `scope=view`:必须同时 `table-id + view-id`
## 意图判断
@@ -127,14 +334,14 @@
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
- 详情 → `base get`
- 创建 → `base create`
- 复制 → `base copy`,必须调用 `dws aitable base copy --base-id <BASE_ID> --target-folder-id <FOLDER_NODE_ID> --format json`;若无目标文件夹,先 `doc info --node <BASE_ID>` 取 `workspaceId`,再 `doc folder create --workspace <WORKSPACE_ID>` 创建文档文件夹作为目标。服务端返回 `Invalid target folder ID` 时,改用 `doc folder create` 新建目标文件夹后重试一次;不要手工重建副本。
- 修改 → `base update`
- 删除 → `base delete`
用户说"数据表/子表/table":
- 查看 → `table get`
- 创建 → `table create`
- 重命名 → `table update`
- 重命名 / 改备注 / 改行命名规则 → `table update`(三选一:`--name` / `--description` / `--record-name-key`)
- 用户说"行命名规则/记录别名/卡片显示成 task/project/event 这种" → `table update --record-name-key <枚举键>`,**中文 → 枚举键**对照见 [aitable-record-name-key.md](./aitable/aitable-record-name-key.md)
- 删除 → `table delete`
用户说"字段/列/column":
@@ -145,19 +352,35 @@
用户说"记录/行/数据/row":
- 查看/搜索 → `record query`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 找空行 / 没填东西的行 → `record query-empty`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 已知 recordId 反查字段值 → `record get`(按 ID 取专用,等价 `record query --record-ids`)
- 添加/写入 → `record create`(读 [aitable-record-create.md](./aitable/aitable-record-create.md))
- 修改/更新(每条独立 cells) → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md))
- **批量更新同一字段值**(统一标记/统一改值) → `record update --records '[{"recordId":"rec1","cells":{...}},{"recordId":"rec2","cells":{...}}]'`
- **批量更新同一字段值**(统一标记/统一改值) → `record batch-update --record-ids ... --cells '{...}'`
- 删除 → `record delete`
- **查记录的字段变更历史 / 操作审计** → `record history-list`(读 [aitable-record-history.md](./aitable/aitable-record-history.md))
- **取记录分享链接 / 把这行发给同事** → `record share-url`(读 [aitable-record-share.md](./aitable/aitable-record-share.md))
- **不知道有没有 → 有就改、没有就建** → `record upsert`(读 [aitable-record-upsert.md](./aitable/aitable-record-upsert.md))
用户说"视图/view":
- 列出/查看全部视图 → `view list`(或 `view get` 不传 --view-ids,二者等价)
- 看某个视图详情 → `view get --view-ids <ID>`
- 创建 → `view create`
- 修改(含"调整字段顺序/隐藏字段") → `view update --config '{"visibleFieldIds":[...]}'`
- 修改某一项配置(filter/sort/group/card/timebar/aggregate 等)→ `view update <attr>`(读 [aitable-view-config.md](./aitable/aitable-view-config.md))
- 锁定 / 冻结列 / 行高 / 数据高亮规则 / 复制视图 → 读 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
- 删除 → `view delete`
用户说"锁定视图/解锁视图/lock view" → `view lock` / `view lock --off`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"冻结列/冻结首列/frozen columns" → `view update frozen-cols --count N`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"行高/单元格高度/紧凑模式/cell height" → `view update row-height --cell-height N`(合法档位 32/56/88/128),详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"数据高亮/条件格式/单元格上色/fill color rule" → `view update fill-color-rule --json '[...]'`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"复制视图/duplicate view" → `view duplicate --view-id ... [--new-name ...]`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"筛选/过滤/filter" → 读 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md)
用户说"统计/分析/聚合/TOP N/全量" → 读 [aitable-data-analysis-sop.md](./aitable/aitable-data-analysis-sop.md)
@@ -166,16 +389,33 @@
用户说"查找引用/lookup/filterUp/跨表" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)(§5.4 跨表引用)
用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md),使用 `view create --view-type FormDesigner`
用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md)
用户说"自动化/工作流/流程/触发/automation/workflow" → 读 [aitable-workflow.md](./aitable/aitable-workflow.md)
- 看 Base 里有哪些流程 / 哪些在跑 → `workflow list`(看 `recordCount` / `runningCount`)
- 看某个流程具体配置(触发条件、动作步骤) → `workflow get`
- 启用流程 → `workflow enable`
- 临时停掉流程(调试 / 数据迁移)→ `workflow disable --yes`
- **新建 / 修改 / 删除流程**:当前不支持,引导用户到 AI 表格 Web 端 → 数据表 → 自动化 面板手动完成
用户说"仪表盘/图表/chart" → 读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
用户说"仪表盘排版乱了/图表对不齐/重新排布/自动布局/美化仪表盘" → `dashboard arrange`(读 [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-advperm.md](./aitable/aitable-advperm.md)
- 开/关高级权限 → `advperm enable` / `advperm disable --yes`
- 看角色配置 → `advperm role-list` 或 `advperm role-get`
- 建角色(可同时指定子角色权限) → `advperm role-create --name ... --sub-roles '[...]'`
- 改角色名 / 改子角色权限(PATCH 语义,未传字段不变) → `advperm role-update --role-id ... [--name ...] [--sub-roles '[...]']`
- 删角色 → `advperm role-delete --yes`
- **角色 ↔ 成员绑定**:当前 CLI 不支持,仍需在 AI 表格 Web 端面板手动完成
命令报错/操作失败 → 读 [aitable-error-recovery.md](./aitable/aitable-error-recovery.md)
**关键区分**: base=表格文件, table=数据表, field=列, record=行
@@ -190,7 +430,7 @@ dws aitable base search --query "项目" --format json
dws aitable base get --base-id <BASE_ID> --format json
# 3. 获取表结构 — 提取 fieldId
dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID> --format json
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
@@ -206,7 +446,8 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|------|-------------|------|
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
| `base create` | `baseId` | 后续命令 + 文档 URI |
| `base get` | `tables[].tableId` | --table-id |
| `base get` | `tables[].tableId` | --table-id,拼接指定数据表 URI |
| `table create` | `tableId` | 后续命令 + 拼接指定数据表 URI |
| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete |
| `record query` | `recordId` | record update/delete;按 ID 反查字段值用 `record get` |
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
@@ -0,0 +1,251 @@
# advperm — 高级权限管理
控制 Base 的高级权限总开关,并管理自定义角色(增删改查 + 子角色权限规则)。
适用场景:"如何控制谁能看/改 Base 数据"、"开启/关闭高级权限"、"新建/修改/删除角色"、"按字段或行配置权限"。
## 命令一览
| 命令 | 用途 |
|------|------|
| `advperm enable` | 开启 Base 高级权限总开关 |
| `advperm disable` | 关闭 Base 高级权限总开关(高危) |
| `advperm role-list` | 列出 Base 下全部角色 |
| `advperm role-get` | 获取单角色完整配置 |
| `advperm role-create` | 创建自定义角色 |
| `advperm role-update` | 增量更新自定义角色(PATCH 语义) |
| `advperm role-delete` | 删除自定义角色(不可逆) |
> 所有子命令的 `--base-id` 必填,可用隐藏别名 `--base`。
## 命令详情
### advperm enable — 开启高级权限
```bash
dws aitable advperm enable --base-id BASE_ID --format json
```
返回 `{baseId, enabled: true}`。
只有开启后角色配置才会真正限制成员的可访问范围;关闭状态下角色配置仍可读但不生效。
### advperm disable — 关闭高级权限(高危)
```bash
dws aitable advperm disable --base-id BASE_ID --yes --format json
```
返回 `{baseId, enabled: false}`。关闭后所有角色配置即刻失效,全员回退到默认权限。涉及多人协作或敏感数据务必和用户二次确认,建议先 `role-list` 留底。
### advperm role-list — 列出全部角色
```bash
dws aitable advperm role-list --base-id BASE_ID --format json
```
返回结构:
```json
{
"data": {
"enabled": true,
"defaultRole": { "mode": 0 },
"roles": [
{
"roleId": "10685308981",
"name": "可查看角色",
"roleType": "custom",
"system": false,
"subRoles": [
{
"authLevel": "read",
"targetId": "HMEaRQ4",
"targetType": "sheet",
"config": { "actions": 268435455 },
"display": {
"authLevelLabel": "仅查看",
"targetTypeLabel": "数据表",
"permissionScopeNote": "...",
"actionsLabels": ["新增视图", "删除视图", "修改视图"],
"actionsNote": "..."
}
}
]
}
]
}
}
```
关键字段:
- `roleType`:`custom`(自定义) / `system_editor` / `system_reader` / `5000`(owner) / `4000`(manager)。
- `system`:boolean,true 表示系统角色(不可删)。
- `subRoles[].display.*`:服务端返回的人类可读标签,可直接拼接给用户阅读,无需自行映射枚举。
- 不返回角色成员列表;如需"成员-角色"映射请去 AI 表格 Web 端。
- 新建 Base 默认 `enabled=false`,开启后只有 `owner` / `manager` 两个 meta 角色;`system_editor` / `system_reader` 需要在 Web UI 给成员授权"可编辑/可查看"后才会被服务端自动生成。
`role-list` / `role-get` 不需要管理员权限,普通成员也可读。
### advperm role-get — 获取单角色配置
```bash
dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID --format json
```
返回结构同 `role-list` 中单个 role 对象(含完整 `subRoles[].config` 字段/行级规则与 `display.*` 标签)。
### advperm role-create — 创建自定义角色
```bash
# 仅指定 name,子角色由服务端按默认(none)填充
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" --format json
# 创建时即指定 sub-roles(推荐——避免再走一次 role-update)
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" \
--sub-roles '[{"targetId":"<sheetId>","targetType":"sheet","authLevel":"read"}]' --format json
```
| flag | 必填 | 说明 |
|------|:---:|------|
| `--name` | ✅ | 角色名称 |
| `--role-type` | | 角色类型字符串(留空由服务端决定默认值,如 `custom`) |
| `--flow-type` | | 流程类型字符串(按业务需要) |
| `--sub-roles` | | JSON 数组:`[{targetId, targetType, authLevel, appId?, config?}]`,详见下方"sub-roles 子字段"段 |
返回新建角色的完整配置(同 `role-get` 出参格式,含自动生成的 default subRoles)。
系统角色无法通过本命令创建。
### advperm role-update — 增量更新自定义角色(PATCH 语义)
```bash
# 只改名
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name "新名字"
# 只改 sheet 子角色 authLevel,name 不传保持不变
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID \
--sub-roles '[{"targetId":"<sheetId>","targetType":"sheet","authLevel":"edit-own"}]'
```
| flag | 必填 | 说明 |
|------|:---:|------|
| `--role-id` | ✅ | 目标自定义角色 ID(数字 long 字符串) |
| `--name` | | 新角色名称;不传不修改 |
| `--role-type` / `--flow-type` | | 可选 |
| `--sub-roles` | | JSON 数组,**PATCH 合并语义**:按 `(targetId, targetType)` 合并到现有 subRoles,入参中的 sub 整体替换该 sub,**入参未提及的 sub 保留不变**(无需先调 `role-get` 自行 merge) |
**系统角色禁止更新**(包括 owner / manager / system_editor / system_reader)。
### sub-roles 子字段
每个 sub-role 描述「角色对某个权限目标的访问粒度」:
| 字段 | 类型 | 说明 |
|------|------|------|
| `targetId` | string | 目标资源 ID(数据表 → `tableId`;仪表盘 → `dashboardId`;应用 → `appId`) |
| `targetType` | string | `sheet` / `dashboard` / `app` |
| `authLevel` | string | `manage` / `edit-own` / `edit-custom-field` / `edit-field-range` / `read` / `none` |
| `appId` | string(可选) | 仅 `targetType=app` 时使用 |
| `config` | object(可选) | 字段/行级细化规则;含 `actions`(位图)/ `rows` / `cells`。结构与 `role-get` 出参 `subRoles[].config` 对齐 |
### advperm role-delete — 删除自定义角色(不可逆)
```bash
dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes --format json
```
要求同时满足:
1. 该 Base 已开启高级权限(`role-list` 返回 `enabled=true`)。
2. 当前 dws 登录用户是该 Base 的管理员/Owner。
3. `--role-id` 是 `role-list` 返回的数字 long 字符串(如 `"10685308981"`),且对应角色 `system=false`。
不可逆,删前先 `role-get` 留底。
## 能力边界
| 能力 | 状态 |
|------|------|
| 开/关高级权限 | ✅ 需管理员 |
| 列出 / 读取角色 | ✅ 普通成员也可读 |
| 创建自定义角色 | ✅ 需管理员 |
| 增量修改角色(PATCH 语义,不清空未传字段) | ✅ 需管理员 |
| 删除自定义角色 | ✅ 需管理员 |
| 修改/删除系统角色 | ❌ 服务端禁止;只能在 AI 表格 Web 端操作 |
| 角色 ↔ 成员绑定 | ❌ CLI 暂不支持,需在 AI 表格 Web 端 → Base 设置 → 高级权限 → 角色管理面板手动完成 |
## 错误码速查
| 场景 | code | type | message |
|------|------|------|---------|
| advperm 关闭时调用写接口(如 `role-delete` / `role-create` / `role-update`) | `ADVANCED_PERMISSION_DISABLED` | `USER_ERROR` | `Advanced permission is disabled for base <BASE>, please enable it via setAdvancedPermission before managing roles` |
| 非管理员调用 `enable` / `disable` / `role-create` / `role-update` / `role-delete` | `401` | `AUTH_ERROR` | `the current user must be a manager (administrator) of this base to manage roles or advanced permission` |
| 删除/更新系统角色(`system=true`) | `600` | `USER_ERROR` | `Illegal argument` |
| 操作不存在的数字 roleId(get/update/delete) | `600` | `USER_ERROR` | `Illegal argument` |
| 传非数字 roleId(如 `owner` / `manager`) | `INVALID_PARAMS` | `INPUT_ERROR` | `roleId is required` |
| `role-create` 缺 `--name` | `INVALID_PARAMS` | `INPUT_ERROR` | `name is required` |
| `--sub-roles` JSON 不是数组 / 解析失败 | (CLI 层拦截) | — | `--sub-roles 解析失败 ...` / `--sub-roles 必须是 JSON 数组` |
| `--base-id` 无法解析 | `INVALID_BASE_ID` | `INPUT_ERROR` | `baseId cannot be resolved to docId` |
> `600 / Illegal argument` 同时覆盖"操作系统角色"和"操作不存在 roleId"两种情况。拿到 `600` 时先 `role-list` 自查目标 roleId 是否存在、是否 `system=true`,再据此引导用户。
## 典型工作流
### 排查"成员看不到某些字段/记录"
```bash
dws aitable advperm role-list --base-id BASE_ID --format json
# 若 enabled=false:高级权限未开,所有规则不生效,与用户确认是否需要 enable
dws aitable advperm enable --base-id BASE_ID --format json
dws aitable advperm role-list --base-id BASE_ID --format json
# 看 roles[] 里有哪些自定义角色
dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID --format json
# 检查 subRoles[].config 中的字段/行级权限规则
```
### 新建一个"市场可读"角色
```bash
# 1. 确保高级权限已开
dws aitable advperm enable --base-id BASE_ID --format json
# 2. 拿目标 sheet 的 tableId
dws aitable table get --base-id BASE_ID --format json
# 3. 创建角色 + 指定 sheet 子角色 authLevel=read
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" \
--sub-roles '[{"targetId":"<tableId>","targetType":"sheet","authLevel":"read"}]' \
--format json
# → 返回新角色完整配置,含 roleId,记下后续 patch / delete 使用
```
### 升级角色权限(read → edit-own),保留其他配置
```bash
# 只传 sub-roles,name 等其他字段保持不变(PATCH 语义)
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID \
--sub-roles '[{"targetId":"<tableId>","targetType":"sheet","authLevel":"edit-own"}]' \
--format json
```
### 改角色名(不影响权限规则)
```bash
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name "新名字"
```
### 清理废弃角色
```bash
dws aitable advperm role-list --base-id BASE_ID --format json
dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes --format json
```
### 关闭高级权限(恢复全员可见)
```bash
dws aitable advperm role-list --base-id BASE_ID --format json > /tmp/roles-backup.json
dws aitable advperm disable --base-id BASE_ID --yes --format json
```
@@ -1,6 +1,8 @@
# attachment — 附件上传
> **STOP — 不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。必须使用以下流程。
>
> **STOP — 严禁在 record create/update 的 cells 里直接传图片 URL!** 直传 `{"url":"https://..."}` 会导致服务端同步下载图片,批量写入时触发 TIMEOUT_ERROR。正确做法:先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。
## 准备附件上传
@@ -38,8 +40,8 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
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
# 2. PUT 上传(Content-Type 必须是文件的具体 MIME type)
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdf
# 3. 写入记录
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
@@ -4,35 +4,30 @@
| 字段类型 | 可写 | 正确方式 |
|----------|------|----------|
| 文本/数字/日期/单选/多选/复选框/URL | 是 | `dws aitable record create` / `dws aitable record update` |
| 附件 | 是,但需先上传 | 先 `dws aitable attachment upload` 取 `uploadUrl/fileToken`,PUT 后把 `fileToken` 写入记录 |
| 创建人/修改人/创建时间/修改时间 | 否 | 系统字段,只读 |
| 公式/查找引用 | 否 | 由系统计算,只读 |
| AI 字段 | 否 | 由 AI 自动计算,只读 |
| 文本/数字/日期/单选/多选/复选框/URL | ✅ | record create/update |
| 附件 | ⚠️ | 必须先走 [attachment upload 流程](./aitable-attachment.md) |
| 创建人/修改人/创建时间/修改时间 | ❌ | 系统字段,只读 |
| 公式/查找引用 | ❌ | 只读,由系统计算 |
| AI 字段 | ❌ | 只读,由 AI 自动计算 |
## 2. 查询执行契约
1. 优先用 `dws aitable record query --filters` 在服务端过滤,不要先拉全量再在上下文里手动筛选。
2. 返回 `has_more=true` 时不能做全局结论,数据可能不完整。
3. 查询前先用 `dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID>` 获取真实 fieldId,不要猜字段 ID。
4. 只需要部分字段时,用 `dws aitable record query --field-ids fld1,fld2` 降低响应体积。
5. 已知 recordId 时,用 `dws aitable record get --record-ids rec1,rec2`,不要构造无意义 filters。
1. **不要拉全量后在 context 里手动统计** — 优先用 `--filters` 在服务端过滤
2. **has_more=true 时不能做全局结论** — 数据可能不完整
3. **优先用 `--filters` 在服务端过滤** — 不要拉全量后在本地 jq/grep
4. **字段名必须来自 `table get` 真实返回** — 不要猜测 fieldId
5. **减少响应体积** — 用 `--field-ids` 仅返回需要的字段
## 3. 任务选路
| 用户诉求 | 优先方案 | 不要误走 |
|---------|----------|----------|
| 查看几条数据 | `dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID>` | 不要默认 `--all` |
| 全量拉取/统计 | `dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --all` | 不要手动循环 cursor |
| 全量导出 | `dws aitable export data --base-id <BASE_ID> --scope all --format excel` | 不要 `--all` 拉全量再写文件 |
| 文件级导入 | `dws aitable import upload --base-id <BASE_ID> --file-name data.xlsx --file-size <字节数>` + `dws aitable import data --import-id <ID>` | 不要手动解析 xlsx 再逐条写入 |
| 批量写入多条不同数据 | `dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> --records '[{"cells":{"<FIELD_ID>":"值"}}]'` | 不要一次超过 100 条 |
| 批量给多条记录写同一组值 | `dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> --records '[{"recordId":"rec1","cells":{"<FIELD_ID>":"值"}},{"recordId":"rec2","cells":{"<FIELD_ID>":"值"}}]'` | 不要使用隐藏兼容命令 |
| 附件上传 | `dws aitable attachment upload --base-id <BASE_ID> --file-name report.pdf --size <字节数>` + PUT + `record create/update` | 不要用钉盘 drive 上传 |
| 调整字段顺序 | `dws aitable view update --base-id <BASE_ID> --table-id <TABLE_ID> --view-id <VIEW_ID> --config '{"visibleFieldIds":["fld1","fld2"]}'` | 没有 `field reorder` 命令 |
| 查看视图列表 | `dws aitable view list --base-id <BASE_ID> --table-id <TABLE_ID>` | 不需要用 `view get --view-ids` |
| 创建收集表/问卷 | `dws aitable view create --base-id <BASE_ID> --table-id <TABLE_ID> --view-type FormDesigner --name "表单名"` | 不要使用隐藏兼容命令 |
| 仪表盘/图表 | 先 `dashboard config-example` / `chart widgets-example`,再 create/update | 不要猜 config 结构 |
| 查看几条数据 | `record query` | 不要用 `--all` |
| 全量拉取/统计 | `record query --all` | 不要手动循环 cursor |
| 全量导出为文件 | `export data` | 不要 `--all` 拉全量再写文件 |
| 批量写入 | `record create`(分批 100 条) | 不要一次传超过 100 条 |
| 附件/图片上传 | `attachment upload` 获取 fileToken → `record create/update` 用 fileToken 写入 | **严禁直接传图片 URL 到附件字段**(服务端同步下载会超时) |
| 文件级导入 | `import upload` + `import data` | 不要手动解析 xlsx 再逐条写入 |
## 4. 创建/修改后回读确认
@@ -40,34 +35,12 @@
| 写操作 | 建议回读命令 | 确认内容 |
|--------|-------------|----------|
| `dws aitable base create` | `dws aitable base get --base-id <BASE_ID>` | base 名称、tables 列表 |
| `dws aitable table create` | `dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID>` | 表名、字段列表是否符合预期 |
| `dws aitable field create` | `dws aitable field get --base-id <BASE_ID> --table-id <TABLE_ID>` | 新字段是否出现在字段列表中 |
| `dws aitable record create/update` | `dws aitable record get --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids <RECORD_ID>` | 写入值是否正确 |
| `dws aitable view update` | `dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID> --view-ids <VIEW_ID>` | `visibleFieldIds` 顺序是否正确 |
| `dws aitable view create/update` | `dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID> --view-ids <VIEW_ID>` | 表单视图名称、描述和配置 |
| `table create` | `table get --table-ids <新tableId>` | 表名、字段列表是否符合预期 |
| `field create` | `table get --table-ids <tableId>` | 新字段是否出现在字段列表中 |
| `record create/update` | `record query --record-ids <新recordId>` | 写入值是否正确 |
## 5. 导入导出与异步任务
## 5. AI 字段注意事项
- `export data` 的 `--format` 是导出格式,不要在此命令上追加全局 `--format json`。
- 创建导出任务:
```bash
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> \
--format excel --timeout-ms 1000
```
- 续等已有导出任务:
```bash
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
```
- 导入本地文件:
```bash
dws aitable import upload --base-id <BASE_ID> --file-name data.xlsx --file-size <字节数> --format json
curl -X PUT "<uploadUrl>" -H "Content-Type:" --data-binary @data.xlsx
dws aitable import data --import-id <IMPORT_ID> --format json
```
## 6. AI 字段注意事项
- AI 字段的 prompt 必须至少包含一个 `fieldRef` 引用,纯文本 prompt 会被后端拒绝。
- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用。
- `outputType` 必须与字段类型一致,例如 `outputType=text` 配 `--type text`。
- AI 字段的 prompt **必须至少包含一个 `fieldRef` 引用**,纯文本 prompt 会被后端拒绝
- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用
- `outputType` 必须与字段类型一致(如 `outputType=text` 配 `--type text`)
@@ -86,11 +86,16 @@
{"fldDateId": "2026-03-15T09:00+08:00"}
```
**读取**:RFC3339 字符串
**读取**:RFC3339 字符串(带时区)
```json
{"fldDateId": "2026-03-15T09:00:00+08:00"}
```
**过滤**(`record query --filters`):日期字段**只能用日期专用操作符** `date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`,比较值用日期字符串(如 `"2026-03-15"`)。
- ❌ 通用 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对日期字段无效,会静默返回 0 条;
- ❌ 不支持区间 `date_between` 与相对 `from_now`(CLI 会直接拒绝),范围查询用 `not_before` + `not_after` 组合。
- 详见 [aitable-filter-sort.md](./aitable-filter-sort.md) §日期字段过滤。
---
### currency(货币)
@@ -236,15 +241,14 @@
### attachment(附件)
**写入**:对象数组,支持 `fileToken` 或 `url` 形式
**写入**:对象数组,**必须使用 `fileToken`**
```json
{"fldAttachId": [{"fileToken": "ft_xxx"}]}
{"fldAttachId": [{"url": "https://example.com/file.pdf"}]}
```
> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 获取 `fileToken`**。
> URL 形式是 best-effort 异步转存,不保证立即可用。
> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 上传文件获取 `fileToken`,再将 `fileToken` 写入 cells。**
> ❌ **严禁直接传 `{"url": "https://..."}` 形式写入附件/图片字段** — 服务端会同步下载图片,10 条记录即触发 TIMEOUT_ERROR 超时。
> 写入会**整体覆盖**原附件列表,不是追加。
**读取**:对象数组(含下载链接、文件名、大小)
@@ -335,7 +339,7 @@
|------|----------|
| cells key 用字段名称 `"课程名称"` | 用 fieldId `"fldXXX"` |
| progress 写入 `75` | 写入 `0.75`(范围 0~1) |
| attachment 直接传文件路径 | 必须先 upload 获取 fileToken |
| attachment 直接传文件路径或图片 URL | 必须先 `attachment upload` 获取 fileToken,再用 fileToken 写入(直传 URL 会超时) |
| user 字段传用户名字符串 | 传对象数组 `[{"userId":"...", "corpId":"..."}]` |
| group 字段用 `openConversationId` | 用 `cid` |
| singleSelect 传 option id 字符串 | 传 name 字符串或 `{"id":"...", "name":"..."}` 对象 |
@@ -27,17 +27,18 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
| `dashboard arrange` | 自动重排图表布局 | `--base-id` `--dashboard-id` | 把图表按行铺满网格,避免某行只占半幅、留下大片空白;返回 `{totalColumns, layout, alignedChartCount}` |
## chart 子命令
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` `--layout` |
| `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 填充配置;同时必须传 `--layout` 指定图表位置和尺寸,例如 `--layout '{"x":0,"y":0,"w":6,"h":4}'`。
创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置。
@@ -63,7 +63,7 @@
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| 导出任务超时 | 数据量大,异步任务未完成 | 用 `export data --base-id <BASE_ID> --task-id <taskId>` 轮询直到完成 |
| 导出任务超时 | 数据量大,异步任务未完成 | 用 `export data --task-id <taskId>` 轮询直到完成 |
| 导入文件格式错误 | 不支持的文件格式或文件损坏 | 确认文件为 .xlsx 格式且未加密 |
## 3. 重试策略
@@ -4,10 +4,10 @@
`export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
> ⚠️ **`export data` 的 `--format` 是导出格式**:需要导出 xlsx/附件时写 `--format excel` / `excel_and_attachment`。不要在这个命令上追加全局 `--format json`。
> ⚠️ **`--format` 冲突警告**:`export data` 的 `--format` 是**导出格式**(excel/attachment 等),不是全局输出格式。**此命令禁止追加全局 `--format json`**,否则会覆盖导出格式导致 `INVALID_EXPORT_FORMAT` 错误。输出默认就是 JSON,无需额外指定。
```bash
# 第一步:创建任务(按 scope 传必要参数)
# 第一步:创建任务(按 scope 传必要参数)——注意:不要加 --format json!
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
@@ -10,7 +10,6 @@
- `field create --name --type --config` 中 config 单独传 JSON 字符串
- `field update --config` 只传 config 部分
- 不需要 config 的类型(如 text、checkbox、attachment)可省略 config 字段
- 成员/负责人字段类型使用 `user`,不要使用 `member`;字段类型不要写 `Text`/`Number`,统一写规范值 `text`/`number`
## 2. 字段类型速查
@@ -235,6 +234,5 @@ AI 字段不使用 config,而使用独立的 `--ai-config` 参数。详见 [ai
| options 更新时只传新增项 | 全量覆盖,旧选项全部丢失 |
| formula 字段尝试写入值 | 只读字段,record create/update 会报错 |
| linkedTableId 传表名而非 ID | 必须传 tableId(如 `tblXXX`),不接受表名 |
| 成员字段使用 `member` | 不支持;人员/负责人/成员字段统一使用 `user` |
| progress 值写入 50 表示 50% | 实际应写入 0.5(range 0~1) |
| rating 值超出 max | 写入会报错 |
@@ -1,5 +1,7 @@
# filters & sort — 筛选排序语法参考
> 视图(view)配置的 filter/sort/group **整体写入**请优先用 `view update filter` / `view update sort` / `view update group` 子命令,详见 [aitable-view-config.md](./aitable-view-config.md)。本文件聚焦于 `record query --filters` 与 view config filter 的语法和差异。
## filters 结构规范
### 强制规则
@@ -49,11 +51,34 @@ CLI 同时兼容两种子条件写法(推荐格式 A):
| `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]"]` |
| `not_before` / `not_after` | 不早于(≥) / 不晚于(≤) | `["fieldId", "2026-05-22"]` |
> **操作符拼写必须严格匹配上表**,CLI 会在调用前校验,错误拼写会被拒绝。
>
> **没有 `date_between`(区间)操作符**,也**不支持 `from_now`**——date 字段不支持区间/相对过滤,传了会被 CLI 拒绝。范围查询用 `not_before` + `not_after` 组合,见下方专节。
### 日期字段过滤(date / 创建时间 / 修改时间)
日期类字段的过滤规则与其它字段**不同**,是线上反馈最高频的踩坑点。**经集成测试实测**确认的规则:
1. **只能用日期专用操作符**:`date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`(与前端筛选 UI 的「等于 / 早于 / 晚于 / 早于或等于 / 晚于或等于 / 不为空 / 为空」一一对应)。
2. **比较值用日期字符串**,如 `"2026-05-22"`(也接受 RFC3339 / 毫秒时间戳,内部统一转成毫秒比较)。读取返回的是带时区 RFC3339(如 `"2026-05-22T00:00:00+08:00"`)。
3. **通用操作符 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对 date 字段无效**——无论传 ISO 字符串还是毫秒时间戳,都会**静默返回 0 条**。这是后端 date 字段的比较规则,不是 bug,CLI 也无法在本地拦截(不知道字段类型),务必用对操作符。
4. **没有区间操作符 `date_between`**,也**不支持 `from_now`(相对天数)**——均会静默返回 0 条,CLI 已直接拒绝。范围查询用 `not_before`(≥起点)+ `not_after`(≤终点)两个条件 `and` 组合。
| 需求 | 操作符 | 示例 operands |
|------|--------|--------------|
| 等于某天 | `date_eq` | `["fldDate", "2026-05-22"]` |
| 早于 / 晚于(不含当天) | `before` / `after` | `["fldDate", "2026-05-22"]` |
| 不早于(≥) / 不晚于(≤) | `not_before` / `not_after` | `["fldDate", "2026-05-22"]` |
| 有值 / 为空 | `exist` / `un_exist` | `["fldDate"]` |
**日期区间查询(替代 between)**——查 `2026-05-01 ~ 2026-05-31`(含端点):
```bash
dws aitable record query --base-id X --table-id Y \
--filters '{"operator":"and","operands":[{"operator":"not_before","operands":["fldDate","2026-05-01"]},{"operator":"not_after","operands":["fldDate","2026-05-31"]}]}'
```
### 常见错误拼写(CLI 会自动提示纠正)
@@ -1,37 +1,120 @@
# 表单视图 — 使用 view(FormDesigner)
# form — 表单管理
悟空命令面不暴露独立表单命令组。表单在 AI 表格里按 `viewType=FormDesigner` 的视图处理,所有生成命令都走 `view` 和 `field`。
## 命令一览
## 命令路线
| 命令 | 用途 |
|------|------|
| `form list` | 列出数据表下所有表单视图 |
| `form get` | 按 viewId 取单个表单详情(list_form_views + viewIds 过滤) |
| `form create` | 创建表单视图(等价于 `view create --view-type FormDesigner`) |
| `form update` | 更新表单标题或描述 |
| `form delete` | 删除表单视图(不可逆) |
| `form field list` | 列出表单可见字段 |
| `form field update` | 更新字段必填/描述 |
| `form field hide` | 在表单中隐藏/显示字段(不影响底层数据表字段) |
| `form share get` | 获取分享配置 |
| `form share update` | 开启/关闭分享 |
| `form questions create` | 添加题目(等价于 `field create`,命令位置上的别名) |
| `form questions delete` | 删除题目(等价于 `field delete`,命令位置上的别名) |
| 诉求 | 使用命令 | 说明 |
|------|----------|------|
| 列出表单视图 | `dws aitable view list --base-id <BASE_ID> --table-id <TABLE_ID>` | 从返回视图中过滤 `viewType=FormDesigner` |
| 查看表单视图 | `dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID> --view-ids <VIEW_ID>` | 按 viewId 获取 |
| 创建表单视图 | `dws aitable view create --base-id <BASE_ID> --table-id <TABLE_ID> --view-type FormDesigner --name "表单名"` | 返回 `viewId` |
| 更新表单视图 | `dws aitable view update --base-id <BASE_ID> --table-id <TABLE_ID> --view-id <VIEW_ID> --name "新名称"` | 描述用 `--desc` JSON |
| 删除表单视图 | `dws aitable view delete --base-id <BASE_ID> --table-id <TABLE_ID> --view-id <VIEW_ID> --yes` | 不可逆 |
| 添加题目 | `dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> --fields '[...]'` | 题目本质是字段 |
| 删除题目 | `dws aitable field delete --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --yes` | 不可逆 |
## 创建工作流
## 建议操作顺序
```bash
# 1. 创建表单视图
dws aitable view create --base-id BASE_ID --table-id TABLE_ID \
--view-type FormDesigner --name "员工信息收集" --format json
# 1) 列出数据表下的表单视图
dws aitable form list --base-id BASE_ID --table-id TABLE_ID --format json
# 2. 添加题目字段
dws aitable field create --base-id BASE_ID --table-id TABLE_ID \
--fields '[{"fieldName":"姓名","type":"text"},{"fieldName":"邮箱","type":"text"}]' --format json
# 2) 查看单个表单详情
dws aitable form get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
# 3. 回读表单视图
dws aitable view get --base-id BASE_ID --table-id TABLE_ID --view-ids VIEW_ID --format json
# 3) 查看表单字段配置
dws aitable form field list --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
# 4) 查看分享配置
dws aitable form share get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
```
## 注意
## 要点
- 不要生成隐藏兼容的独立表单命令;它不属于悟空对齐的公开命令面。
- `VIEW_ID` 来自 `view create` 返回。
- `FIELD_ID` 来自 `field create` 或 `field get` 返回。
- 字段必填、字段隐藏、分享开关等表单高级配置没有公开的悟空命令入口;不要用隐藏兼容命令替代。
- **创建表单**有两种等价方式:
- `form create --name "表单名"`(推荐,语义清晰)
- `view create --view-type FormDesigner --name "表单名"`(底层一致)
- `form update` 支持 `--title` 与 `--name` 两个等价参数;至少需传一项
- `form field update` 必须传 `--required` 或 `--field-description` 至少一项
- `form field hide` 仅控制字段在表单中的可见性,不影响底层数据表字段
- **题目管理**与字段管理本质相同(题目 = 表格字段):
- `form questions create` 与 `field create` 入参完全一致(`--fields` JSON 或 `--name --type`)
- `form questions delete` 与 `field delete` 入参完全一致(必传 `--field-id`)
- 设置必填要在 create 后用 `form field update --required true` 单独调一次
## form 子命令
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 返回 viewId/name/title/createdAt |
| `form get` | 按 viewId 取单个表单 | `--base-id` `--table-id` `--view-id` | 内部基于 list_form_views 过滤 |
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | viewType=FormDesigner |
| `form update` | 更新表单 | `--base-id` `--table-id` `--view-id` | `--title`/`--name`(等价)和 `--description` 至少传一项;同时传 title/name 时 title 优先 |
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` `--yes` | 不可逆 |
## form field 子命令
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `form field list` | 列出表单字段 | `--base-id` `--table-id` `--view-id` | 返回 fieldId/name/type/required/hidden/description(hidden=true 的字段不在此返回) |
| `form field update` | 更新表单字段 | `--base-id` `--table-id` `--view-id` `--field-id` | `--required` 或 `--field-description` 至少一项 |
| `form field hide` | 切换字段隐藏 | `--base-id` `--table-id` `--view-id` `--field-id` `--hidden` | `--hidden true` 隐藏 / `--hidden false` 显示 |
## form questions 子命令
`form questions create/delete` 与 `field create/delete` 入参、行为完全一致,只是命令位置归属于 `form` 命令组,方便从表单视角操作题目。
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `form questions create` | 添加题目 | `--base-id` `--table-id` + (`--fields` 或 `--name --type`) | 入参与 `field create` 完全一致 |
| `form questions delete` | 删除题目 | `--base-id` `--table-id` `--field-id` `--yes` | 入参与 `field delete` 完全一致;不可逆;批量需多次调用 |
## form share 子命令
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `form share get` | 获取分享配置 | `--base-id` `--table-id` `--view-id` | 返回 enabled/status/shareFormUuid |
| `form share update` | 开启/关闭分享 | `--base-id` `--table-id` `--view-id` `--enabled` | `--enabled true` 开启 / `--enabled false` 关闭。注意:UI 上"发布并分享"按钮是另一概念,本命令只切换内部 enabled 标志,开启后需在 UI 刷新页面才会看到分享面板 |
## 完整工作流示例
> **占位符约定**:
> - `BASE_ID` 来自 `dws aitable base list` / `base search` 返回的 `data.bases[].baseId`
> - `TABLE_ID` 来自 `dws aitable base get --base-id BASE_ID` 返回的 `data.tables[].tableId`
> - `VIEW_ID` 来自步骤 1 `form create` 返回的 `data.viewId`
> - `FIELD_ID` 来自步骤 2 `form questions create` 返回的 `data.results[].fieldId`
```bash
# 1) 创建表单 → 取返回的 data.viewId 作为 VIEW_ID
dws aitable form create --base-id BASE_ID --table-id TABLE_ID --name "员工信息收集" --format json
# 2) 添加题目 → 取返回的 data.results[].fieldId 作为 FIELD_ID
dws aitable form questions create --base-id BASE_ID --table-id TABLE_ID \
--fields '[{"fieldName":"姓名","type":"text"},{"fieldName":"邮箱","type":"text"}]' --format json
# 3) 配置表单标题与描述
dws aitable form update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
--title "员工信息收集" --description "请填写您的基本信息" --format json
# 4) 设置题目必填(FIELD_ID 来自步骤 2)
dws aitable form field update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
--field-id FIELD_ID --required true --format json
# 5) 隐藏不需要的题目
dws aitable form field hide --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
--field-id FIELD_ID --hidden true --format json
# 6) 开启分享(注意:开启后需 UI 刷新页面才会看到分享面板)
dws aitable form share update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
--enabled true --format json
```
## 返回结构补充
- `form list` 返回 `data.formViews[]`,**每条仅含** `viewId/name/title/createdAt`;`shareFormUuid` 不在此返回,请用 `form share get` 单独获取。
- `form get` 返回结构与 `form list` 完全一致(`data.formViews[]`),仅含一条记录(与请求 viewId 一致)。Agent 提取时仍走 `data.formViews[0]`。
- `form field list` 仅返回**未隐藏**的字段;`hidden=true` 的字段不在此返回,如需查看全部字段请用 `field get`。
@@ -0,0 +1,55 @@
# 主键文档管理
## 适用场景
当需要为 AI 表格中的记录创建或查询关联的主键文档时使用。主键文档是 primaryDoc 类型字段对应的钉钉在线文档,可通过 `dws doc` 进行内容读写。
## 命令
### 查询主键文档
```bash
dws aitable record primary-doc-get --base-id BASE_ID --table-id TABLE_ID --record-id RECORD_ID
```
**参数:**
- `--base-id`(必填):Base ID
- `--table-id`(必填):Table ID
- `--record-id`(必填):Record ID
**返回:** `data.nodeId` — 主键文档的 nodeId,可直接传给 `dws doc read/update` 的 `--node` 参数。若该记录尚未创建主键文档,`nodeId` 为 null。
### 创建主键文档
```bash
dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
```
**参数:**
- `--base-id`(必填):Base ID
- `--table-id`(必填):Table ID
- `--field-id`(必填):主键字段 ID,必须是 primaryDoc 类型(通过 `dws aitable table get` 查看字段类型)
- `--record-id`(必填):Record ID
**返回:** `data.nodeId` — 创建或已存在的主键文档 nodeId。
**幂等性:** 若该记录已有主键文档,直接返回已有文档的 nodeId,不会重复创建。
## 注意事项
- `fieldId` 必须是 primaryDoc 类型,否则返回 `INVALID_FIELD_TYPE` 错误
- 传入不存在的 `recordId` 会返回 `RECORD_NOT_FOUND` 错误
- 创建后可通过 `dws doc update --node <nodeId>` 写入文档内容,或 `dws doc read --node <nodeId>` 读取
## 典型工作流
```bash
# 1. 查询表结构,拿到 primaryDoc 字段的 fieldId
dws aitable table get --base-id BASE_ID --table-ids TABLE_ID
# 2. 为某条记录创建主键文档
dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
# 3. 拿到返回的 nodeId,用 dws doc 写入内容
dws doc update --node <data.nodeId> --content "# 项目方案\n\n文档正文内容..."
```
@@ -27,12 +27,13 @@ Flags:
| cells key 用字段名 | ❌ cells key 必须是 fieldId(如 `fldXXX`),不是字段名称(如 `"课程名称"`) |
| 不先获取 fieldId | ❌ 必须先 `table get` 获取 fieldId,再写入记录 |
| 单次超 100 条 | ❌ 单次最多 100 条,超过需分批 |
| 附件/图片字段直传 URL | ❌ 严禁 `{"url":"https://..."}` — 会触发 TIMEOUT_ERROR。必须先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。详见 [aitable-attachment.md](./aitable-attachment.md) |
## 正确流程
```bash
# 先获取 fieldId
dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID> --format json
dws aitable table get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 从返回中提取 fieldId(如 fldABC123)
# 再用 fieldId 写入记录
@@ -0,0 +1,97 @@
# 行记录变更历史(record history-list)
按 recordId 查询单条记录的全部变更历史,用于审计、回溯字段变更、定位操作人。
## 命令
```
dws aitable record history-list \
--base-id BASE_ID --table-id TABLE_ID --record-id REC_ID \
[--offset N] [--limit M]
```
| flag | 说明 |
|------|------|
| `--base-id` | 所属 Base ID(必填,可用 `--base` 别名) |
| `--table-id` | 所属 Table ID(必填) |
| `--record-id` | 目标记录 ID(必填,单条;不支持批量) |
| `--offset` | 分页偏移量,默认 0 |
| `--limit` | 每页返回数量,范围 [1, 50],默认 20 |
## 返回结构
```jsonc
{
"data": {
"histories": [
{
"type": "field_change", // 变更类型
"action": "update", // 操作动作: create / update / delete
"newValue": "{\"...\":\"...\"}", // 变更后的值(JSON 字符串)
"oldValue": "{\"...\":\"...\"}", // 变更前的值(JSON 字符串)
"operateTime": 1733123456789, // 操作时间(毫秒级时间戳)
"typeChangedFields": "{...}", // 类型变更的字段信息(JSON 字符串)
"version": 7 // 版本号(单调递增)
}
]
}
}
```
`newValue` / `oldValue` / `typeChangedFields` 是 JSON 字符串(不是 JSON 对象),需要二次 `JSON.parse` 才能拿到结构化值。
## 字段含义速查
| 字段 | 用途 |
|------|------|
| `type` | 高层分类:`record_create` / `field_change` / `record_delete` 等。先按 type 过滤大类。 |
| `action` | 三态:`create` / `update` / `delete`。比 type 粗,但便于按"动作"统计。 |
| `version` | 单调递增整数;同一 record 越新值越大。**用作"上一条 vs 这一条"的稳定排序键**。 |
| `operateTime` | 毫秒时间戳;可格式化成可读时间。多条同 version 的极端场景用 operateTime 兜底排序。 |
## 典型用法
### 1. 看一条记录被改过几次
```bash
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
| jq '.data.histories[] | {version, action, operateTime}'
```
### 2. 翻页拉全量历史
```bash
# 第 1 页(最新 20 条)
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --offset 0
# 第 2 页
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --offset 50
```
`limit` 上限 50,需要更多请增加 `offset` 翻页。
### 3. 回溯某字段最近一次值
```bash
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --format json \
| jq '[.data.histories[] | select(.action == "update")][0].oldValue'
```
### 4. 找出删除事件(如果存在 delete history)
```bash
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
| jq '.data.histories[] | select(.action == "delete") | {version, operateTime}'
```
## 注意事项
- 一次只能查一条 record;如需批量审计多条记录请循环调用。
- 仅返回**字段值变更**与**记录生命周期事件**;视图、字段定义、表结构变更不在此 history 里。
- 历史保留时长由 server 决定,过老的记录可能不再返回。
## 与其他 record 命令的关系
- 想看记录"现在长什么样" → `record query` / `record get`
- 想看记录"过去长什么样、什么时候改的" → `record history-list`(本命令)
- 想看"这张表整体改过什么" → 当前 CLI 不支持表级 history;只能逐 record 查
@@ -0,0 +1,47 @@
# 行命名规则枚举键(recordNameKey)映射
`dws aitable table update --record-name-key <枚举键>` 用于设置数据表的"行命名规则"——卡片/详情页里"行"的展示别名。**取值是固定枚举,不是字段 ID**;传非法值服务端返回 `INVALID_RECORD_NAME_KEY`。
## 中文 → 枚举键(按 UI 下拉顺序)
| 用户说 | --record-name-key | 用户说 | --record-name-key |
|---|---|---|---|
| 记录 | `ji_lu`(默认) | 项目 | `project` |
| 任务 | `task` | 事件 | `event` |
| 请求 | `request` | 活动 | `campaign` |
| 目标 | `objective` | 交付物 | `deliverable` |
| 资产 | `asset` | 客户 | `customer` |
| 订单 | `order` | 联系人 | `contact` |
| 物料/物品 | `item` | 问题 | `question` 或 `issue` |
| 工单 | `ticket` | 候选人 | `candidate` |
| 商机/机会 | `opportunity` | 会议 | `meeting` |
| 成员 | `member` | OKR | `okr` |
## 其他常用键(按场景分组)
- **业务流程**:`approval` / `application` / `case` / `decision` / `delivery` / `payment` / `purchase_order` / `quote` / `release`
- **HR / 财务**:`employee` / `expense` / `budget` / `invoice`
- **产品 / 研发**:`feature` / `feedback` / `idea` / `bug` / `requirement` / `risk` / `sprint` / `story` / `subtask` / `epic`
- **CRM**:`account` / `lead` / `prospect` / `deal`
- **运营 / 支持**:`note` / `report` / `topic` / `session` / `service`
- **资源 / 通用**:`file` / `document` / `product` / `team` / `user` / `vendor` / `key_result` / `metric`
完整集合较大(共 273 个),服务端校验;以上未列出的合法键也可直接传(如 `goal` / `okr` / `pillar` / `phase` / `milestone` 等)。
## 使用示例
```bash
# 用户说"把这张表的行叫'任务'吧" → 传 task
dws aitable table update --base-id BASE --table-id TBL --record-name-key task
# 用户说"换成项目" → 传 project
dws aitable table update --base-id BASE --table-id TBL --record-name-key project
# 用户说"恢复成默认(记录)" → 传 ji_lu
dws aitable table update --base-id BASE --table-id TBL --record-name-key ji_lu
```
## 注意
- recordNameKey **不会在 `table get` 响应里回显**(`get_tables` DTO 设计上不暴露该字段);写入是否成功以 `table update` 的 set response 是否回填 `recordNameKey` 字段为准。
- 中文别名是 server 内置 i18n,UI 显示用户对应的国际化文案,CLI 必须传英文枚举键。
@@ -72,3 +72,49 @@ dws aitable record query --base-id X --table-id Y --all --cursor "上次返回
- `--sort` 用 `"order":"desc"` → 必须用 `"direction":"desc"`
- 不加 `--field-ids` 拉全字段 → 大表响应体积过大
- 全量拉取后在 context 里手动统计 → 应优先用 `--filters` 服务端过滤
## record query-empty — 找空行
`record query-empty` 是与 `record query` 平行的独立子命令,专门按表内顺序扫描出"完全没填用户字段"的空行。
```bash
dws aitable record query-empty --base-id BASE_ID --table-id TABLE_ID
```
| flag | 说明 |
|------|------|
| `--base-id` / `--base` | 必填 |
| `--table-id` | 必填 |
| `--limit` | 单次**扫描预算**(不是返回数);范围 [1, 100],默认 100 |
| `--cursor` | 分页游标。响应中 `nextCursor` 非空 → 用它翻页继续扫;nextCursor 为空(或不存在)→ 已扫完整表 |
返回结构:
```jsonc
{ "data": { "records": [...], "nextCursor": "..." } }
```
### 关键语义
1. **`--limit` 是扫描预算不是返回数**:可能扫了 100 条但全部非空,本页 `records: []`。
2. **本页空 records ≠ 全表无空行**:必须看 `nextCursor`,nextCursor 还在就要继续翻。
3. **空行定义**:除系统字段(recordId / 创建人 / 创建时间 / 修改人 / 修改时间)外,所有 cell 都是 null、空字符串、空集合或空 Map。一般是用户在 UI 上"插入空行"产生的。
### 典型用法
```bash
# 扫一页,看本页有没有空行
dws aitable record query-empty --base-id BASE --table-id TBL
# 翻页
dws aitable record query-empty --base-id BASE --table-id TBL --cursor <上次的nextCursor>
# 把整表扫完(手动循环 cursor)
NC=""
while : ; do
R=$(dws aitable record query-empty --base-id BASE --table-id TBL ${NC:+--cursor "$NC"} --format json)
echo "$R" | jq '.data.records[] | .recordId'
NC=$(echo "$R" | jq -r '.data.nextCursor // empty')
[ -z "$NC" ] && break
done
```
@@ -0,0 +1,54 @@
# 行记录分享链接(record share-url)
按 recordId 批量获取记录的分享链接,把某行单独发给同事查看。
## 命令
```
dws aitable record share-url \
--base-id BASE_ID --table-id TABLE_ID \
--record-ids rec1,rec2,rec3 \
[--view-id VIEW_ID]
```
| flag | 说明 |
|------|------|
| `--base-id` | 所属 Base ID(必填,可用 `--base` 别名) |
| `--table-id` | 所属 Table ID(必填) |
| `--record-ids` | 目标 Record ID 列表,CSV 逗号分隔,**单次最多 20 条**(必填) |
| `--view-id` | 视图 ID(可选)。带上后链接打开会落在该视图上下文里 |
## 返回结构
```jsonc
{
"data": {
"items": [
{ "recordId": "rec1", "shareUrl": "https://..." },
{ "recordId": "rec2", "shareUrl": "https://..." }
]
}
}
```
`shareUrl` 为 null 表示该条获取失败(不影响其他条目)。
## 典型用法
```bash
# 一次拿一条记录的链接
dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1
# 批量拿,配合 jq 过滤出 url
dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1,rec2,rec3 --format json \
| jq '.data.items[] | {recordId, shareUrl}'
# 带视图上下文(链接打开时落在指定视图)
dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1 --view-id viw_VIP
```
## 注意事项
- **单次最多 20 条**,超出请客户端拆批。
- 该链接是分享链接(不是源文档链接),打开后看到的是该 record 的只读详情页。
- 取消单条分享 / 关闭整表分享当前 CLI 不支持,需要在 AI 表格 Web 端操作。
@@ -0,0 +1,89 @@
# 行记录 Upsert(record upsert)
按 `recordId` 是否存在,自动把入参拆分到 update 链路或 create 链路:批次混合"已存在改 + 新出现建"时用,省掉客户端按 ID 分批的逻辑。
## 命令
```
dws aitable record upsert \
--base-id BASE_ID --table-id TABLE_ID \
--records '[{"recordId":"<可选>","cells":{...}}, ...]'
```
| flag | 说明 |
|------|------|
| `--base-id` | 必填(可用 `--base` 别名) |
| `--table-id` | 必填 |
| `--records` | 待 upsert 的记录 JSON 数组,**单次最多 100 条**(必填)|
| `--records-file` | 从文件读入(命令行 JSON 太长时用),与 `--records` 互斥优先级更高 |
## --records 结构
每项 JSON:
```jsonc
{
"recordId": "rec1", // 可选;带 → update,缺省 → create
"cells": { // 必填;key 是 fieldId,value 按字段类型
"fldTitleId": "新标题",
"fldNumberId": 42
}
}
```
`cells` 写入格式与 `record create` / `record update` **完全一致**(key 必须是 fieldId 不是字段名;按字段类型见 [aitable-cell-value.md](./aitable-cell-value.md))。
## 返回结构
```jsonc
{
"data": {
"createdRecordIds": ["recX", "recY"], // 不带 recordId 的项产出
"updatedRecordIds": ["recA", "recB"] // 带 recordId 的项产出
}
}
```
`createdRecordIds` 顺序对应入参里**不带 recordId**的项(按出现顺序汇总),同理 `updatedRecordIds` 对应**带 recordId**的项。
## 典型用法
```bash
# 1) 全部新建:所有项都不带 recordId
dws aitable record upsert --base-id BASE --table-id TBL --records '[
{"cells":{"fldTitleId":"任务1","fldStatusId":"待办"}},
{"cells":{"fldTitleId":"任务2","fldStatusId":"待办"}}
]'
# 2) 全部更新:所有项都带 recordId
dws aitable record upsert --base-id BASE --table-id TBL --records '[
{"recordId":"rec1","cells":{"fldStatusId":"已完成"}},
{"recordId":"rec2","cells":{"fldStatusId":"已完成"}}
]'
# 3) 混合:第 1 条更新(带 recordId),第 2 条创建(不带)
dws aitable record upsert --base-id BASE --table-id TBL --records '[
{"recordId":"rec1","cells":{"fldStatusId":"已完成"}},
{"cells":{"fldTitleId":"新增任务","fldStatusId":"待办"}}
]'
# 4) 长 JSON 用文件
dws aitable record upsert --base-id BASE --table-id TBL --records-file ./batch.json
```
## 与 record create / record update 的关系
| 场景 | 命令 |
|------|------|
| 确定全是新增 | `record create` |
| 确定全是更新(每条独立 cells) | `record update` |
| 确定全是更新(共享同一 cells) | `record batch-update` |
| **不确定有没有,按 recordId 自动分流** | `record upsert`(本命令) |
`record upsert` 的 `--records` 入参格式与 `record update` 完全相同,唯一差别是 `recordId` 字段在 upsert 里是可选的。如果批次确定全是更新或全是新建,用专用命令更清晰;批次混合时(典型场景:定时同步外部数据,源里既有已存在的也有新出现的),用 upsert。
## 注意事项
- **单次最多 100 条**(创建 + 更新合计),超出请客户端拆批。
- `cells` 的 key 必须是 fieldId 不是字段名(先用 `record query` 或 `field get` 拿 fieldId)。
- 只读字段(formula / lookup / 系统字段)不能写入 — upsert 链路与 update 链路同样限制。
@@ -0,0 +1,203 @@
# 视图配置(view get/update <attr>)
按属性局部读/写视图配置。每个属性独立子命令,typed flag 友好,agent 不必拼 JSON。
向后兼容:`view update --config '{...}'` 一次多属性入口仍可用。
## viewType × 支持矩阵
| viewType | card | timebar | aggregate | filter / sort / group | visible-fields | field-widths | name |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| Grid | | | ✅ | ✅ | ✅ | ✅ | ✅ |
| Kanban | ✅ | | | ✅ | ✅ | | ✅ |
| Gallery | ✅ | | | ✅ | ✅ | | ✅ |
| Gantt | | ✅ | | ✅ | ✅ | | ✅ |
| Calendar | | | | ✅ | ✅ | | ✅ |
| FormDesigner | (走 `form` 系列命令) | | | | | | |
> card 在 Kanban 走 `kanbanCard`,在 Gallery 走 `galleryCard`,CLI 自动按 viewType dispatch(preflight 1 次 `get_views`)。timebar 仅 Gantt 支持;Calendar 服务端未暴露任何 timebar 配置。
> **Gantt 视图必须两步创建**:`view create --view-type Gantt` 只创建空壳(`ganttTimebar: {}`),**必须**紧跟 `view update timebar --start-field <日期字段ID>` 绑定时间轴字段,否则视图打开是空白。`create_view` 的 `--config` 中传入 `ganttTimebar` 会被服务端忽略。
## 读取:view get <attr>
所有 `view get <attr>` 共用 `--base-id` / `--table-id` / `--view-id`,输出是该属性子块的 JSON(不存在时输出 `{}`)。viewType 不匹配会报错并指明应该选哪种视图。
```bash
dws aitable view get card --view-id VIEW_ID --format json # Kanban / Gallery
dws aitable view get timebar --view-id VIEW_ID --format json # Gantt
dws aitable view get aggregate --view-id VIEW_ID --format json # Grid
dws aitable view get filter --view-id VIEW_ID --format json # 所有
dws aitable view get sort --view-id VIEW_ID --format json
dws aitable view get group --view-id VIEW_ID --format json
dws aitable view get visible-fields --view-id VIEW_ID --format json
dws aitable view get field-widths --view-id VIEW_ID --format json # Grid
```
## 写入:view update <attr>
所有 `view update <attr>` 共用 `--base-id` / `--table-id` / `--view-id`。
**typed flag + `--json` 可混用**;冲突时 typed flag 优先并 stderr 提示。
card / timebar / aggregate 三类写入有 viewType 校验(preflight 1 次 get_views)。
### view update card(Kanban / Gallery)
服务端按 viewType 分发到 `kanbanCard` 或 `galleryCard`。typed flag 共享。
| flag | 类型 | 说明 |
|------|------|------|
| `--cover-field-id` | string | 封面字段 ID(Kanban / Gallery 通用),与 `--no-cover` 互斥 |
| `--no-cover` | bool | 清除封面(等价 `coverFieldId="NONE"`) |
| `--cover-resize-mode` | string | `cover` / `contain` / `stretch` |
| `--hidden-field-title` | bool | 隐藏字段名标题(仅 Kanban 生效) |
| `--cover-mode` | string | `none` / `auto` / `custom`(仅 Gallery 生效) |
| `--display-field-name` | bool | 是否显示字段名(仅 Gallery 生效) |
| `--json` | JSON | 完整 card 子块对象 |
```bash
dws aitable view update card --view-id KANBAN_ID --cover-field-id fldAttachment --cover-resize-mode contain
dws aitable view update card --view-id KANBAN_ID --no-cover
dws aitable view update card --view-id GALLERY_ID --cover-mode auto
dws aitable view update card --view-id GALLERY_ID --json '{"coverMode":"custom","coverFieldId":"fldX","displayFieldName":true}'
```
### view update timebar(仅 Gantt)
| flag | 类型 | 说明 |
|------|------|------|
| `--start-field` | string (date fieldId) | 开始日期字段 |
| `--end-field` | string (date fieldId) | 结束日期字段 |
| `--display-field-id` | string | 时间条上显示的标题字段 |
| `--timeline-scale` | string | `year` / `quarter` / `month` / `weeks` |
| `--color-configs` | JSON 数组 | 颜色配置数组(结构由下游协议定义;清空传 `[]`) |
| `--official-holiday` | bool | 是否标注法定节假日 |
| `--json` | JSON | 完整 ganttTimebar 子块 |
```bash
dws aitable view update timebar --view-id GANTT_ID --start-field fldStart --end-field fldEnd --timeline-scale month
dws aitable view update timebar --view-id GANTT_ID --official-holiday=true
```
### view update aggregate(仅 Grid)
值是 `map[fieldId]→AggregateAction string`;传 null 清除某个字段聚合。
| flag | 类型 | 说明 |
|------|------|------|
| `--field-id` | string | 配合 `--action` 设置**单字段**聚合 |
| `--action` | string | `SUM`/`AVG`/`MAX`/`MIN`/`MEDIAN`/`RANGE`/`TOTAL`/`DISTINCT`/`EXIST`/`UN_EXIST`/`CHECKED`/`EARLIEST_DATE` 等(按字段类型可用) |
| `--clear-field-id` | string (CSV) | 一/多个字段 ID,清除其聚合 |
| `--json` | JSON | 完整 aggregate map |
```bash
dws aitable view update aggregate --view-id GRID_ID --field-id fldX --action SUM
dws aitable view update aggregate --view-id GRID_ID --clear-field-id fldA,fldB
dws aitable view update aggregate --view-id GRID_ID --json '{"fldX":"AVG","fldY":null}'
```
### view update field-widths(仅 Grid)
| flag | 类型 |
|------|------|
| `--field-id` + `--width` | string + int(单字段) |
| `--json` | `{fldId: width, ...}` |
```bash
dws aitable view update field-widths --view-id GRID_ID --field-id fldX --width 200
dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB":200}'
```
### view update visible-fields(通用)
整组替换可见字段列表与顺序。首列字段(primaryDoc)必须保留在数组第一位。
> ⚠️ 注意:服务端**只接受 reorder,不接受真"隐藏字段"**——如果传入的列表比当前 columns 短,缺失的字段不会被隐藏。需要真正隐藏字段请到 AI 表格 Web UI。
| flag | 类型 |
|------|------|
| `--field-ids` | string (CSV) |
| `--json` | string 数组 JSON(与 `--field-ids` 同传时 `--json` 优先) |
```bash
dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,fldA,fldB
dws aitable view update visible-fields --view-id VIEW_ID --json '["fldPrimary","fldA","fldB"]'
```
### view update filter / sort / group(通用,纯 --json)
```bash
dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'
dws aitable view update sort --view-id VIEW_ID --json '[{"fieldId":"fldX","direction":"asc"}]'
dws aitable view update group --view-id VIEW_ID --json '[{"fieldId":"fldX","direction":"asc"}]'
```
> filter/sort/group 入参格式与 `record query --filters`(对象格式)**不同**:view config 这边外层必须是数组。传对象 CLI 会自动 wrap,建议直接用数组。详见 [aitable-filter-sort.md](./aitable-filter-sort.md)。
### view update name(重命名)
```bash
dws aitable view update name --view-id VIEW_ID --name "新视图名"
```
等价于 `dws aitable view update --view-id VIEW_ID --name "新视图名"`,无 `config` 参数。
## 服务端字段速查(与 dws CLI 关系)
| dws 子命令 | 服务端 `update_view.config` 子键 | 服务端 Java 模型 |
|---|---|---|
| `view update card`(Kanban) | `kanbanCard` | `KanbanCardUpdateInput` |
| `view update card`(Gallery) | `galleryCard` | `GalleryCardUpdateInput` |
| `view update timebar` | `ganttTimebar` | `GanttTimebarUpdateInput` |
| `view update aggregate` | `aggregate` | `Map<fieldId, AggregateAction>` |
| `view update visible-fields` | `visibleFieldIds` | `List<String>` |
| `view update filter / sort / group` | `filter` / `sort` / `group` | `List<Object>` |
| `view update field-widths` | `fieldWidths` | `Map<String, Object>` |
| `view update name` | (不在 config 内)`newViewName` 顶层 | — |
## 典型工作流
### 排查"Kanban 卡片为啥不显示封面"
```bash
dws aitable view get card --view-id KANBAN_ID --format json
# → 看 coverFieldId 是不是 "NONE" 或缺失;不是再看 coverResizeMode 是不是 contain 导致裁掉
```
### 创建可用的 Gantt 视图(必须两步)
```bash
# 第 1 步:创建 Gantt 视图
dws aitable view create --base-id BASE_ID --table-id TABLE_ID \
--view-type Gantt --name "项目甘特图" -f json
# → 记录返回的 viewId
# 第 2 步(必须):绑定日期字段,否则视图为空
dws aitable view update timebar --base-id BASE_ID --table-id TABLE_ID \
--view-id VIEW_ID --start-field fldDateStart
# 可选:加结束日期、标题字段、时间尺度
# --end-field fldDateEnd --display-field-id fldName --timeline-scale month
```
### 把 Gantt 时间轴改成季度尺度并加节假日
```bash
dws aitable view update timebar --view-id GANTT_ID \
--timeline-scale quarter --official-holiday=true
```
### 用 dws 脚本批量替换 Kanban 封面字段
```bash
for v in viw1 viw2 viw3; do
dws aitable view update card --view-id $v --cover-field-id fldNewCover --cover-resize-mode cover --format json | jq .status
done
```
### 一次性多属性更新(仍走 legacy --config)
```bash
dws aitable view update --view-id VIEW_ID --config '{
"visibleFieldIds":["fldPrimary","fldA","fldB"],
"filter":[{"operator":"and","operands":[]}],
"kanbanCard":{"coverFieldId":"fldImg","coverResizeMode":"contain"}
}'
```
@@ -0,0 +1,198 @@
# 视图扩展操作(lock / frozen-cols / row-height / fill-color-rule / duplicate)
本文档讲 5 项视图操作命令:
- 锁定 / 解锁视图:`view lock` / `view get lock`
- 冻结列:`view update frozen-cols` / `view get frozen-cols`
- 行高:`view update row-height` / `view get row-height`
- 数据高亮规则(条件填色):`view update fill-color-rule` / `view get fill-color-rule`
- 复制视图:`view duplicate`
> **与 [aitable-view-config.md](./aitable-view-config.md) 的分工**:
> - `aitable-view-config.md` 讲 `view get/update <attr>` 中 8 个属性:filter / sort / group / visible-fields / field-widths / aggregate / card / timebar。
> - 本文档讲上面 5 项额外能力(包括 attr 形式的 frozen-cols / row-height / fill-color-rule,以及顶层独立的 lock / duplicate)。
> 这 5 项**不能**通过 `view update --config '{...}'` 写入,必须用各自专属子命令。
## 命令矩阵
| 子命令 | 用途 | 必填参数 | 适用 viewType |
|---|---|---|---|
| `view lock [--off]` | 锁定(默认)/ 解锁视图 | `--base-id --table-id --view-id` | 全部 |
| `view get lock` | 读取锁定状态 | `--base-id --table-id --view-id` | 全部 |
| `view update frozen-cols --count N` | 冻结左侧 N 列(0 取消) | `--base-id --table-id --view-id --count` | Grid |
| `view get frozen-cols` | 读取冻结列数 | `--base-id --table-id --view-id` | Grid |
| `view update row-height --cell-height N` | 设置单元格高度(像素) | `--base-id --table-id --view-id --cell-height` | Grid |
| `view get row-height` | 读取单元格高度 | `--base-id --table-id --view-id` | Grid |
| `view update fill-color-rule --json '[...]'` | 全量覆盖条件填色规则 | `--base-id --table-id --view-id --json` | Grid |
| `view get fill-color-rule` | 读取条件填色规则 | `--base-id --table-id --view-id` | 全部(其他视图返回 `[]`) |
| `view duplicate [--new-name X]` | 复制视图 | `--base-id --table-id --view-id` | 全部 |
## 视图锁定 / 解锁
```bash
# 锁定(默认)
dws aitable view lock --view-id VIEW_ID
# 解锁
dws aitable view lock --view-id VIEW_ID --off
# 查询当前是否锁定
dws aitable view get lock --view-id VIEW_ID --format json
# → {"data": {"baseId": ..., "tableId": ..., "viewId": ..., "locked": true|false}}
```
锁定的视图禁止他人修改其配置(filter/sort/group/字段顺序等),但记录读写不受影响。锁定状态可重复 set,幂等。
## 冻结列(仅 Grid)
```bash
# 冻结从首列起 1 列
dws aitable view update frozen-cols --view-id VIEW_ID --count 1
# 取消冻结
dws aitable view update frozen-cols --view-id VIEW_ID --count 0
# 查询当前冻结列数
dws aitable view get frozen-cols --view-id VIEW_ID --format json
# → {"data": {..., "count": 1}} count 为 null 表示视图未显式设置
```
`--count` 必须 ≥ 0;负数会被拒绝。
## 行高(仅 Grid)
⚠️ **`--cell-height` 只接受 4 档枚举:32 / 56 / 88 / 128**(与前端 CELL_HEIGHTS 约定一致),其他值会被拒绝。默认值为 32。
```bash
# 设置行高 — 推荐档位 32 / 56 / 88 / 128
dws aitable view update row-height --view-id VIEW_ID --cell-height 56
# 查询当前行高
dws aitable view get row-height --view-id VIEW_ID --format json
# → {"data": {..., "cellHeight": 56}} cellHeight 为 null 表示视图未显式设置(前端按 32 渲染)
```
## 数据高亮规则(条件填色,仅 Grid)
`view update fill-color-rule` **整组覆盖**,传 `--json '[]'` 清空所有规则。
### 规则结构
每条规则 JSON 结构:
```jsonc
{
"type": "cell" | "row" | "column" | "preRow",
"formatFieldId": "fldX", // 命中规则后被高亮的字段(cell/column 类型有意义)
"format": { "color": "firstLine5" }, // ⚠️ 必须用 FORMAT_COLORS 代号,不接受 hex
"filters": [ // 当前固定 1 条
{
"fieldId": "fldX", // ⚠️ 不是 operands[0]
"symbol": "GT", // ⚠️ 不是 operator;大写枚举
"value": 100 // 部分 symbol(EXIST/UN_EXIST)不需要 value
}
]
}
```
### color 合法值(FORMAT_COLORS)
`firstLine1` ~ `firstLine11`(共 11 档色码,对应前端调色盘)。**不接受 `#FF0000` 这种 hex**。
### filter.symbol 合法值
| 类别 | symbol |
|---|---|
| 数值/通用比较 | `GT` / `LT` / `GTE` / `LTE` / `EQ` / `NE` |
| 文本 | `CONTAIN` / `EXCLUSIVE` |
| 存在性(无 value) | `EXIST` / `UN_EXIST` |
| 多选 / 集合 | `ALL_OF` / `ANY_OF` / `NONE_OF` |
| 日期 | `BEFORE` / `AFTER` / `NOT_BEFORE` / `NOT_AFTER` / `DATE_EQ` / `FROM_NOW` / `DATE_BETWEEN` |
> **与 `record query --filters` / `view update filter` 的格式不同**:那两处用 `{operator, operands}` 结构;这里是 `{fieldId, symbol, value}`。不要混用。
### 典型用法
```bash
# 1) 给金额字段 > 100 的单元格上 firstLine5 色
dws aitable view update fill-color-rule --view-id GRID_ID --json '[
{
"type":"cell",
"formatFieldId":"fldAmount",
"format":{"color":"firstLine5"},
"filters":[{"fieldId":"fldAmount","symbol":"GT","value":100}]
}
]'
# 2) 清空所有规则
dws aitable view update fill-color-rule --view-id GRID_ID --json '[]'
# 3) 查询当前规则
dws aitable view get fill-color-rule --view-id GRID_ID --format json
# → {"data": [...]} 数组
```
> **写入后请用 `view get fill-color-rule` 二次确认实际生效**,以读到的 `data` 数组为准。
## 复制视图
```bash
# 显式命名
dws aitable view duplicate --view-id VIEW_ID --new-name "副本视图"
# 系统自动命名(一般是 "原视图名 (副本)")
dws aitable view duplicate --view-id VIEW_ID --format json
# → {"data": {..., "viewId": "<新视图ID>", "sourceViewId": "<原视图ID>", "viewName": "..."}}
```
复制会保留源视图的 filter / sort / group / visible-fields / card / timebar 等全部配置;新视图的 viewId 与源视图独立。
## 这些字段不能用 `view update --config '{...}'` 写
下列字段必须用对应的专属子命令;如果错塞进 `view update --config`,CLI 会在 stderr 提示对应子命令并拒绝把字段当 view config 处理:
| 错误用法 | 应改用 |
|---|---|
| `--config '{"flags":1}'` | `view lock` / `view lock --off` |
| `--config '{"frozenColCount":2}'` | `view update frozen-cols --count N` |
| `--config '{"cellHeight":56}'` | `view update row-height --cell-height N` |
| `--config '{"rowHeightLevel":"tall"}'` | `view update row-height --cell-height N`(合法档位 32/56/88/128) |
| `--config '{"conditionalFormats":[...]}'` | `view update fill-color-rule --json '[...]'` |
## 典型工作流
### 配置一个"金额超阈值红色高亮"的 Grid 视图
```bash
BASE=baseXXX; TABLE=tblYYY; VIEW=viwGridZZ; FLD=fldAmount
# 1) 关键字段冻结,避免横向滚动看不到
dws aitable view update frozen-cols --base-id $BASE --table-id $TABLE --view-id $VIEW --count 1
# 2) 加大行高让数据更易读
dws aitable view update row-height --base-id $BASE --table-id $TABLE --view-id $VIEW --cell-height 56
# 3) 金额 > 100 的单元格上色
dws aitable view update fill-color-rule --base-id $BASE --table-id $TABLE --view-id $VIEW --json "[
{\"type\":\"cell\",\"formatFieldId\":\"$FLD\",\"format\":{\"color\":\"firstLine5\"},
\"filters\":[{\"fieldId\":\"$FLD\",\"symbol\":\"GT\",\"value\":100}]}
]"
# 4) 锁定视图,防止他人改坏
dws aitable view lock --base-id $BASE --table-id $TABLE --view-id $VIEW
```
### 复制一个"金牌客户"视图给销售团队
```bash
dws aitable view duplicate --view-id viw_VIP_template --new-name "金牌客户-华东区"
# 取返回里 data.viewId 进一步定制
```
### 排查"我设置了高亮规则为啥没生效"
```bash
# 看实际生效的 conditionalFormats
dws aitable view get fill-color-rule --view-id VIEW_ID --format json
# → 如果是 [] 说明上次写入失败;常见原因:color 用了 hex(必须 firstLineN)/ filter 用了 operator(必须 symbol)
```
@@ -0,0 +1,180 @@
# workflow — 自动化工作流管理
启停 / 查看 / 列出 Base 下的自动化工作流(在 AI 表格 Web 端配置的 "当 X 时自动 Y" 流程)。
适用场景:用户问 "停掉这个流程"、"看下都有哪些自动化流程"、"流程 X 的配置是什么"。
## 命令一览
| 命令 | 用途 |
|------|------|
| `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 |
| `workflow get` | 获取单个工作流详情(含 flowSchema 完整节点定义) |
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
> 当前**不支持通过 CLI 新建工作流**,请在 AI 表格 Web 端配置好后用 `workflow list` 拿到 ID 再启停。
## 命令详情
### workflow list — 列出工作流
```bash
dws aitable workflow list --base-id BASE_ID --format json
dws aitable workflow list --base-id BASE_ID --limit 50 --offset 100
```
| flag | 说明 |
|------|------|
| `--base-id` | 必填 |
| `--limit` | 可选,分页大小 `[1, 100]`,不传走服务端默认 20 |
| `--offset` | 可选,分页偏移量 `>= 0`,不传走服务端默认 0 |
返回结构:
```json
{
"data": {
"list": [
{
"flowId": "G-FLOW-XXXXXX", // ★ 注意字段名是 flowId
"name": "流程1",
"description": "当创建记录时,就更新记录",
"status": "RUNNING", // RUNNING / STOP
"creatorStaffId": "281493",
"lastModifier": { "name": "李普阳", "staffId": "281493" },
"gmtModified": 1780318540000,
"versionId": "G-FLOW-VER-XXXXXX",
"icons": ["..."], // 触发器+动作的图标
"isSubFlow": false,
"opPermissions": { "canEdit": true }
}
],
"recordCount": 1, // Base 下总数
"runningCount": 1 // RUNNING 状态的数量
}
}
```
**注意**:
- 标识字段服务端在 `list` 里叫 **`flowId`**,但在 `enable` / `disable` 出参里叫 **`workflowId`**。CLI `--workflow-id` 传任一即可(同值)。
- `status` 是字符串枚举:`RUNNING`(启用中)/ `STOP`(已禁用),**不是** boolean。
- `runningCount` 是当前 Base 下 status=RUNNING 的工作流数,方便快速判断「有几个流程在跑」。
### workflow get — 获取单个工作流详情
```bash
dws aitable workflow get --base-id BASE_ID --workflow-id WORKFLOW_ID --format json
```
| flag | 说明 |
|------|------|
| `--base-id` | 必填 |
| `--workflow-id` | 必填,对应 list 出参里的 `flowId` |
返回完整工作流配置:
```json
{
"data": {
"name": "流程1",
"namespace": "...",
"status": "RUNNING",
"versionId": "G-FLOW-VER-XXXXXX",
"versionNo": 14,
"versionStatus": "...",
"accessor": {...}, // 访问者信息
"corpId": "...",
"flowAttribute": {...}, // 流程顶层属性
"flowSchema": {...}, // ★ 流程节点定义(触发器/动作/分支等)
"gmtCreate": 1780317804000,
"gmtModified": 1780318540000
}
}
```
`flowSchema` 是完整的节点 DAG,结构因流程而异(条件触发器 vs 定时触发器、单分支 vs 多分支等)。agent 应按需读取关心字段,不要试图建静态 schema。
### workflow enable — 启用工作流
```bash
dws aitable workflow enable --base-id BASE_ID --workflow-id WORKFLOW_ID --format json
```
返回 `{workflowId, enabled: true}` —— **`enabled: true` 是动作确认,不是当前状态查询**。要确认真启用了,必须再 `workflow list` 看 `status` 是否变成 `"RUNNING"` 或 `runningCount` 是否加 1。
### workflow disable — 禁用工作流(高危)
```bash
dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes --format json
```
返回 `{workflowId, disabled: true}` —— 同样是动作确认。禁用后该工作流不再自动触发。
**风险**:直接影响业务自动化(如停掉「记录创建后自动发通知」会让通知断流)。建议:
- 操作前先 `workflow get` 留底当前配置
- 脚本场景显式传 `--yes`;交互场景让用户在 prompt 中再次确认
## 能力边界
| 能力 | 状态 |
|------|------|
| 列出工作流 | ✅ |
| 看工作流详情(含 flowSchema) | ✅ |
| 启用/禁用 | ✅ |
| 新建工作流 | ❌ 当前不支持,请去 AI 表格 Web 端 → 数据表 → 自动化 创建 |
| 修改工作流配置 | ❌ 同上,需 Web UI 编辑 |
| 删除工作流 | ❌ 同上 |
| 查看运行历史/执行日志 | ❌ 暂未开放 |
| 手动触发/单次运行 | ❌ 暂未开放 |
## 错误码速查
| 场景 | code | type | 备注 |
|------|------|------|------|
| `workflow-id` 不存在调 get | `GET_WORKFLOW_ERROR` | `SYSTEM_ERROR` | message 可能为 null,先 `workflow list` 核对 ID |
| `workflow-id` 不存在调 enable | `ENABLE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | message 含 "场域中不存在该 namespace" |
| `workflow-id` 不存在调 disable | `DISABLE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | 同上 |
| `--limit` < 1 或 > 100 | (CLI 层拦截) | — | `--limit 必须在 [1, 100] 范围内,got N` |
| `--offset` < 0 | (CLI 层拦截) | — | `--offset 必须 >= 0,got N` |
> 拿到 `*_WORKFLOW_ERROR / SYSTEM_ERROR` 时,先 `workflow list` 自查目标 ID 是否还存在、是否在当前 Base 下。
## 典型工作流
### 看看 Base 里有哪些自动化在跑
```bash
dws aitable workflow list --base-id BASE_ID --format json | jq '.data | {total: .recordCount, running: .runningCount, items: .list | map({name, status, flowId})}'
```
### 临时停掉某个流程做调试
```bash
# 1. 留底当前状态
dws aitable workflow get --base-id BASE_ID --workflow-id WORKFLOW_ID --format json > /tmp/wf-backup.json
# 2. 禁用
dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes --format json
# 3. 调试做完后重启
dws aitable workflow enable --base-id BASE_ID --workflow-id WORKFLOW_ID --format json
# 4. 确认 status=RUNNING
dws aitable workflow list --base-id BASE_ID --format json | jq '.data.list[] | select(.flowId == "WORKFLOW_ID") | .status'
```
### 批量关掉某个 Base 下所有 workflow(调试 / 迁移前清场)
```bash
for WF in $(dws aitable workflow list --base-id BASE_ID --limit 100 --format json | jq -r '.data.list[] | select(.status == "RUNNING") | .flowId'); do
dws aitable workflow disable --base-id BASE_ID --workflow-id "$WF" --yes --format json | jq .status
done
```
## 注意事项
- `--workflow-id` 接受的就是 `list` 返回里的 `flowId`(同值,CLI 屏蔽了服务端字段名差异)。
- enable / disable 出参里的 `enabled` / `disabled` 是 **动作确认 flag**,不是当前状态字段。要确认真生效请走 `workflow list` 查 `status`。
- `workflow get` 的 `flowSchema` 结构随触发器/动作类型变化,不要假设固定字段。
- 新建/修改/删除工作流目前必须在 AI 表格 Web 端(数据表页面 → 自动化)完成。
+71 -72
View File
@@ -35,13 +35,13 @@
dws doc --help
# 查看具体命令的完整参数说明
dws doc list --help
dws doc read --help
dws doc create --help
dws doc block insert --help
# 查看子命令组下的所有命令
dws doc block --help
dws doc permission --help
dws doc media --help
```
规则:
@@ -54,12 +54,10 @@ dws doc permission --help
> 命令名 → 单文件,按需加载子文档。复杂任务请优先看下方 §场景索引。
### 检索 / 阅读 / 元信息
### 阅读 / 元信息
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc search` | 关键字搜索 / 最近访问 | — | [`doc/doc-search.md`](./doc/doc-search.md) |
| `doc list` | 文件夹遍历 | — | [`doc/doc-list.md`](./doc/doc-list.md) |
| `doc info` | 文档元信息(含 contentType / extension) | `--node` | [`doc/doc-info.md`](./doc/doc-info.md) |
| `doc read` | 读取正文(markdown 或 jsonml) | `--node` | [`doc/doc-read.md`](./doc/doc-read.md) |
@@ -71,23 +69,31 @@ dws doc permission --help
| `doc update` | 整篇 / 段落级更新(markdown / jsonml) | `--node` `--mode` | [`doc/doc-update.md`](./doc/doc-update.md) |
| `doc block list/insert/update/delete` | 块级精细编辑(含 JSONML 节点操作) | `--node` (+ `--block-id`) | [`doc/doc-block.md`](./doc/doc-block.md) |
### 附件 / 评论 / 权限 / 导出
### 附件 / 评论 / 导出
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc media insert/download` | 附件 / 图片插入与下载 | `--node` `--file` 或 `--resource-id` | [`doc/doc-media.md`](./doc/doc-media.md) |
| `doc comment list/create/reply/create-inline` | 文档评论与划词评论 | `--node` (+ ...) | [`doc/doc-comment.md`](./doc/doc-comment.md) |
| `doc permission add/update/list` | 节点级授权 | `--node` `--user` `--role` | [`doc/doc-permission.md`](./doc/doc-permission.md) |
| `doc export` / `doc export get` | 在线文档导出 docx | `--node` `--output` | [`doc/doc-export.md`](./doc/doc-export.md) |
### 文件操作
### 文件操作(已迁移,以下命令 deprecated)
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc upload` | 上传文件到文档空间/知识库 | `--file` | [`doc/doc-file-ops.md`](./doc/doc-file-ops.md) |
| `doc download` | 下载已有文件(非 ALIDOC) | `--node` `--output` | [`doc/doc-file-ops.md`](./doc/doc-file-ops.md) |
| `doc copy` / `doc move` / `doc rename` / `doc delete` | 复制/移动/重命名/删除 | `--node` (+ ...) | [`doc/doc-file-ops.md`](./doc/doc-file-ops.md) |
| `doc folder create` | 创建文件夹 | `--name` | [`doc/doc-file-ops.md`](./doc/doc-file-ops.md) |
> **迁移提示**:文件管理命令已按产品领域架构重新归属,旧命令在过渡期内仍可使用,运行时会输出 deprecated 警告。
| 旧命令 | 推荐命令 | 详见 |
|--------|----------|------|
| `doc upload` | `dws drive upload` | [`drive.md`](./drive.md) |
| `doc download` | `dws drive download` | [`drive.md`](./drive.md) |
| `doc copy` | `dws drive copy` | [`drive.md`](./drive.md) |
| `doc move` | `dws drive move` | [`drive.md`](./drive.md) |
| `doc rename` | `dws drive rename` | [`drive.md`](./drive.md) |
| `doc delete` | `dws drive delete` | [`drive.md`](./drive.md) |
| `doc folder create` | `dws drive folder create` | [`drive.md`](./drive.md) |
| `doc file create` | `dws wiki node create --type <type>` | [`wiki.md`](./wiki.md) |
| `doc permission *` | `dws drive permission *` | [`drive.md`](./drive.md) |
| `doc list` | `dws drive list --workspace` / `dws wiki node list` | [`drive.md`](./drive.md) / [`wiki.md`](./wiki.md) |
| `doc search` | `dws drive search` / `dws wiki node search` | [`drive.md`](./drive.md) / [`wiki.md`](./wiki.md) |
### 排版规范 / JSONML 参考
@@ -105,7 +111,7 @@ dws doc permission --help
| 任务场景 | 一次性读取 | 主命令 |
|---------|-----------|--------|
| 定位 nodeId / URL 解析 / 目录遍历 | [`doc-search.md`](./doc/doc-search.md) + [`doc-list.md`](./doc/doc-list.md) + [`doc-info.md`](./doc/doc-info.md) | search |
| 定位 nodeId / URL 解析 | [`doc-info.md`](./doc/doc-info.md)(搜索请用 `dws drive search` / `dws wiki node search`;遍历请用 `dws drive list` / `dws wiki node list`) | `drive search` / `wiki node search` |
| 阅读已有文档 | [`doc-info.md`](./doc/doc-info.md) + [`doc-read.md`](./doc/doc-read.md) | read |
| 创建新文档 | [`doc-create.md`](./doc/doc-create.md) + [`doc-update.md`](./doc/doc-update.md)(写入管道)+ [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create |
| 创建文档且包含图片/截图/图文并茂 | [`doc-create.md`](./doc/doc-create.md) + [`doc-media.md`](./doc/doc-media.md) + [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create → media insert |
@@ -114,16 +120,17 @@ dws doc permission --help
| 插入富 block(callout / 分栏 / 表格) | [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | block insert |
| 上传图片 / 附件 | [`doc-media.md`](./doc/doc-media.md) | media insert |
| 评论 / 划词评论(含 @人) | [`doc-comment.md`](./doc/doc-comment.md)(+ `dws contact user search` 取 mention 用 userId) | comment create |
| 文档分享 / 节点级权限 | [`doc-permission.md`](./doc/doc-permission.md) | permission add |
| 文档分享 / 节点级权限 | [`drive.md`](./drive.md)(已迁移:`dws drive permission add/update/list/remove`) | `drive permission` |
| 导出 PDF / DOCX | [`doc-info.md`](./doc/doc-info.md) + [`doc-export.md`](./doc/doc-export.md) | export |
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`doc-file-ops.md`](./doc/doc-file-ops.md) + [`doc-info.md`](./doc/doc-info.md)(download 前判 contentType) | upload / download / move / copy / rename |
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`drive.md`](./drive.md)(已迁移:`dws drive upload/download/copy/move/rename/delete`) | `drive *` |
## 意图判断
用户说"找文档/搜文档/最近文档":
- 搜索 → `search`
- 浏览 → `list`
- 全局搜索 → `dws drive search --query "<关键词>"`(聚合钉盘+文档空间)
- 空间内搜索 → `dws wiki node search --workspace <WS_ID> --keyword "<关键词>"`
- 遍历文件夹 → `dws drive list --workspace <WS_ID>` 或 `dws wiki node list --workspace <WS_ID>`
用户说"看文档/读内容/文档内容":
@@ -144,44 +151,42 @@ dws doc permission --help
用户说"建文件夹/新建目录":
- 创建 → `folder create`
- 创建 → `dws drive folder create`
用户说"上传文件/传文件/上传到文档/上传到知识库":
- 上传 → `upload`(需本地文件路径)
- 上传并转换 → `upload --convert`
- 上传 → `dws drive upload`(需本地文件路径)
用户说"下载/导出/下载到本地/导出文档/导出为Word/导出为docx/把文档导出来":
- **必须先判断目标文件类型**,再决定走 `export` 还是 `download`:
- 在线文档 (alidocs/adoc) → **`export`**(格式转换后导出为 docx)
- 已有文件(PDF、图片、附件、视频等非在线文档) → **`download`**(直接下载原始文件)
- 在线文档 (alidocs/adoc) → **`doc export`**(导出是内容层操作,仅对 adoc 有意义)
- 已有文件(PDF、图片、附件、视频等非在线文档) → **`dws drive download`**
- 判断方法:
1. 如果用户明确说了"导出文档"、"导出为Word/docx" → 直接走 `export`
2. 如果用户明确说了"下载PDF/图片/附件" → 直接走 `download`
3. 不确定时,先用 `info --node <ID>` 查询节点信息,根据返回的 `contentType` 字段判断:
- `contentType` 为 `ALIDOC` → 走 `export`
- `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等 → 走 `download`
1. 如果用户明确说了"导出文档"、"导出为Word/docx" → 直接走 `doc export`
2. 如果用户明确说了"下载PDF/图片/附件" → 直接走 `drive download`
3. 不确定时,先用 `drive info --node <ID>` 查询节点信息,根据返回的 `contentType` 字段判断:
- `contentType` 为 `ALIDOC` → 走 `doc export`
- `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等 → 走 `drive download`
> **严禁将"导出文档"直接路由到 `download`**。`download` 只能下载已有文件(原样下载),`export` 是将在线文档格式转换后导出为 docx,两者完全不同。
用户说"复制文档/拷贝文件/复制到":
- 复制 → `copy`(需文档 ID 或 URL + 目标位置)
- 复制 → `dws drive copy`
用户说"移动文档/搬到/移到/转移文件":
- 移动 → `move`(需文档 ID 或 URL + 目标位置)
- 移动 → `dws drive move`
用户说"重命名/rename/改名/改文档名/修改文档名称/修改文档标题/把这个文档叫做...":
- 重命名 → `doc rename`(需文档 ID 或 URL + 新名称)
- 只要意图是修改文档在列表和链接中展示的名称,统一路由到 `dws doc rename --node <DOC_ID_OR_URL> --name "新名称"`;不要走 `drive`、`doc update` 或重新 `doc create`。
- 重命名 → `dws drive rename --node <DOC_ID_OR_URL> --name "新名称"`
- 只有用户明确说"正文里的标题/章节标题/段落标题/H1 标题"时,才走 `block update`。
用户说"删除文档/删掉这个文件/移到回收站/丢掉这篇文档":
- 删除节点 → `delete`(危险操作,需确认;需文档 ID 或 URL)
- 删除节点 → `dws drive delete`
用户说"插入附件/上传附件到文档/往文档里加文件/加附件":
@@ -205,13 +210,14 @@ dws doc permission --help
用户说"给某人开权限/分享给某人/授权某文档/把这篇文档给 xxx 看":
- 新增权限 → `permission add`(需 `--node` + `--user` + `--role`)
- 修改权限 → `permission update`
- 查看谁有权限 → `permission list`
- 新增权限 → `dws drive permission add`
- 修改权限 → `dws drive permission update`
- 查看谁有权限 → `dws drive permission list`
- 移除权限 → `dws drive permission remove`
> **关键区分**:
>
> - "把**某篇文档**授权给某人" → `doc permission add`(节点级,包括「我的文档」下的文档都支持)
> - "把**某篇文档**授权给某人" → `drive permission add`(节点级,包括「我的文档」下的文档都支持)
> - "把**某个知识库**整体授权给某人" → `wiki member add`(容器级,但**「我的文档」个人空间不支持**)
> 补充:如果用户直接粘贴的是原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe;只有 probe 确认是 `adoc` / `file` / `folder` 后,才继续按下列意图执行。
@@ -226,7 +232,7 @@ dws doc permission --help
- "帮我看看这个文档" → `read`
- "这个文档的信息" → `info`
- "往这个文档追加内容" → `update --mode append`
- "把这个文档标题改成 X" / "这个文档改名为 X" → `rename`
- "把这个文档标题改成 X" / "这个文档改名为 X" → `dws drive rename`
- "把正文里的一级标题/章节标题改成 X" → `block update`
关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(钉盘文件管理)
@@ -236,14 +242,13 @@ dws doc permission --help
> 步骤性指引。"读哪些文件"组合参见上方 §场景索引;命令详细参数参见对应子文件。
```bash
# ── 工作流 1: 浏览并阅读文档 ──
dws doc list --format json # 1. 浏览根目录
dws doc list --folder <DOC_FOLDER_NODE_ID> --format json # 2. 进入子目录
dws doc info --node <DOC_ID> --format json # 3. 元信息(含 contentType)
dws doc read --node <DOC_ID> --format json # 4. 读 markdown 正文
# ── 工作流 1: 定位并阅读文档 ──
dws drive search --query "<关键词>" --format json # 1. 搜索定位 nodeId(或 wiki node search)
dws doc info --node <DOC_ID> --format json # 2. 元信息(含 contentType)
dws doc read --node <DOC_ID> --format json # 3. 读 markdown 正文
# ── 工作流 2: 创建文档(含分片自动写入)──
dws doc folder create --name "项目资料" --format json # 1. (可选) 文件夹
dws drive folder create --name "项目资料" --format json # 1. (可选) 创建文件夹
dws doc create --name "项目周报" --content-file /tmp/x.md \
--folder <DOC_FOLDER_NODE_ID> --format json # 2. 创建 + 写入
dws doc read --node <DOC_ID> --format json # 3. 回读校验(必须)
@@ -263,14 +268,14 @@ dws doc update --node <DOC_ID> --content-file /tmp/doc.json \
dws doc read --node <DOC_ID> --content-format jsonml # 3. 回读
# 担心被并发覆盖时,可加 --revision <N> 触发并发检查(详见 doc-update.md)
# ── 工作流 5: 上传 vs 插入附件 ──
dws doc upload --file ./report.pdf --folder <ID> # 上传作为独立文件
dws doc media insert --node <DOC_ID> --file ./report.pdf # 上传并作为附件块插入正文
# ── 工作流 5: 上传独立文件 vs 插入附件到文档正文 ──
dws drive upload --file ./report.pdf --folder <ID> # 上传作为独立文件(存储层)
dws doc media insert --node <DOC_ID> --file ./report.pdf # 上传并作为附件块插入正文(内容层)
# ── 工作流 6: 下载 vs 导出(先 info 判 contentType)──
dws doc info --node <NODE_ID> --format json # 必须先查 contentType
dws doc export --node <NODE_ID> --output ~/downloads/ # contentType=ALIDOC 走 export
dws doc download --node <NODE_ID> --output ~/downloads/ # contentType≠ALIDOC 走 download
dws doc export --node <NODE_ID> --output ~/downloads/ # contentType=ALIDOC 走 export(内容层)
dws drive download --node <NODE_ID> --output ~/downloads/ # contentType≠ALIDOC 走 drive download(存储层)
# ── 工作流 7: 评论 + 划词(@人 需 userId)──
dws contact user search --query "张三" --format json # 取 userId
@@ -279,38 +284,32 @@ dws doc block list --node <DOC_ID> --format json # 划词需先
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> \
--start 0 --end 10 --content "建议调整" --selected-text "原文" --format json
# ── 工作流 8: 权限授予(节点级)──
# ── 工作流 8: 权限授予(节点级,已迁移到 drive)──
dws contact user search --query "张三" --format json # 取 userId
dws doc permission add --node <DOC_ID> --user <uid1>,<uid2> --role EDITOR --format json
dws doc permission list --node <DOC_ID> --format json # 校验
dws drive permission add --node <DOC_ID> --user <uid1>,<uid2> --role EDITOR --format json
dws drive permission list --node <DOC_ID> --format json # 校验
# ── 工作流 9: 文件操作(复制/移动/重命名/删除)──
# 第一步:获取 nodeId(三种方式按场景选一,命中即停,不要冗余调用)
# 方式 A(优先):用户直接提供 URL / nodeId → 直接传 --node,跳过 search/list
# 方式 B:按关键字找:dws doc search --query "项目周报" --format json
# 方式 C:按文件夹遍历:dws doc list --folder <DOC_FOLDER_NODE_ID> --format json
# 第二步:执行(--node 支持 ID 或完整 URL;--folder 支持文件夹 nodeId 或 alidocs 文件夹 URL)
dws doc copy --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json # 异步任务
dws doc move --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
dws doc rename --node <DOC_ID_OR_URL> --name "新名称" --format json
# 删除是危险操作:必须先向用户展示「即将删除「文档名」到回收站」 → 用户确认 → 才加 --yes
dws doc delete --node <DOC_ID_OR_URL> --yes --format json
# ── 工作流 9: 文件操作(已迁移到 drive)──
# 第一步:获取 nodeId
# 方式 A(优先):用户直接提供 URL / nodeId → 直接传 --node
# 方式 B:按关键字找:dws drive search --query "项目周报" --format json
# 方式 C:按文件夹遍历:dws drive list --workspace <WS_ID> --format json
# 第二步:执行
dws drive copy --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
dws drive move --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
dws drive rename --node <DOC_ID_OR_URL> --name "新名称" --format json
dws drive delete --node <DOC_ID_OR_URL> --yes --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | `nodes[].nodeId` | read / info / update / copy / move / rename / block 操作的 --node |
| `list` | folder 类型的 `nodeId` | list 的 --folder, create / copy / move 的 --folder |
| `search` | 文档 `nodeId` / URL / `createTime` / `creatorUid` | read / info / update / copy / move / rename 的 --node;创建时间与创建者信息 |
| `drive search` / `wiki node search` | 文档 `nodeId` / URL | doc read / info / update 等所有 `--node` 入参 |
| `drive list` / `wiki node list` | `nodes[].nodeId` | doc read / info / update / block 操作的 --node |
| `create` | `nodeId` | update / block 操作的 --node |
| `folder create` | `nodeId` | create / list / upload / copy / move 的 --folder |
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
| `read --content-format jsonml` | `revision` | update --content-format jsonml 的 --revision(可选,并发检查时使用) |
| `upload` | `nodeId` / URL | 上传后文件的访问链接 |
| `download` | 本地文件路径 | 下载后的文件保存位置 |
| `copy` | `nodeId` / URL (异步,不保证返回) | 复制是异步任务,若任务未完成则不会返回新文档 ID;如需获取可稍后通过 list 查询目标文件夹 |
| `media insert` | `resourceId` | 附件已插入文档,可通过 block list 查看附件块 |
| `media download` | 附件下载链接 `downloadUrl` | 下载文档中的附件资源 |
| `block list` | attachment 块的 `resourceId` | media download 的 --resource-id |
@@ -319,7 +318,7 @@ dws doc delete --node <DOC_ID_OR_URL> --yes --format json
| `comment create-inline` | `commentKey` | comment reply 的 --comment-key |
| `block list` | `blocks[].element.id` | comment create-inline 的 --block-id |
| `block list` | `blocks[].element.paragraph.text` | 计算 create-inline 的 --start / --end 偏移量 |
| `contact user search` | `userId` | comment create/reply/create-inline 的 --mention;permission add/update 的 --user |
| `contact user search` | `userId` | comment create/reply/create-inline 的 --mention;drive permission 的 --user |
## 相关产品
@@ -2,13 +2,13 @@
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、JSONML normalize/validator 行为)
> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、JSONML validator 行为)
> 3. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — JSONML 范例(含 callout / 分栏 / 表格 / 标题等节点的完整命令)
> 4. [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md) — JSONML 节点结构字段定义
>
> **同任务常配合**:[`doc-update.md`](./doc-update.md)(整篇 overwrite / 末尾追加纯文本)/ [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(JSONML 复制范例)
> **改写已有文档优先 JSONML**:保真度最高、callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端默认 normalize + validate。详见 [`./style/doc-update-workflow.md` §1.3 编辑形态优先级](./style/doc-update-workflow.md)。
> **改写已有文档优先 JSONML**:保真度最高、callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 validator 兜底。详见 [`./style/doc-update-workflow.md` §1.3 编辑形态优先级](./style/doc-update-workflow.md)。
---
@@ -88,8 +88,7 @@ Flags:
--level int 标题级别 1-6 (配合 --heading,默认 1)
--element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串
--content-format string 输入格式: 默认为 element,可选 jsonml
--fix-jsonml 启用全部 JSONML 修复(含 JSON 语法修复 + 结构修复),推荐 agent 调用时使用
--no-fix-jsonml 关闭全部 JSONML 修复(跳过 JSON 语法修复和结构修复),用于排查原始错误
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
--index int 参照位置索引 (从 0 开始)
--where string 插入方向: before / after (默认 after)
--ref-block string 参照块 ID (优先级高于 --index)
@@ -116,8 +115,7 @@ Flags:
--level int 标题级别 1-6 (配合 --heading,默认 1)
--element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串
--content-format string 输入格式: 默认为 element,可选 jsonml
--fix-jsonml 启用全部 JSONML 修复(含 JSON 语法修复 + 结构修复),推荐 agent 调用时使用
--no-fix-jsonml 关闭全部 JSONML 修复(跳过 JSON 语法修复和结构修复),用于排查原始错误
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
```
> 使用 `--content-format jsonml` 时,`element` 中的 `uuid` **必须**等于 `--block-id`,否则报错。
@@ -169,17 +167,16 @@ dws doc block update --node DOC_ID --block-id UUID --content-format jsonml \
dws doc block delete --node DOC_ID --block-id UUID
```
> 关于自动修复 / 严格校验:默认会自动注入 uuid、把裸字符串包成 span/leaf;如要禁用,用 `--no-fix-jsonml`。如需同时启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),用 `--fix-jsonml`。文本结构定义见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)。
> 关于校验:CLI 不做结构修复,裸字符串、缺 uuid 等错误会被 validator 直接抦下。如需同时启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),用 `--fix-jsonml`。文本结构定义见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)。
## 关键说明
- **块类型**:paragraph、heading、blockquote、callout、columns、orderedList、unorderedList、table、sheet、attachment、slot。
- **快捷 vs --element**:`block insert` 优先使用 `--text` 或 `--heading` 快捷方式;复杂块类型(table、callout、columns 等)使用 `--element` JSON 或 `--content-format jsonml`。
- **简单内容追加**:建议用 [`./doc-update.md`](./doc-update.md) `--mode append`,不必走 block insert。
- **JSONML normalize + validator**(写入端默认行为):
- 缺 `uuid` 的 block 会自动注入;裸字符串自动包成 `["span",{"data-type":"text"},["span",{"data-type":"leaf"},"..."]]`;每条修复以 `[FIX]` 行输出。
- 结构错误会被 validator 拦下并返回带 path 的错误(如 `$[2][2]: paragraph child must be span wrapper, got raw string.`)。
- `--no-fix-jsonml` 关闭全部修复(normalize + JSON repair);`--fix-jsonml` 开启全部修复(含 JSON 语法修复),推荐 agent 调用。
- **JSONML validator**(写入端默认行为):
- 裸字符串、缺 uuid 等结构错误会被 validator 抦下并返回带 path 的错误(如 `$[2][2]: paragraph child must be span wrapper, got raw string.`)。
- `--fix-jsonml` 开启 JSON 语法修复,推荐 agent 调用。
- **图片插入**:插入图片走 [`./doc-media.md`](./doc-media.md) `media insert`(作为附件块),不走 block insert。
- **分割线**:用 [`./doc-update.md`](./doc-update.md) `--content "---" --mode append`,不走 block insert。
@@ -265,7 +262,7 @@ dws doc block insert --node <DOC_ID> --content-format jsonml --parent-block <UUI
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-update.md`](./doc-update.md)(整篇改写 / 纯文本追加)
- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、normalize/validator 行为)
- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、validator 行为)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(所有节点的可复制 JSONML 范例)
- [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构字段定义)
- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(callout 颜色 / 元素边界规范)
@@ -35,13 +35,12 @@ Flags:
--content string 文档初始内容(短文本字面量);传 - 表示从 stdin 读取
--content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用
--content-format string 内容格式: 默认为 markdown,可选 jsonml
--fix-jsonml 启用全部 JSONML 修复(含 JSON 语法修复 + 结构修复),推荐 agent 调用时使用
--no-fix-jsonml 关闭全部 JSONML 修复(跳过 JSON 语法修复和结构修复),用于排查原始错误
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
```
## 关键说明
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。若正文首行仍是与 `--name` 相同的一级标题,CLI 会自动移除并在 stderr 提示(仅精确匹配会被移除,其他一级标题不受影响)。
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
@@ -5,9 +5,9 @@
> **路由前置判断**:用户说「下载/导出」时**必须**先用 [`./doc-info.md`](./doc-info.md) `info --node <ID> --format json` 查 `contentType`:
> - `contentType` 为 `ALIDOC`(在线文档)→ **必须用 `export`**,禁止用 `download`
> - `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等(已有文件)→ 用 [`./doc-file-ops.md`](./doc-file-ops.md) `download`
> - `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等(已有文件)→ 用 `dws drive download`(详见 [`../drive.md`](../drive.md))
>
> `download` 只能下载**已有文件**(原样下载),`export` 是将**在线文档格式转换**后导出为 docx,两者完全不同。
> `drive download` 只能下载**已有文件**(原样下载),`export` 是将**在线文档格式转换**后导出为 docx,两者完全不同。
---
@@ -77,4 +77,4 @@ dws doc export get --job-id <JOB_ID> --format json
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-info.md`](./doc-info.md)(前置:判断 contentType=ALIDOC 才走 export)
- [`./doc-file-ops.md`](./doc-file-ops.md)(非 ALIDOC 文件用 download)
- [`../drive.md`](../drive.md)(非 ALIDOC 文件用 `dws drive download`)
@@ -4,7 +4,7 @@
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`../../url-patterns.md`](../../url-patterns.md) — 仅当用户原始 `alidocs` URL 需要 probe 时
>
> **同任务常配合**:[`doc-search.md`](./doc-search.md) / [`doc-list.md`](./doc-list.md)(先拿到 nodeId)/ [`doc-read.md`](./doc-read.md)(确认是 ALIDOC 后读正文)
> **同任务常配合**:`dws drive search` / `dws wiki node search`(先定位 nodeId)/ [`doc-read.md`](./doc-read.md)(确认是 ALIDOC 后读正文)
## 命令格式
@@ -59,7 +59,7 @@ Flags:
- `dentryUuid` 是 `alidocs` URL `/i/nodes/{dentryUuid}` 的最后一段,在 `doc` 场景中等价于可传入 CLI 的 `nodeId`;不要把它改写成数字 ID。
- `dentryId` 通常是纯数字,**不是** `doc` 的 `nodeId`,也不是 `doc --folder` 的目标文件夹 ID;不要把数字 `dentryId` 当作 `--node`、`--folder` 或 `--parent-id` 使用。
- `parentId` / `--parent-id` 不是 `doc` 命令参数;`doc` 里目标父文件夹统一使用 `--folder <folderNodeId或folderUrl>`,目标知识库使用 `--workspace <workspaceId或workspaceUrl>`。
- 如果上下文只有数字 `dentryId`,但用户要读、改、移动、复制、重命名文档,先通过 `doc search` / `doc list` / 用户提供的 `alidocs` URL 获取 `nodeId` / `dentryUuid`,不要用数字 `dentryId` 重试为父目录参数。
- 如果上下文只有数字 `dentryId`,但用户要读、改、移动、复制、重命名文档,先通过 `dws drive search` / `dws drive list` / 用户提供的 `alidocs` URL 获取 `nodeId` / `dentryUuid`,不要用数字 `dentryId` 重试为父目录参数。
## 处理流程
@@ -78,8 +78,8 @@ Flags:
| 方式 | 触发条件 | 操作 |
|------|----------|------|
| **A** | 用户**直接提供文档 URL 或 nodeId** | **直接传给 `--node`**,无需额外查询;优先使用此方式 |
| **B** | 用户给出关键字 / 文档名 | `dws doc search --query "<关键字>" --format json` 从返回的 `nodes[].nodeId` 提取 |
| **C** | 用户指向某个文件夹下的文档 | `dws doc list --folder <DOC_FOLDER_NODE_ID> --format json` 从返回中提取 |
| **B** | 用户给出关键字 / 文档名 | `dws drive search --query "<关键字>" --format json` 或 `dws wiki node search --workspace <WS_ID> --keyword "<关键字>"` 从返回中提取 nodeId |
| **C** | 用户指向某个文件夹下的文档 | `dws drive list --workspace <WS_ID> --format json` 或 `dws wiki node list --workspace <WS_ID>` 从返回中提取 |
> **关键节省**:方式 A 命中时,禁止再调 search/list "确认一下" —— 用户提供的 URL/nodeId 本身就是权威输入。同理,`--folder` 也支持 alidocs 文件夹 URL 直传,不要先 search 把 URL 解析成纯数字 ID 再传。
@@ -104,13 +104,13 @@ dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=749936706
`--folder` 参数同样支持 alidocs 文件夹 URL 或文档文件夹 nodeId。
不要把纯数字 `dentryId` 当成这里的 ID。需要父文件夹时,使用文件夹的 `nodeId` / `dentryUuid` / URL 传给 `--folder`;不能改用 `--parent-id`。如果上一步只拿到了 drive/chat 链路里的纯数字 `dentryId`、`spaceId` 或 `parent-id`,说明还没有拿到 doc 文件夹,应该省略 `--folder` 使用默认文档根目录,或先通过 `dws doc list/search` 找到文档文件夹 nodeId。
不要把纯数字 `dentryId` 当成这里的 ID。需要父文件夹时,使用文件夹的 `nodeId` / `dentryUuid` / URL 传给 `--folder`;不能改用 `--parent-id`。如果上一步只拿到了 drive/chat 链路里的纯数字 `dentryId`、`spaceId` 或 `parent-id`,说明还没有拿到 doc 文件夹,应该省略 `--folder` 使用默认文档根目录,或先通过 `dws drive search` / `dws drive list` 找到文档文件夹 nodeId。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `contentType` + `extension` | 选择 [`./doc-read.md`](./doc-read.md) / `dws sheet ...` / `dws aitable ...` / [`./doc-file-ops.md`](./doc-file-ops.md) 的下载/导出路径 |
| `contentType` + `extension` | 选择 [`./doc-read.md`](./doc-read.md) / `dws sheet ...` / `dws aitable ...` / `dws drive download`(非 ALIDOC 走存储层下载) |
| `nodeId` / `docUrl` | 后续所有 `--node` 入参 |
## 常用模板
@@ -145,6 +145,6 @@ dws doc info --node <WS_ID> # 错误
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-search.md`](./doc-search.md) / [`./doc-list.md`](./doc-list.md)(前置:拿 nodeId 的入口)
- `dws drive search` / `dws wiki node search`(前置:定位 nodeId 的搜索入口,详见 [`../drive.md`](../drive.md) / [`../wiki.md`](../wiki.md))
- [`./doc-read.md`](./doc-read.md)(contentType=ALIDOC + extension=adoc 的后续命令)
- [`../../url-patterns.md`](../../url-patterns.md)(用户原始 alidocs URL 的 probe 流程)
@@ -4,7 +4,7 @@
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> ⚠️ **图片插入硬规则**:
> - 图片来源如果是钉盘/文档空间中的文件,**必须先下载到本地**(`dws doc download --node <图片nodeId> --output /tmp/xxx.png`),再执行 `media insert`
> - 图片来源如果是钉盘/文档空间中的文件,**必须先下载到本地**(`dws drive download --node <图片nodeId> --output /tmp/xxx.png`),再执行 `media insert`
> - **禁止**把钉盘/文档节点 URL(如 `alidocs.dingtalk.com/i/nodes/...`)写进 Markdown `![](...)` 图片语法——这些是页面链接,不是可渲染的图片资源
> - 创建文档时需要图文并茂:先用 `doc create` 写入纯文本骨架,再对每张图片执行 `media insert`,最后用 `doc block list` 验证附件块存在
@@ -36,7 +36,7 @@ Flags:
### 关键说明
- `--mime-type` 可选,不指定时根据扩展名自动推断;支持常见文件类型(PDF、Office、图片、视频、压缩包等)。
- 与 [`./doc-file-ops.md`](./doc-file-ops.md) `doc upload` 的区别:`upload` 将文件上传到文档空间/知识库作为**独立文件**;`media insert` 将文件作为**附件块插入到文档正文中**。
- 与 `dws drive upload` 的区别:`drive upload` 将文件上传到文档空间/知识库作为**独立文件**;`media insert` 将文件作为**附件块插入到文档正文中**。
---
@@ -94,5 +94,5 @@ dws doc media download --node <DOC_ID> --resource-id <RESOURCE_ID>
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-block.md`](./doc-block.md)(block list 取 attachment 的 resourceId)
- [`./doc-file-ops.md`](./doc-file-ops.md)(独立文件上传:`doc upload`)
- [`../drive.md`](../drive.md)(独立文件上传:`dws drive upload`)
- [`./style/doc-style-guideline.md` §4.9 附件与图片](./style/doc-style-guideline.md)(图示与附件使用规范)
@@ -27,8 +27,7 @@ Flags:
--mode string 更新模式: overwrite=覆盖, append=追加 (必填)
--content-format string 内容格式: 默认为 markdown,可选 jsonml
--revision int 文档版本号(仅 --content-format jsonml 时生效,可选);传入后服务端做并发检查,版本不一致时返回 VersionConflict。不传则直接覆盖,不做并发检查
--fix-jsonml 启用全部 JSONML 修复(含 JSON 语法修复 + 结构修复),推荐 agent 调用时使用
--no-fix-jsonml 关闭全部 JSONML 修复(跳过 JSON 语法修复和结构修复),用于排查原始错误
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
--index int 插入位置(从 0 开始),仅在 mode=append 时生效。指定将内容插入到文档第几个 block 之前。不传时追加到末尾。block 的 index 可通过 doc block list 获取。插入成功后,该位置及之后所有 block 的 index 会依次 +1
```
@@ -52,7 +51,7 @@ Flags:
]}
```
> 默认模式下,CLI 会自动给缺 uuid 的 block 注入 uuid、把 `["p", {}, "hello"]` 之类的裸字符串自动包裹成上述 span/leaf 形式(每条修复都会以 `[FIX]` 行输出)。如需严格按原样发送,加 `--no-fix-jsonml`,结构错误会被 validator 直接拦下。如果输入来自 LLM 生成且可能有 JSON 语法错误(缺括号/逗号),加 `--fix-jsonml` 启用全部修复。
> CLI 不做结构修复——裸字符串、缺 uuid 等错误会被 validator 直接抦下。body 必须以 `["root", ...]` 为根节点,缺少会报错。如果输入来自 LLM 生成且可能有 JSON 语法错误(缺括号/逗号),加 `--fix-jsonml` 启用 JSON 语法修复。
**典型流程**(无损读改写):
@@ -52,7 +52,7 @@
- 表格用 `table → tr → tc`(无 th/td),表头底色用 `tc` 的 `"fill"` 属性
- 关键数据着色用 leaf 的 `"color"`(绿=好 / 红=风险)
- 状态标记用 leaf 的 `"highlight"`(黄=待确认、绿=完成、红=阻塞)
- uuid 可全部省略——CLI normalize 自动补充
- uuid 必须显式提供——CLI 不再自动补充
## ⚠️ JSONML 结构严格约束(生成时必须遵守)
@@ -96,16 +96,16 @@
## 核心规则
1. **每个 block 节点应有 uuid**:`["tag", {"uuid": "唯一ID"}, ...children]`
- insert 时可以自行生成任意唯一字符串,后端会自动分配正式 uuid
- insert 时必须提供 uuid(可自行生成任意唯一字符串,后端会自动分配正式 uuid)
- update 时 uuid **必须**与 `--block-id` 一致
- normalize **仅在 attrs 槽完全缺失**(如 `["p", "text"]`)时才补 uuid;如果你写了 `["p", {}, ...]`,normalize 会尊重这个空 attrs 不再补 uuid(这一行为保证 `doc read → doc update` 不污染原文档)
- uuid 必须显式提供,不再自动补充
2. **文本必须用 span + leaf 三层结构**,不要直接写裸字符串
- ✅ `["p", {"uuid": "x"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "hello"]]]`
- ❌ `["p", {"uuid": "x"}, "hello"]` — validator 会报错;默认模式下 CLI 会自动包成 ✅ 的形式并打印 `[FIX]` 行
- ❌ `["p", {"uuid": "x"}, "hello"]` — validator 会报错,请手动包成 ✅ 的形式
- ⚠️ `["p", {"uuid": "x"}, ["text", {}, "hello"]]` — `text` 是历史 inline tag,validator 不会报错,但建议改写为 ✅ 形式以与 `dws doc read --content-format jsonml` 的输出保持一致
3. **attrs 对象必须存在**(即使为空):`["p", {}, ...]` 不能省略 `{}`
> **自动修复 vs 严格模式**:CLI 默认 normalize 会把 ❌ 的裸字符串自动包成 ✅;如要 1:1 透传原始 JSONML(例如复现服务端报错),用 `--no-fix-jsonml`,此时 validator 会以 `JSONPath + Suggestion` 形式逐条报错。如果输入来自 LLM 且可能有 JSON 语法错误(缺括号/逗号),用 `--fix-jsonml` 启用全部修复。
> **严格模式(缺省)**:CLI 不做结构修复,裸字符串等错误会被 validator 以 `JSONPath + Suggestion` 形式逐条报错。如果输入来自 LLM 且可能有 JSON 语法错误(缺括号/逗号),用 `--fix-jsonml` 启用 JSON 语法修复。
## 段落 (p)
@@ -118,6 +118,11 @@ dws doc block insert --node <DOC_ID> --content-format jsonml \
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "new2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "bold": true}, "加粗"], ["span", {"data-type": "leaf"}, "普通"], ["span", {"data-type": "leaf", "italic": true}, "斜体"]]]'
# 多行文本(每行一个 p,同一 p 内的多个 span 不会换行)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "line1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "bold": true}, "第一行标题"]]]' \
--element '["p", {"uuid": "line2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第二行正文内容"]]]'
# 带链接(link 是与 text 并列的子节点)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "new3"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "请访问"]], ["a", {"href": "https://example.com"}, "链接文字"]]'
@@ -126,11 +131,73 @@ dws doc block insert --node <DOC_ID> --content-format jsonml \
**leaf 支持的格式属性**:
- `bold: true` — 加粗
- `italic: true` — 斜体
- `underline: true` — 下划线
- `underline: {"value": "single"}` — 下划线(value: `single`/`dash`/`wave`/`double`/`none`,可选 `color`)
- `strike: true` — 删除线
- `color: "#ff0000"` — 文字颜色
- `dstrike: true` — 双删除线
- `color: "#ff0000"` — 文字颜色(`#rrggbb` 格式)
- `highlight: "#ffff00"` — 高亮背景色
- `sz: 14` / `szUnit: "pt"` — 字号
- `sz: 14` / `szUnit: "pt"` — 字号(szUnit 默认 `"px"`,推荐显式写 `"pt"`)
`fonts: {"ascii": "Arial", "eastAsia": "SimHei"}` — 字体(四分区:ascii/hAnsi/cs/eastAsia,值必须使用 font-family 名称,见下方字体表)
- `vertAlign: "superscript"` — 上标(`"subscript"` 下标,`"baseline"` 基线)
- `spacing: 2` — 字间距(单位 pt)
**字体名称映射**(`fonts` 字段必须使用 font-family 值,不能写中文名):
| 用户说法 | font-family 值 | 用户说法 | font-family 值 |
|---------|---------------|---------|---------------|
| 宋体 | `SimSun` | 黑体 | `SimHei` |
| 微软雅黑 | `Microsoft YaHei` | 微软雅黑UI | `Microsoft YaHei UI` |
| 仿宋 | `FangSong` | 仿宋_GB2312 | `FangSong_GB2312` |
| 楷体 | `KaiTi` | 楷体_GB2312 | `KaiTi_GB2312` |
| 等线 | `DengXian` | 新宋体 | `NSimSun` |
| 宋体-简 | `SimSun SC` | 宋体-繁 | `SimSun TC` |
| 黑体-简 | `Heiti SC` | 黑体-繁 | `Heiti TC` |
| 华文宋体 | `STSong` | 华文黑体 | `STHeiti` |
| 华文楷体 | `STKaiti` | 华文仿宋 | `STFangsong` |
| 华文中宋 | `STZhongsong` | 华文行楷 | `STXingkai` |
| 华文隶书 | `STLiti` | 华文新魏 | `STXinwei` |
| 华文细黑 | `STXihei` | 华文琥珀 | `STHupo` |
| 苹方-简 | `PingFang SC` | 苹方-繁 | `PingFang TC` |
| 苹方-港 | `PingFang HK` | 冬青黑-简 | `Hiragino Sans GB` |
| 兰亭黑-简 | `Lantinghei SC` | 兰亭黑-繁 | `Lantinghei TC` |
| 凌慧体-简 | `LingWai SC` | 幼圆 | `YouYuan` |
| 思源黑体 | `Source Han Sans CN` | 思源宋体 | `Source Han Serif CN` |
| 思源等宽 | `Source Han Mono SC` | 思源黑体Regular | `Source Han Sans CN Regular` |
| 阿里普惠体2.0 | `"Alibaba PuHuiTi 2.0"` | 阿里普惠体3.0 | `"Alibaba PuHuiTi 3.0"` |
| 钉钉进步体 | `DingTalk JinBuTi` | Adobe仿宋 | `Adobe 仿宋 Std` |
| 方正小标宋_GBK | `FZXiaoBiaoSong-B05` | 方正小标宋简体 | `FZXiaoBiaoSong-B05S` |
| 方正黑体 | `FZHei-B01S` | 方正楷体 | `FZKai-Z03S` |
| 方正仿宋 | `FZFangSong-Z02S` | 方正仿宋_GBK | `FZFangSong-Z02` |
| PMingLiU | `PMingLiU` | — | — |
**英文字体**(font-family 值即为字体名):
`Arial` ・ `Calibri` ・ `Cambria` ・ `Centaur` ・ `Comfortaa` ・ `Comic Sans MS` ・ `Courier New` ・ `Franklin Gothic` ・ `Garamond` ・ `Georgia` ・ `Helvetica` ・ `Impact` ・ `Lora` ・ `Lucida Sans` ・ `Merriweather` ・ `Montserrat` ・ `Nunito` ・ `Oswald` ・ `Playfair Display` ・ `Roboto` ・ `Spectral` ・ `Times New Roman` ・ `Trebuchet MS` ・ `Verdana`
> **规则**:优先从上表匹配;用户指定的字体不在列表时,使用该字体在操作系统中的真实 font-family 名称(如"更纱黑体" → `Sarasa Gothic SC`)。
**leaf 组合示例**:
```json
["span", {"data-type": "leaf", "bold": true, "color": "#C62828", "sz": 16, "szUnit": "pt"}, "红色加粗大字"]
["span", {"data-type": "leaf", "strike": true, "color": "#9E9E9E"}, "已废弃内容"]
["span", {"data-type": "leaf", "vertAlign": "superscript"}, "[1]"]
["span", {"data-type": "leaf", "fonts": {"ascii": "Courier New", "eastAsia": "DengXian"}}, "等宽字体"]
```
**段落级排版属性**(写在 p/h1-h6 的 attrs 上):
- `jc: "center"` — 对齐(`left`/`center`/`right`/`both`/`justify`)
- `spacing: {"line": 1.5, "lineRule": "auto"}` — 行距(lineRule=auto 时 line 为倍数:1=单倍、1.5=1.5倍、2=双倍)
- `spacing: {"before": 12, "after": 8}` — 段前/段后间距(单位 pt)
- `ind: {"firstLine": 32}` — 首行缩进(≈ 2 中文字符)
- `ind: {"left": 96}` — 左缩进
**段落排版示例**:
```json
["p", {"uuid": "p1", "jc": "center"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "居中段落"]]]
["p", {"uuid": "p2", "spacing": {"line": 1.5, "lineRule": "auto"}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "1.5倍行距"]]]
["p", {"uuid": "p3", "spacing": {"line": 2, "lineRule": "auto", "before": 12, "after": 8}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "双倍行距+段前后间距"]]]
```
## 标题 (h1-h6)
@@ -155,9 +222,11 @@ dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format json
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "li1", "list": {"listId": "mylist1", "level": 0, "isOrdered": false}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "无序列表第一项"]]]'
# 有序列表项
# 有序列表项(仅第一项设 start,后续项不设,系统自动递增)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "li2", "list": {"listId": "mylist2", "level": 0, "isOrdered": true, "start": 1}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序列表第一项"]]]'
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "li2b", "list": {"listId": "mylist2", "level": 0, "isOrdered": true}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序列表第二项"]]]'
# 缩进子项(level: 1)
dws doc block insert --node <DOC_ID> --content-format jsonml \
@@ -220,8 +289,10 @@ dws doc block insert --node <DOC_ID> --content-format jsonml \
## 表格 (table)
> colsWidth 单位为 **pt**(页宽约 650pt)。如配合 `tblW: {"type": "pct"}` 则为百分比权重。
```bash
# 2行2列表格
# 2行2列表格(各列 200pt)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["table", {"uuid": "tb1", "colsWidth": [200, 200]}, ["tr", {"uuid": "tr1"}, ["tc", {"uuid": "tc1", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "标题A"]]]], ["tc", {"uuid": "tc2", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "标题B"]]]]], ["tr", {"uuid": "tr2"}, ["tc", {"uuid": "tc3", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp3"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "数据1"]]]], ["tc", {"uuid": "tc4", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp4"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "数据2"]]]]]]]'
```
@@ -239,14 +310,19 @@ dws doc block insert --node <DOC_ID> --content-format jsonml \
## 分栏布局 (columns)
分栏复用 table tag,通过 `sr: true` 区分。
分栏复用 table tag,通过 `sr: true` 区分。分栏的 `tc` 可设置 `fill`(背景色)和 `border`(边框)属性提升视觉效果。
```bash
# 两栏布局
# 两栏布局(带背景色)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["table", {"uuid": "col1", "sr": true, "colsWidth": [300, 300]}, ["tr", {"uuid": "coltr"}, ["tc", {"uuid": "coltc1"}, ["p", {"uuid": "colp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "左栏内容"]]]], ["tc", {"uuid": "coltc2"}, ["p", {"uuid": "colp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "右栏内容"]]]]]]'
--element '["table", {"uuid": "col1", "sr": true, "colsWidth": [300, 300]}, ["tr", {"uuid": "coltr"}, ["tc", {"uuid": "coltc1", "fill": "#EEF6FF", "vAlign": "top"}, ["p", {"uuid": "colp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "左栏内容"]]]], ["tc", {"uuid": "coltc2", "fill": "#FFF3E0", "vAlign": "top"}, ["p", {"uuid": "colp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "右栏内容"]]]]]]'
```
**分栏视觉属性**:
- `fill` — 单元格背景色(推荐淡色,如 `#EEF6FF` / `#FFF8E1` / `#F3E5F5`)
- `border` — 边框配置(可选)
- 分栏建议始终设置 `fill` 背景色,纯白底分栏视觉上与普通段落无异,读者无法感知分栏结构
## 嵌入块 (embed)
通用文件/iframe 嵌入。`embed` 是 void 块,仅含 attrs,无子节点。
@@ -374,7 +450,7 @@ dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format json
| 错误写法 | 问题 | 正确写法 |
|---------|------|---------|
| `["p", {}, "文字"]` | 裸字符串。validator 会报 `段落子节点不能是裸字符串`;默认 normalize 会自动包成右侧形式(打印 `[FIX]` 行) | `["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "文字"]]]` |
| `["p", {}, "文字"]` | 裸字符串。validator 会报 `段落子节点不能是裸字符串`,请手动包成右侧形式 | `["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "文字"]]]` |
| `["p", {}, ["text", {}, "文字"]]` | `text` 是历史 inline tag,validator 不报错但服务端实际渲染的 canonical 形式是 span/leaf;为与 `doc read` 输出一致,建议改写 | 同上,用 span + data-type |
| `["callout", {}, ...]` | 不存在 callout tag | `["container", {"subType": "colorBlocks", ...}, ...]` |
| `["list", {}, ...]` | 不存在 list tag | `["p", {"list": {...}}, ...]` |
@@ -30,28 +30,23 @@ JSONML 是文档内容树的序列化格式:
- 第二个元素为文档级属性对象(如 `sectPr` 页面设置),可选
- 后续元素为块级节点(每个 block 节点应带 `uuid`)
全量覆写(overwrite)时,CLI 接受三种 body 形态:
全量覆写(overwrite)时,CLI 要求 body 必须以 `["root", ...]` 为根节点:
1. `["root", {sectPr}, ...blocks]` — 服务端 canonical 形式,`doc read` 输出
2. `[blocks, ...]` — 纯块数组(缺少 root,validator 会 warn 但不阻断)
3. 单个 `[tag, ...]` — 当作单 block,validator warn
2. `["root", {}, ...blocks]` — 无页面设置时用空 attrs
## CLI 行为概览(validator + normalize)
## CLI 行为概览(validator)
写入端(`doc create/update`、`block insert/update`)默认会走 **normalize → validate** 两步:
写入端(`doc create/update`、`block insert/update`)走 **validate** 一步,不做结构修复:
| 行为 | 缺省 | `--fix-jsonml` | `--no-fix-jsonml` |
|------|------|----------------|-------------------|
| JSON 语法修复(括号/逗号补全) | ✗ | ✓(打印 `[FIX]`) | ✗ |
| 解包单 block 为 body | ✓ | ✓ | ✗ |
| **attrs 槽完全缺失**时补 `attrs` + `uuid` | ✓ | ✓ | ✗ |
| 裸字符串 → `span/text + span/leaf` 自动包裹 | ✓(打印 `[FIX]`) | ✓(打印 `[FIX]`) | ✗ |
| validator 阻断(HasErrors → 拒绝发送) | ✓ | ✓ | ✓ |
| validator 警告(warnings → 仅 stderr) | ✓ | ✓ | ✓ |
| 行为 | 缺省 | `--fix-jsonml` |
|------|------|----------------|
| JSON 语法修复(括号/逗号补全) | ✗ | ✓(打印 `[FIX]`) |
| validator 阻断(HasErrors → 拒绝发送) | ✓ | ✓ |
| validator 警告(warnings → 仅 stderr) | ✓ | ✓ |
| root 校验(仅 doc create/update) | ✓ | ✓ |
> **uuid 注入的边界**:仅当 attrs 槽**完全缺失**(如 `["p", "text"]`)才补 `attrs` + `uuid`。当生产者已显式给出 `attrs`(哪怕是 `{}` 或不含 uuid),normalize 一律不再补 uuid —— 视为生产者明确意图。这避免 `doc read → doc update` 回灌时污染原文档中未修改的节点(真实文档中常见 `["h1", {}, ...]` 形态)。
三态设计:缺省 = 结构修复 ON + JSON 语法修复 OFF;`--fix-jsonml` 全开(含 JSON repair,推荐 agent 调用);`--no-fix-jsonml` 全关(用于排查原始错误)。校验始终执行,不可跳过。
> `doc create/update` 要求 body 必须以 `["root", {attrs?}, ...blocks]` 为根节点。缺少 root 会报错而非自动包装。`doc block insert/update` 不要求 root。
报错格式(面向 agent):
@@ -89,12 +84,12 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
| `strike` | `boolean` | `true` / `false` | 单删除线 |
| `dstrike` | `boolean` | `true` / `false` | 双删除线(独立于 strike) |
| `underline` | `object` | `{value, color?}` | 下划线。value: `"single"` \| `"dash"` \| `"wave"` \| `"double"` \| `"none"` |
| `color` | `string` | CSS 颜色 | 文字颜色,如 `"#ff0000"` |
| `color` | `string` | `"#rrggbb"` | 文字颜色 |
| `highlight` | `string` | CSS 颜色 | 文字高亮背景色 |
| `shd` | `object` | `{val?, color?, fill?}` | OOXML 底纹(Word 导入保留) |
| `sz` | `number` | 数值 | 字号,配合 `szUnit` |
| `szUnit` | `string` | `"px"` \| `"pt"` | 字号单位,默认 `"px"` |
| `fonts` | `object` | `{ascii, hAnsi, cs, eastAsia}` | OOXML 四分区字体 |
| `fonts` | `object` | `{ascii, hAnsi, cs, eastAsia}` | OOXML 四分区字体,值为 font-family 名称(如 `SimHei`),不能写中文名 |
| `vertAlign` | `string` | `"superscript"` \| `"subscript"` \| `"baseline"` | 上标/下标/基线 |
| `spacing` | `number` | 数值(pt) | 字间距 |
@@ -107,6 +102,17 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
["span", {"data-type": "text"},
["span", {"data-type": "leaf", "underline": {"value": "single", "color": "#ff0000"}, "sz": 14, "szUnit": "px"}, "红色下划线"]
]
["span", {"data-type": "text"},
["span", {"data-type": "leaf", "strike": true, "color": "#9E9E9E"}, "删除线灰字"]
]
["span", {"data-type": "text"},
["span", {"data-type": "leaf", "fonts": {"ascii": "Arial", "eastAsia": "SimSun"}, "sz": 12, "szUnit": "pt"}, "指定字体"]
]
["span", {"data-type": "text"},
["span", {"data-type": "leaf"}, "H"],
["span", {"data-type": "leaf", "vertAlign": "subscript"}, "2"],
["span", {"data-type": "leaf"}, "O"]
]
```
### 历史/兼容形式
@@ -115,7 +121,7 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
|------|-----------|--------|------|
| `["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "x"]]` | ✓ canonical | ✓ | ✅ 新内容首选 |
| `["text", {marks}, "x"]` | ✓(`text` 在 inline 白名单中) | ✓(兼容) | ⚠️ 历史 inline tag。`doc read` 不会输出这种形式;如需复制粘贴回写、保持与现有内容一致,建议改写为 canonical |
| `"raw string"` 作为 block 子节点 | ✗ 报错 `段落子节点不能是裸字符串` | — | 不要直接写。默认 normalize 会自动包成 canonical(打印 `[FIX]`) |
| `"raw string"` 作为 block 子节点 | ✗ 报错 `段落子节点不能是裸字符串` | — | 不要直接写。validator 会报错,请手动包成 canonical 形式 |
> Marks 表的属性集对 canonical 的 leaf 和 legacy 的 text 都适用;差别仅在承载位置(leaf 的 attrs vs text 的 attrs)。
@@ -133,9 +139,9 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
- **tag**: `"p"`
- **attrs**(全部 optional):
- `jc?: "left" | "center" | "right" | "both" | "distribute" | "justify"` — 对齐
- `ind?` — 缩进(单位 **twips**,1 twip = 1/1440 英寸)
- `ind?` — 缩进
- `left?: number`, `right?: number`, `firstLine?: number`, `firstLineChars?: number`, `hanging?: number`
- `spacing?` — 行间距(单位 **twips**;`line` 字段:`lineRule=auto` 时 1/240 行,否则 twips)
- `spacing?` — 行间距(`lineRule=auto` 时 `line` 为倍数:1=单倍、1.5=1.5倍、2=双倍;`before`/`after` 单位 pt)
- `line?: number`, `before?: number`, `after?: number`
- `lineRule?: "atLeast" | "auto" | "exact"`
- `shd?: {val?, fill?, color?}` — 底纹
@@ -148,7 +154,8 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
```json
["p", {"uuid": "p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "普通段落"]]]
["p", {"uuid": "p2", "jc": "center"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "居中段落"]]]
["p", {"uuid": "p3", "ind": {"firstLine": 420}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "首行缩进段落"]]]
["p", {"uuid": "p3", "ind": {"firstLine": 32}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "首行缩进段落"]]]
["p", {"uuid": "p4", "spacing": {"line": 1.5, "lineRule": "auto"}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "1.5倍行距"]]]
```
### heading(标题)
@@ -184,7 +191,7 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
- `isTaskList?: boolean` — 是否任务列表,默认 `false`
- `isChecked?: boolean` — 任务是否完成(仅 isTaskList=true 时有意义)
- `isCanceled?: boolean` — 任务是否取消
- `start?: number` — 有序列表起始序号(≥1)。validator 报错:`必须 ≥1`
- `start?: number` — 有序列表起始序号(≥1)。**仅在列表第一项设置**,后续项不设置此字段(系统自动递增)。validator 报错:`必须 ≥1`
- `listStyleType?: string` — 样式类型(31 种预设)
- `hideSymbol?: boolean` — 隐藏列表符号
- **示例**:
@@ -193,7 +200,9 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
["p", {"uuid": "li1", "list": {"listId": "abc", "level": 0, "isOrdered": false}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "无序列表项"]]]
["p", {"uuid": "li2", "list": {"listId": "def", "level": 0, "isOrdered": true, "start": 1}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序列表项"]]]
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序第一项"]]]
["p", {"uuid": "li2b", "list": {"listId": "def", "level": 0, "isOrdered": true}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序第二项(不设 start,自动编号 2)"]]]
["p", {"uuid": "li3", "list": {"listId": "ghi", "level": 1, "isOrdered": false}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "二级缩进"]]]
["p", {"uuid": "li4", "list": {"listId": "jkl", "level": 0, "isTaskList": true, "isChecked": false}},
@@ -221,7 +230,10 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
- **tag**: `"table"`
- **attrs**:
- `colsWidth: number[]` — 列宽(语义必传),单位 **px**。缺失时 validator 警告 `table 应提供 colsWidth`
- `colsWidth: number[]` — 列宽(语义必传)。缺失时 validator 警告 `table 应提供 colsWidth`
- 默认模式:值为各列绝对宽度,单位 **pt**(如 `[325, 325]` 总和≈页宽 650pt)
- 比例模式(配合 `tblW: {"type": "pct"}`):值为百分比权重(如 `[33.3, 33.3, 33.4]` 总和=100)
- `tblW?: {w?: number, type?: string}` — 表格宽度模式。`type: "pct"` 时 colsWidth 按比例解析
- `sr?: boolean` — `true` 表示这是分栏布局(columns),不是普通表格
- `jc?: string` — 对齐(源码注释"目前无消费")
- **children**: `["tr", ...]` 行节点
@@ -293,7 +305,7 @@ Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your tex
- **tag**: `"table"`(复用 table tag,通过 `sr: true` 区分)
- **attrs**:
- `sr: true` — 固定标识
- `colsWidth?: number[]` — 各列宽度
- `colsWidth?: number[]` — 各列宽度(pt),前端按比例换算为页面宽度
- `spacing?: number` — 栏间距
- **children**: 单个 `["tr", ...]`,内含多个 `["tc", ...]`
- **示例**:
@@ -660,7 +672,7 @@ block tag 也可出现在 inline 上下文(如 `img` 既是 block 又是 inlin
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "列表示例"]]],
["p", {"uuid": "li1", "list": {"listId": "l1", "level": 0, "isOrdered": true, "start": 1}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第一项"]]],
["p", {"uuid": "li2", "list": {"listId": "l1", "level": 0, "isOrdered": true, "start": 2}},
["p", {"uuid": "li2", "list": {"listId": "l1", "level": 0, "isOrdered": true}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第二项"]]],
["p", {"uuid": "li3", "list": {"listId": "l1", "level": 1, "isOrdered": false}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "子项"]]],
@@ -687,11 +699,11 @@ block tag 也可出现在 inline 上下文(如 `img` 既是 block 又是 inlin
## 设计要点
1. **Canonical 文本是 span/leaf**:每段文字 = `["span", {"data-type":"text"}, ["span", {"data-type":"leaf", ...marks}, "..."]]`。legacy `["text", {marks}, "..."]` 仍被接受但不建议新写。
2. **裸字符串作 block 子节点违法**:validator 报错;默认 normalize 自动包成 canonical 并打印 `[FIX]`;可用 `--no-fix-jsonml` 关闭。
3. **每个 block 必带 `uuid`**:手写 JSONML 时建议每个 block 自带 `uuid`(base32 alphanumeric,dws CLI 用 `dws` 前缀)。写入端 normalize **仅在 `attrs` 槽完全缺失(如 `["p", "text"]`)** 时自动补一个;只要 `attrs` 槽存在(即使是 `{}`),normalize 不再补 uuid —— 这保证 `doc read → doc update` 回灌不会污染原文档节点。
2. **裸字符串作 block 子节点违法**:validator 报错,请手动包成 canonical 形式。
3. **每个 block 必带 `uuid`**:手写 JSONML 时建议每个 block 自带 `uuid`(base32 alphanumeric,dws CLI 用 `dws` 前缀)。
4. **扁平列表**: 列表不嵌套,通过 `listId` + `level` 表达层级。
5. **属性装饰**: blockquote / list / footnote 不是独立 tag,是 paragraph 的属性。
6. **Void 节点**: hr / code / img / card / toc / embed / onlineVideo 在构造时不需要传子节点;服务端返回的真实文档中这些节点**可能包含内部配置数据子节点**,解析时应兼容。
7. **columns = table + sr:true**: 分栏复用表格结构。
8. **card 轻引用**: body 中只存 cardType + metadata.id,重数据在 parts 层。
9. **root 节点**: 服务端返回的完整 body 以 `["root", {sectPr...}, ...blocks]` 包裹,写入时可直接使用此格式或纯块列表格式。
9. **root 节点**: 服务端返回的完整 body 以 `["root", {sectPr...}, ...blocks]` 包裹。`doc create/update` 写入时必须以 root 为根节点,缺少会报错。`doc block insert/update` 不要求 root。
@@ -45,7 +45,7 @@
| 项目 | 要求 |
|------|------|
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题(若重复,CLI 会自动移除与 `--name` 相同的首行 H1 并提示) |
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
@@ -75,13 +75,171 @@
---
## 设计规划(JSONML 起稿前必做)
判定走 JSONML 路径后,**禁止立即动手写 JSONML**。先完成以下两阶段规划,各自产出一个持久文件作为后续阶段的锚点。
### 设计原则(全程生效)
1. **结构即信息** — 标题层级、表格 vs 分栏 vs 列表、callout 位置都应编码内容逻辑。问自己:“去掉这个结构元素,读者会丢失信息或体验变差吗?”— 丢失信息则必保留;不丢失信息但能提升可读性或美观度(如分割线分隔章节、分栏对比排版)也应保留;既不携带信息也不提升体验的装饰元素才删除。
2. **视觉层级引导阅读** — 每一屏必须让读者瞥一眼就能回答:“这块最重要的是什么?”字号/粗体/颜色形成明确梯度:标题 > 重点数据 > 正文 > 辅助信息。
3. **克制产生质感** — 遵循 60-30-10 配色比例:60% 中性底色(白/浅灰)、30% 辅助色、10% 强调色。多色系共存时需保持**同等饱和度**并各有语义角色(如淡蓝=信息、淡黄=提示、淡红=风险),同一色系内深浅变化自由。callout 不超过 2 个。
4. **设计先于执行** — 从规划阶段起每个结构块的样式就已确定,执行时(无论直接 JSONML 还是脚手架精修)只是落地已有设计,不是边写边想。
5. **同类同色、一色多阶** — 同类信息必须使用相同色系;单一色系按元素角色展开为深/中/浅/极浅四级(标题文字用深色、强调用中色、高亮/表头用浅色、背景用极浅色),不要全篇只用一个 hex 值。
### Phase 1:RFC — 需求理解与设计方向
**目标**:明确“做什么”和“为什么这样做”,形成方向性锚点。
#### 1.1 需求提取(全量列出用户显式要求)
通读用户 prompt,抽取两类要求并编为清单:
**内容要求**:
- 标题、字数、章节划分
- 数据来源、受众
- 语气/风格(如“大气”“专业”“轻松”)
**样式要求**(每一条都必须在最终输出中体现,不得遗漏):
- 字体:映射为 font-family 名称(参照 cookbook 字体映射表),区分“全文字体”和“特定元素字体”
- 字号、行距、对齐、颜色
- 强调手段(加粗、高亮、配色…)
- 约束(如“每部分不省略”)
> 用户没有明确指定的维度(如未指定行距、未指定表格样式)由 Phase 2 补充设计决策。
#### 1.2 内容-表现适配(每个章节的内容适合用什么元素)
对每个章节回答:“这个内容的核心是什么类型的信息?”→ 选择最佳元素:
**块级结构元素**:
| 信息类型 | 首选元素 | 不适合 |
|----------|---------|--------|
| 多个同类实体对比(≥ 4 项或 ≥ 3 维度) | 彩色表头表格 | 纯文本段落 |
| 少量实体对比(2-3 项× 少量维度) | 分栏(每栏一个实体,可设边框/背景色) | 大宽表格 |
| 时间序列/流程(行程/步骤) | 有序列表 + 粗体时间标签 | 无序列表 |
| 单个结论/推荐/重要提示 | callout(“花大胆”的地方) | 普通段落 |
| 描述性文字(背景/说明) | 正文段落 + 关键词粗体 | 表格 |
| 分类列举(特色/亮点) | 无序列表 | 表格(数据不够多列时) |
| 数值强调(评分/价格/统计) | 加粗 + 着色 | 跳过不强调 |
| 引用原文(用户评价/网友点评/官方说明) | 引用块 | 普通段落 |
| 任务/待办清单 | checklist(`- [ ]`) | 普通列表 |
| 章节分隔/主题转换 | 分割线(`hr`) | 空行 |
| 板块内子区域分隔(同一单元格/容器内多个逻辑段) | hr 内部分隔(在 tc 或 container 内部使用) | 空行或留白 |
| 结构化元信息(人/时间/地点/属性清单) | 键值对表格(窄标签列 ~15-20% + 宽内容列) | 多行段落 |
| 分类标签/状态标记 | 标签元素(tag) | 纯文本标记 |
**行内强调元素**:
- **emoji** — 用于 callout 前缀、状态标记、H2/H3 标题前;不在普通段落和列表项中滥用
- **加粗/高亮/着色** — 强调关键数据和结论
- **highlight 色带** — 在标题 span 上设 `"highlight": "#浅色"` 形成轻量色条标记,比 callout 更轻,适合区分多个并列板块的主题色
- **灰色辅助文字** — 用浅灰色(如 `#979A9B`)标记示例/说明/占位文字,与正文形成明确的主次层级
- **图片** — 实景照片、截图、示意图能显著提升理解时使用
**分栏选型补充**:分栏栏数无上限,但推荐 2-4 栏(≥5 栏会比较拥挤),可设置边框和背景色。**建议设置 `fill` 背景色**(纯白底分栏视觉上与普通段落无异,读者不易感知分栏结构)。适合场景:
- 2-3 个同类实体并排展示(每栏一个实体,含标题 + 描述 + 关键数据)
- 轻量对比:“优点 vs 缺点”、“方案 A vs B”、“Day 1 vs Day 2”
- 网格布局:多次插入同栏数分栏可形成卡片网格(如 2×3、3×2),适合 4-9 个结构相同、内容等长的卡片式实体。**约束**:每个格子内容必须结构一致且长度相近,否则高低不齐会很丑;实体数不能整除栏数时不使用
例如:“5 个实体多维度对比” → **彩色表头表格**;“两组信息并排” → **分栏**;“6 个结构相同的卡片” → **2×3 分栏网格**。
#### 1.3 起稿策略选择
根据文档复杂度和上方适配结果,确定起稿路径:直接 JSONML / Markdown 脚手架 + JSONML 精修 / 直接 JSONML 分段构造。具体条件和流程见下方「起稿」节的策略表。
#### 落盘
将以上内容写入 `<name>-rfc.md`,包括:
- 需求摘要(内容要求 + 样式要求)
- 每章展现策略 + 选择理由
- 起稿策略
---
### Phase 2:Spec — 精确设计参数
**目标**:将 RFC 的方向决策转化为可直接执行的参数。
#### 2.1 视觉体系设计(确定全局设计变量)
在以下四个维度做出明确选择,每个选择都必须能解释为什么适合这篇文档:
**色彩**(选定主色后展开为色阶):
用户指定颜色时(如"蓝色""绿色"),不要全篇只用一个 hex 值。将其展开为 4 级色阶,按元素角色分配:
| 角色 | 用途 | 色阶要求 |
|------|------|----------|
| 深色 | 标题文字、重点数据 `color` | 白底上高对比可读 |
| 中色 | 正文强调、链接 `color` | 辨识度高但不抢标题 |
| 浅色 | highlight 色带、表头 fill | 底色柔和,上方深色文字可读 |
| 极浅 | container bgcolor、大面积背景 | 接近白色,仅提供区域感 |
**规则**:
- 深→浅的层级关系不可颠倒(不能用极浅色做标题文字、不能用深色做背景)
- 同类板块/同类标题必须使用完全相同的色阶组合,通过色彩的重复形成视觉韵律
- 不同类别可用不同色系区分(如:任务=蓝系、风险=红系、成果=绿系)
- 遵循 60-30-10 配色比例:60% 中性底色、30% 辅助色、10% 强调色;用户指定的颜色值优先
**字体梯度**(形成明确层级):
- 主标题(h2):字体 / 字号 / 粗体 / 颜色
- 正文:字体 / 字号 / 行距
- 强调文字:粗体 + 颜色或高亮
- 辅助信息(注释/来源):字号偏小 / 灰色
**表格风格**(有表格时):
- 表头:底色 + 文字色 + 是否粗体
- 单元格:默认对齐 / 字号
**视觉重心**(克制原则落地):
全篇选 1-2 处给予最强视觉处理(配色 callout / 彩色表头 / 分栏对比),其余元素保持朴素。不要处处强调 — 处处强调等于没有强调。
callout 可通过 `"showstk": true, "sticker": "图标名"` 配置顶部贴纸图标(如“灯泡”“火”“钉子”),增强语义标识。设置了 sticker 后,高亮块内首个段落不要再以 emoji 开头,避免紧邻的位置出现两个图标。
#### 落盘与回验
1. 将以上内容写入 `<name>-design.md`,包括:
- 逐章元素映射(用什么标签、什么属性)
- 色阶具体 hex 值
- 字体梯度参数
- 表格风格细节
2. **回验**:Read `<name>-rfc.md`,逐条确认:
- [ ] RFC 中每条需求在 Spec 中有对应实现
- [ ] 展现策略在 Spec 中有具体参数支撑
- [ ] 配色遵循 60-30-10 比例、callout ≤ 2 个、多色系饱和度一致且有语义角色
回验通过后进入下一节开始构造 JSONML。
---
## JSONML 起稿(命中判定时使用)
当判定为 JSONML 起稿时,在本地临时 JSON 文件中直接构造完整的 JSONML 文档树。路径:`/tmp/<name>.json`。
根据 RFC 中确定的起稿策略执行:
| 策略 | 条件 | 执行流程 |
|------|------|----------|
| **直接 JSONML** | 短文档(≤ 15 块级节点)且结构简单 | 在 `/tmp/<name>.json` 手写完整 JSONML 树 → `doc create --content-format jsonml` |
| **Markdown 脚手架 + JSONML 精修** | 长文档且结构较线性 | ① Markdown 建立内容骨架 → `doc create --content-format markdown` ② `doc read --node <id> --content-format jsonml --output /tmp/<name>.json` 拉回 JSONML(已含 uuid) ③ **Read `<name>-rfc.md` + `<name>-design.md`** 回顾规划 ④ 逐章对照执行结构变换 + 叠加样式 |
| **直接 JSONML 分段构造** | 长文档且含大量富结构 | 按章节分段构造 JSONML,每段写完校验通过后再写下一段,最后拼接 |
> **脚手架策略警示**:Markdown 无法表达分栏/callout/色彩表头,拉回的 JSONML 只有纯文本骨架。精修阶段不是“在现有结构上加色”,而是“参照 RFC/Spec 重组结构”。
> **MUST READ**:动手写 JSONML 前,必须先用 Read 工具读取 [doc-jsonml-cookbook.md](../format/doc-jsonml-cookbook.md) — 其中 §决策型文档骨架范例 有可直接复制修改的完整模板。
> 节点类型和属性的权威定义见 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md)。
### ⚠️ JSONML 降级约束
**禁止因一次校验失败就放弃 JSONML 降级为 Markdown。** 当用户需求已触发 JSONML 起稿判定时,JSONML 是实现其样式要求的首选路径。失败时的处理策略:
1. **校验报错** → 读取错误信息,定位具体节点,修复后重试
2. **JSON 语法错误** → 检查括号匹配、逗号、引号,修复后重试
3. **反复失败(≥3 次)** → 尝试简化结构(减少嵌套、拆分复杂节点)再试
4. **仍然失败** → 退化为「Markdown 脚手架 + JSONML 精修」路径(流程同上方策略表),并告知用户当前状况
> “由于 JSONML 结构复杂且容易出错,改用 Markdown” — 这不是合法降级理由。必须先充分重试,且降级后仍需通过精修补回样式。
### ⚠️ JSONML 结构严格约束(生成时必须遵守)
每个节点是一个 JSON 数组:`[tagName, attributes?, ...children]`
@@ -99,6 +257,10 @@
| 多余逗号 | `["p", {},]` | JSON 解析失败 |
| 缺少逗号 | `["p", {} ["span"]]` | JSON 解析失败 |
| 引号不匹配 | `["p", {"uuid": "abc}]` | JSON 解析失败 |
| 有序列表每项都设 `start:1` | `{"start":1}` 在每项重复 | 所有项编号重置为 1(显示为 a/a/a) |
| 列表 `level` 从 1 开始 | `"level": 1` 作为顶级 | 顶级列表项多一层缩进;建议:顶级 `"level": 0`,子级 `"level": 1` |
| 用 `fontFamily` 设字体 | `"fontFamily": "Arial"` | 校验报错;正确写法:`"fonts": {"ascii": "Arial", "eastAsia": "..."}` |
| 用多个 span “换行” | 同一 `p` 内放两个 `span` | 不会产生换行;每个换行必须是独立的 `p` 节点 |
### 基础结构
@@ -114,14 +276,20 @@
- 根节点固定 `"root"`(不是 `"body"`)
- `--name` 已是 H1,JSONML 从 `h2` 开始
- 表格结构是 `table → tr → tc`(无 `th`/`td`)
- uuid 可省略(CLI normalize 自动补)
- 分栏是 `table` + `"sr": true`,`tc` 建议设 `fill` 背景色
- 有序列表:仅第一项设 `"start": 1`,后续项不设 `start`(系统自动递增)
- 列表 `level` 建议从 0 开始:顶级项 `"level": 0`,子项 `"level": 1`,以此类推
- uuid 必须显式提供(CLI 不自动生成)
- **每行内容对应一个 `p` 节点** — 同一 `p` 内的多个 `span` 不会换行,只会横向拼接;需要换行时必须拆分为多个 `p`
### 视觉设计要点
构造时主动使用这些属性实现视觉效果:
- **文字着色**:leaf 上 `"color": "#hex"`、`"highlight": "#hex"`
- **highlight 色带**:标题 leaf 上 `"highlight": "#浅色"` 可形成色条效果(比 callout 更轻量的板块标记)
- **字号**:leaf 上 `"sz": 14, "szUnit": "pt"`
- **callout**:`["container", {"subType": "colorBlocks", "metadata": {"bgcolor": "#E8F5E9", "border": "left"}}, ...blocks]`
- **callout + sticker**:`["container", {"subType": "colorBlocks", "metadata": {"bgcolor": "#FEF3F3", "showstk": true, "sticker": "火"}}, ...blocks]`
- **表格单元格底色**:tc 上 `"fill": "#hex"`
### 写入
@@ -147,12 +315,12 @@ dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readba
- 只使用用户已提供或对话中已确认的正文素材。
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。与 `--name` 相同的首行一级标题会被 CLI 自动移除(stderr 有提示),但不要依赖这个兜底。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
- Markdown 草稿阶段**不要**写 callout / 分栏 / 附件——这些留到「创建后的精修」用 `doc block insert` 操作(style-guideline §1.3)。
- **图片素材闭环(硬规则)**:正文需求含图片/截图/图文并茂时,**禁止**在 Markdown 中写 `![](...)` 图片语法(包括真实存在的 alidocs URL)。正确做法:Markdown 只写文本骨架和图片占位说明(如 `📌 此处插入:xxx 产品截图`),创建文档后逐个执行 `dws doc media insert --node <nodeId> --file <本地图片路径>` 插入,最后用 `dws doc block list --node <nodeId>` 验证图片块存在。图片来源如果是钉盘文件,必须先 `dws doc download --node <图片nodeId> --output /tmp/xxx.png` 下载到本地再 insert。
- **图片素材闭环(硬规则)**:正文需求含图片/截图/图文并茂时,**禁止**在 Markdown 中写 `![](...)` 图片语法(包括真实存在的 alidocs URL)。正确做法:Markdown 只写文本骨架和图片占位说明(如 `📌 此处插入:xxx 产品截图`),创建文档后逐个执行 `dws doc media insert --node <nodeId> --file <本地图片路径>` 插入,最后用 `dws doc block list --node <nodeId>` 验证图片块存在。图片来源如果是钉盘文件,必须先 `dws drive download --node <图片nodeId> --output /tmp/xxx.png` 下载到本地再 insert。
## 创建写入
@@ -230,7 +398,7 @@ dws doc update --node <nodeId> --content-file /tmp/<name>-resume.md --mode appen
精修常见入口(**按 [doc-update-workflow.md §1.3](./doc-update-workflow.md) 优先级排序:JSONML 首选**):
- 单 block JSONML 精修(首选):`doc block list --node <id> --content-format jsonml --block-id <uuid>` 取子树 → `doc block update --node <id> --block-id <uuid> --content-format jsonml --element '[...]'` 写回(uuid 必须 == --block-id;写入端默认 normalize + validate,详见 [doc-update-workflow.md §4.4](./doc-update-workflow.md))
- 单 block JSONML 精修(首选):`doc block list --node <id> --content-format jsonml --block-id <uuid>` 取子树 → `doc block update --node <id> --block-id <uuid> --content-format jsonml --element '[...]'` 写回(uuid 必须 == --block-id;写入端默认执行 schema validate,详见 [doc-update-workflow.md §4.4](./doc-update-workflow.md))
- 整篇 JSONML 无损:`doc update --content-format jsonml --mode overwrite`(默认直接覆盖,适合一次改多处或改 root sectPr;担心并发覆盖时加 `--revision <N>` 触发并发检查)
- 插入附件 / 图片:`doc media insert`(无 JSONML 形态,直接走 element)
- element JSON 次选:`doc block insert` / `doc block update` 不带 `--content-format jsonml` 时按老接口 JSON 解析;仅在 JSONML 不支持某字段时使用
@@ -56,7 +56,7 @@
1. **读者**:谁打开这篇文档?读完要做什么动作(操作 / 选择 / 查参数 / 看推理)?
2. **唯一记忆点**:读者关掉文档后,最想让他记住的一句话是什么?这句话决定开头摘要 / callout 该写什么。
3. **形态**:按 §2.0 推荐格式列 + [doc-create-workflow.md §JSONML 起稿判定](./doc-create-workflow.md#jsonml-起稿判定) 决定路径。命中判定条件(决策型 / 含对比的知识沉淀型 / 用户意图关键词)→ **直接 JSONML 起稿**;未命中 → markdown 起稿 + 创建后精修。
3. **形态**:按 §2.0 推荐格式列 + [doc-create-workflow.md §JSONML 起稿判定](./doc-create-workflow.md#jsonml-起稿判定) 决定路径。命中判定条件(决策型 / 含对比的知识沉淀型 / 用户意图关键词)→ **直接 JSONML 起稿**(但必须先完成 [doc-create-workflow.md §设计规划](./doc-create-workflow.md#设计规划jsonml-起稿前必做) 的 4 步规划);未命中 → markdown 起稿 + 创建后精修。
写完自检时回看这三个答案:开头有没有兑现「记忆点」、形态有没有兑现「推荐格式」。两条任一不兑现,按 §8 自检表对应行动。
@@ -46,7 +46,7 @@ JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/
| 优先级 | 形态 | 适用 |
|--------|------|------|
| ① 首选 | `--content-format jsonml` | 保真度最高;callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套结构都能 1:1 round-trip;写入端有 normalize + validator 兜底(§4.4) |
| ① 首选 | `--content-format jsonml` | 保真度最高;callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套结构都能 1:1 round-trip;写入端有 validator 兜底(§4.4) |
| ② 次选 | `--content-format element`(JSON,老接口) | JSONML 不支持某个块字段时;或快速插入 callout / 分栏不想构造 JSONML 时;不保真改写正文 |
| ③ 兜底 | markdown(不带 `--content-format` 即默认)| 纯文本追加、整篇重排骨架;callout / 分栏 / 颜色 / 部分属性会被 markdown 还原过程丢失 |
@@ -106,7 +106,7 @@ JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/
## 四、改写路径详细
> **首选 JSONML(§4.4)**——保真度最高且 normalize/validator 兜底;本节其余路径(markdown / element)仅在 §1.3 列出的"次选 / 兜底"场景下使用。
> **首选 JSONML(§4.4)**——保真度最高且 validator 兜底;本节其余路径(markdown / element)仅在 §1.3 列出的"次选 / 兜底"场景下使用。
### 4.1 段落级 overwrite(markdown 兜底路径)
@@ -161,7 +161,7 @@ dws doc block insert --node <nodeId> --ref-block <BLOCK_ID> --where after --cont
### 4.4 JSONML 无损改写(**首选路径**)
> 改写已有文档**默认走本节**——保真度最高,callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 normalize + validator 兜底。其他路径(§4.1/4.2/4.3/4.5 markdown)仅在 §1.3 列出的"次选 / 兜底"场景下使用。
> 改写已有文档**默认走本节**——保真度最高,callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 validator 兜底。其他路径(§4.1/4.2/4.3/4.5 markdown)仅在 §1.3 列出的"次选 / 兜底"场景下使用。
两条子路径:
@@ -201,16 +201,15 @@ dws doc update --node <nodeId> --content-file /tmp/doc_modified.json \
> **并发安全模式(担心被并发覆盖时使用)**:如果担心多 agent 同时改这篇文档,可以把第 1 步 read 返回的 `revision` 通过 `--revision <N>` 透传给第 4 步:服务端会做并发检查,版本不一致返回 `VersionConflict`,此时回到第 1 步重读重写即可。普通单 agent 改写场景默认不传 `--revision`。
#### JSONML 写入端的 normalize 与 validator
#### JSONML 写入端的 validator
写入命令(`doc create/update` + `doc block insert/update`)默认按 **normalize → validate** 两步处理 JSONML:
写入命令(`doc create/update` + `doc block insert/update`)走 **validate** 一步,不做结构修复:
| 行为 | 缺省 | `--fix-jsonml` | `--no-fix-jsonml` |
|------|------|----------------|-------------------|
| JSON 语法修复(括号/逗号补全) | ✗ | ✓(打印 `[FIX]`) | ✗ |
| 注入缺失的 block `uuid` | ✓ | ✓ | ✗ |
| 裸字符串 → 包成 `["span",{"data-type":"text"},["span",{"data-type":"leaf"},"..."]]` | ✓(stderr 打印 `[FIX]`) | ✓ | ✗ |
| validator 阻断(HasErrors → 拒发) | ✓ | ✓ | ✓ |
| 行为 | 缺省 | `--fix-jsonml` |
|------|------|----------------|
| JSON 语法修复(括号/逗号补全) | ✗ | ✓(打印 `[FIX]`) |
| validator 阻断(HasErrors → 拒发) | ✓ | ✓ |
| root 校验(仅 doc create/update) | ✓ | ✓ |
报错格式(agent 友好):
@@ -219,11 +218,11 @@ $[2][2]: paragraph child must be span wrapper, got raw string.
Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your text>"]]
```
三态设计:
设计要点:
- 不传:结构修复 ON + JSON 语法修复 OFF + 校验 ON(推荐人工调用)
- `--fix-jsonml`:全部修复 ON(含 JSON 语法修复,推荐 agent 调用)
- `--no-fix-jsonml`:全部修复 OFF,校验仍 ON;用于排查原始错误
- 缺省为严格模式:不做结构修复,裸字符串、缺 uuid 等错误会被 validator 抦下。
- `doc create/update` 要求 body 必须以 `["root", ...]` 为根节点,缺少会报错。`doc block insert/update` 不要求 root。
- `--fix-jsonml`:启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),推荐 agent 调用。
**何时不走本节、改用 markdown**:纯文本追加章节(§4.2)、整篇按全新骨架重写(§4.5,且无富结构需要保留时)、只在乎"加一段文字"且确认目标段落无 callout / 分栏 / 颜色 / @人 / 附件。其余场景默认本节。
+119 -40
View File
@@ -10,6 +10,7 @@ dws drive --help
# 查看具体命令的完整参数说明
dws drive list --help
dws drive search --help
dws drive upload --help
dws drive download --help
```
@@ -30,7 +31,7 @@ Example:
dws drive list --limit 20
dws drive list --limit 20 --folder <dentryUuid> --order-by name --order asc
Flags:
--limit int 每页返回数量,默认 20,最大 100 (可选)
--limit int 每页返回数量,默认 20,最大 50 (可选)
--cursor string 分页游标,首次不传 (可选)
--order string 排序方向: asc|desc,默认 desc (可选)
--order-by string 排序字段: createTime|modifyTime|name (可选)
@@ -65,6 +66,43 @@ spaceType 筛选规则:
- `spaceType` — 空间类型(如 `orgSpace`)
- `nextToken` — 若不为空,表示还有更多空间可查询(仅企业空间)
### 搜索钉盘文件/文件夹/空间
按关键词在钉盘中搜索文件、文件夹或团队空间。不同于 `list`(需要明确的 spaceId/parentId 逐层遍历),`search` 用于不知道具体位置、只记得名称/关键词的场景。
```
Usage:
dws drive search [flags]
Example:
dws drive search --query "季度汇报"
dws drive search --query "合同" --target file --extensions pdf,docx
dws drive search --query "项目" --target space
dws drive search --query "方案" --created-from 1700000000000 --created-to 1710000000000
dws drive search --query "周报" --creator-uids 012345
dws drive search --query "报告" --limit 30 --cursor <pageToken>
Flags:
--query string 搜索关键词 (必填)
--target string 搜索目标: all(默认) | file | space (可选)
--file-types strings 按文件内容类型过滤,逗号分隔: alidoc,document,image,video,audio,archive (仅 target=file/all 生效)
--extensions strings 按文件扩展名过滤,不含点号,逗号分隔 (如 pdf,docx,adoc)
--creator-uids strings 按创建者用户 ID 过滤,逗号分隔
--created-from int 创建时间起始 (毫秒时间戳,含)
--created-to int 创建时间截止 (毫秒时间戳,含)
--modified-from int 修改时间起始 (毫秒时间戳,含)
--modified-to int 修改时间截止 (毫秒时间戳,含)
--limit int 每页返回数量(默认 10,最大 30)
--cursor string 分页游标,从上次返回的 nextCursor 获取 (可选)
```
搜索目标 (`--target`) 选择规则:
- `all`(默认):同时搜文件与空间,返回混合结果 — 不确定目标是文件还是空间时使用
- `file`:只搜文件 / 文件夹,支持 `--file-types` / `--extensions` 过滤 — 明确是找文件时使用
- `space`:只搜团队空间 — 明确知道空间名、需快速定位空间 spaceId/rootFolderId 时使用
返回结果中 `type` 字段区分:`SPACE`(空间)、`FILE`(普通文件)、`FOLDER`(文件夹)、`ALIDOC`(钉钉在线文档)。
> **提示**:结果按相关性排序,首页未命中时优先调整关键词 / 补充 `--file-types`/`--extensions` 缩小范围 / 加上时间范围,而非反复翻页。
### 获取文件元数据信息
```
@@ -85,8 +123,8 @@ Flags:
| extension | 文件类型 | 操作 | 命令 |
|-----------|---------|------|------|
| adoc | 在线文档 | 在线获取 Markdown 内容 | `dws doc read --node <fileId>` |
| axls | 在线表格 | 在线读取表格数据 | `dws sheet list` → `dws sheet range read` |
| able | 多维表格 | 在线查询记录 | `dws aitable table list` → `dws aitable record query` |
| axls | 在线表格 | 在线读取表格数据 | `dws sheet get-all-sheets` → `dws sheet get-range` |
| able | 多维表格 | 在线查询记录 | `dws aitable get-tables` → `dws aitable query-records` |
| 其他(pdf/docx/txt/png 等) | 普通文件 | **不支持在线分析**,需用户主动下载后本地查看 | `dws drive download` |
### 下载文件到本地
@@ -161,18 +199,27 @@ Flags:
用户说"我的文件/钉盘/网盘/云盘" → `list`
用户说"钉盘空间/团队文件/有哪些空间/空间列表/团队文件列表" → `list-spaces`
用户说"搜索钉盘文件/钉盘里找个文件/查找某个钉盘文件/钉盘中搜索" → `search`
用户说"文件详情/文件信息" → `info`
用户说"下载文件" → `download`
用户说"新建文件夹/创建目录" → `mkdir`
用户说"新建文件夹/创建目录" → `mkdir`(钉盘空间)/ `folder create`(文档空间)
用户说"上传文件/传文件到钉盘" → `upload`(必须使用此命令,自动完成三步流程)
用户说"复制文件/移动文件/搬到/移到" → 使用 `dws doc copy`/`dws doc move`(详见下方「复制/移动钉盘文件」工作流)
用户说"复制文件/移动文件/搬到/移到" → `copy` / `move`
用户说"重命名/改名" → `rename`
用户说"删除文件/删除文件夹/移到回收站" → `delete`(危险操作,需确认)
用户说"给文档授权/分享权限" → `permission add`
关键区分: drive(钉盘文件管理) vs doc(文档内容读写)
关键区分: drive(文件管理) vs doc(文档内容读写) vs wiki(空间管理)
**drive upload vs doc upload**: 用户提到"钉盘/网盘/我的文件"→ `drive upload`;提到"知识库/文档空间/workspace"→ `doc upload`;未明确目标时默认 `drive upload`
**drive search vs wiki node search**: 用户提到"钉盘/网盘/我的文件里搜" → `drive search`;提到"知识库/文档空间/workspace 里搜" → `wiki node search`;未明确目标时优先问明。
**钉盘文件复制/移动**: drive 本身没有 copy/move 命令,需使用 `dws doc copy`/`dws doc move` 实现(详见下方工作流)
**drive upload**: 文件上传统一走 `drive upload`。上传到知识库/文档空间时加 `--workspace` 参数。
**drive permission vs wiki member**: "给某篇文档/文件授权" → `drive permission add`(节点级);"给某个知识库整体加成员" → `wiki member add`(空间级)
**创建在线文档/表格/脑图**: drive 不支持创建文件,需走 `wiki node create --type <type>`(创建空节点)或 `doc create`(创建并写入内容)。
**导出文档/导出为Word**: 导出是内容层操作,走 `doc export`,不属于 drive。
## 核心工作流
@@ -201,57 +248,85 @@ dws drive upload --file ./报告.pdf --folder <dentryUuid> --format json
dws drive delete --node <dentryUuid> --yes --format json
```
## 复制/移动钉盘文件
## 文档空间管理命令
钉盘本身没有 copy/move 命令,需使用 `dws doc copy`/`dws doc move` 实现。
> 以下命令操作的是**文档空间**(知识库 / 我的文档),底层路由到 doc MCP server。
> 与钉盘命令(list / mkdir / upload 等)的区别:钉盘命令操作钉盘空间(spaceId 纯数字),文档空间命令操作知识库/我的文档(workspaceId 加密 string)。
> **注意:字段选择**:`drive list` 返回中有 `dentryId`(数字格式)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`(UUID 格式,如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)**作为 `--node` 和 `--folder` 的参数值。**禁止使用 `dentryId`(数字格式,如 `220335325118`),传入数字格式会导致命令失败。**
### 复制/移动/重命名文件
> **注意**:钉盘场域下,仅支持将文件复制/移动到文件夹下,不支持文档下嵌套文档。
```
Usage:
dws drive copy --node <ID> [--folder <TARGET>] [--workspace <WS>]
dws drive move --node <ID> [--folder <TARGET>] [--workspace <WS>]
dws drive rename --node <ID> --name "新名称"
Flags:
--node string 文档/文件 ID 或 URL (必填)
--folder string 目标文件夹 nodeId
--workspace string 目标知识库 ID
--name string 新名称 (仅 rename 必填)
```
> **字段选择**:`drive list` 返回中有 `dentryId`(数字格式)和 `fileId`(UUID 格式),**必须使用 `fileId`(UUID 格式)**作为 `--node` 和 `--folder` 参数值。
### 创建文件夹(文档空间)
```
Usage:
dws drive folder create --name "文件夹名"
Flags:
--name string 名称 (必填)
--folder string 父文件夹 nodeId
--workspace string 目标知识库 ID
```
### 权限管理(文档节点级)
> 仅适用于文档空间节点,不适用于钉盘文件。
```
Usage:
dws drive permission add --node <ID> --users uid1,uid2 --role READER
dws drive permission update --node <ID> --users uid1 --role EDITOR
dws drive permission list --node <ID>
dws drive permission remove --node <ID> --users uid1
Flags:
--node string 目标节点 ID 或 URL (必填)
--users string 用户 userId 列表,逗号分隔
--role string 角色: MANAGER / EDITOR / DOWNLOADER / READER
--limit int 返回成员数上限 (仅 list,默认 30,最大 200)
--filter-role string 按角色过滤 (仅 list)
```
> **注意**:`drive export` 不存在。导出仅对自研文档 (adoc) 有意义,属于内容层操作,应使用 `doc export`。
### 目标位置参数规则
| 目标位置 | 参数传递方式 | 前置步骤 |
|---------|-----------|---------|
| 未指定目标(默认) | `--folder <rootFolderId>` | 先 `dws drive list-spaces --space-type mySpace` 获取「我的文件」的 `rootFolderId` |
| 知识库空间根目录 | `--workspace <workspaceId>` | 无需额外步骤,直接传入 workspaceId |
| 知识库空间根目录 | `--workspace <workspaceId>` | 无需额外步骤 |
| 钉盘 space 根目录 | `--folder <rootFolderId>` | 先 `dws drive list-spaces` 获取目标 space 的 `rootFolderId` |
| 钉盘 space 下的子文件夹 | `--folder <fileId>` | 先 `dws drive list --space-id <spaceId>` 逐层浏览,获取目标文件夹的 `fileId`(dentryUuid 格式) |
| 钉盘 space 下的子文件夹 | `--folder <fileId>` | 先 `dws drive list --space-id <spaceId>` 逐层浏览 |
### 工作流示例
```bash
# ── 场景 默认: 用户未指定目标位置 → 复制/移动到「我的文件」根目录 ──
# 1. 获取源文件 dentryUuid
# ── 场景 默认: 复制/移动到「我的文件」根目录 ──
dws drive list --space-id <SPACE_ID> --format json
# 2. 获取「我的文件」个人空间的 rootFolderId
dws drive list-spaces --space-type mySpace --format json
# 3. 用「我的文件」的 rootFolderId 作为 --folder
dws doc copy --node <源文件dentryUuid> --folder <我的文件rootFolderId> --format json
dws drive copy --node <源文件dentryUuid> --folder <我的文件rootFolderId> --format json
# ── 场景 A: 复制钉盘文件到知识库空间根目录 ──
# 1. 获取源文件 dentryUuid
dws drive list --space-id <SPACE_ID> --format json
# 2. 直接传 workspaceId 即可
dws doc copy --node <源文件dentryUuid> --workspace <TARGET_WS_ID> --format json
# ── 场景 A: 复制到知识库空间根目录 ──
dws drive copy --node <源文件dentryUuid> --workspace <TARGET_WS_ID> --format json
# ── 场景 B: 移动钉盘文件到另一个钉盘 space 根目录 ──
# 1. 获取源文件 dentryUuid
dws drive list --space-id <SOURCE_SPACE_ID> --format json
# 2. 获取目标 space 的 rootFolderId
# ── 场景 B: 移动到另一个钉盘 space 根目录 ──
dws drive list-spaces --format json
# 3. 用 rootFolderId 作为 --folder(不需要传 --workspace)
dws doc move --node <源文件dentryUuid> --folder <目标space的rootFolderId> --format json
dws drive move --node <源文件dentryUuid> --folder <目标space的rootFolderId> --format json
# ── 场景 C: 复制钉盘文件到钉盘 space 下的子文件夹 ──
# 1. 获取源文件 dentryUuid
dws drive list --space-id <SOURCE_SPACE_ID> --format json
# 2. 浏览目标 space 找到目标文件夹的 fileId(dentryUuid 格式)
# ── 场景 C: 复制到钉盘子文件夹 ──
dws drive list --space-id <TARGET_SPACE_ID> --format json
# 若目标在更深层级,继续用 --folder 逐层浏览
dws drive list --space-id <TARGET_SPACE_ID> --folder <父文件夹dentryUuid> --format json
# 3. 用目标文件夹的 fileId 作为 --folder
dws doc copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --format json
dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --format json
```
## 上下文传递表
@@ -259,10 +334,13 @@ dws doc copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --for
| 操作 | 从返回中提取 | 用于 |
| ------------- | ---------------------------- | -------------------------------------------------------- |
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / mkdir / delete / list 的 --node 或 --folder;`doc copy/move` 的 --node 或 --folder |
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / mkdir / delete / list 的 --node 或 --folder;`drive copy/move` 的 --node 或 --folder |
| `list` | `spaceId` | info / download / mkdir / commit 的 --space-id |
| `list-spaces` | `rootFolderId` | `doc copy/move` 的 --folder(复制/移动到钉盘 space 根目录时) |
| `list-spaces` | `rootFolderId` | `drive copy/move` 的 --folder(复制/移动到钉盘 space 根目录时) |
| `list-spaces` | `spaceId` | list / info / download / mkdir / upload 的 --space-id |
| `search` | **`fileId`**(文件/文件夹结果) | info / download / delete 的 --node;list 的 --folder |
| `search` | `spaceId` / `rootFolderId`(空间结果) | list 的 --space-id;`drive copy/move` 的 --folder |
| `search` | `nextCursor` | search 的 --cursor(翻页) |
| `mkdir` | `fileId`(UUID 格式) | list 的 --folder |
> **重要**:`drive list` 返回结果中同时包含 `dentryId` 和 `fileId` 两个字段。所有需要传 `--node` 的命令(info / download / delete)必须使用 `fileId`(即 dentryUuid),**不要使用** `dentryId`。
@@ -273,6 +351,7 @@ dws doc copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --for
- 不传 `--space-id` 时默认使用「我的文件」空间
- 不传 `--folder` 时默认操作空间根目录
- `--folder` 只能使用父文件夹的 `dentryUuid`。不要把 `drive info` 返回的数字型 `dentryId` 当作父目录;`dentryId` 只用于 `chat message send --dentry-id`
- **`--limit` 最大值为 50**,禁止传入超过 50 的值(如 `--limit 100`)。用户要求超过 50 条时,应使用 `--limit 50` 配合 `--cursor` 分页查询,不要直接传大于 50 的值
- `--order-by` 支持: `createTime`、`modifyTime`、`name`
- **上传文件必须使用 `dws drive upload` 命令**,禁止使用 `upload-info` + `curl` + `commit` 三步手动流程
- `--file-name` 必须包含扩展名(如 `report.pdf`)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,271 @@
# 条件格式 (conditional format)
## 使用场景
### 条件格式
#### 强制走条件格式的触发词(硬约束)
当用户出现以下口语指令时,**强制**走 `cond-format create/update/delete`,**禁止**用 `range set-style` 写静态背景色/字体色代替:
- **颜色动作**:"标红 / 标黄 / 标绿 / 上色 / 染色 / 涂色"
- **视觉强调**:"高亮 / 突出 / 标记 / 标注 / 区分"
- **条件触发**:"重复的标出来 / 异常的圈出来 / 过期的染红 / 大于 X 的标黄 / 不达标的标红"
- **联动语义**:"颜色随数据变 / 联动 / 自动更新 / 改了数据颜色也跟着变"
- **数值可视化**:"数据条 / 色阶 / 渐变色 / 进度条样式"
**判断标准**:交付后 `cond-format list` 必须能返回该规则;否则视为违规。
> 如果用 `range set-style` 写静态背景色,源数据变化时颜色不会跟着变。典型反例:用户要求"过期单元格标红"时用静态填充——日期变化后颜色不再准确。
**大数据量优势**:当数据量 > 1000 行时,条件格式是首选——它由服务端自身渲染,不需要逐行调用 `range set-style`,性能远优于静态样式写入。
#### 意图判断
用户说"条件格式/条件样式/自动变色/满足条件时高亮/按条件设样式/条件格式规则":
- 查看已有条件格式 → `cond-format list`
- 查看指定规则详情 → `cond-format list --rule-id RULE_ID`
用户说"创建条件格式/设置条件格式/大于某值时标红/包含某文本时高亮/数据条/图标集/色阶/重复值高亮":
- 创建条件格式规则 → `cond-format create`
- `--condition` 为 JSON 对象,key 为条件类型,value 为条件参数
- `--cell-style` 为命中时的样式(适用于数值/文本/空值/错误/重复/公式/排名/平均值/标准差类型)
- `--data-bar-style` 为数据条样式(仅数据条类型时使用)
- 色阶和图标集类型不需要 `--cell-style`(样式内置在条件定义中)
用户说"修改条件格式/更新条件格式规则/改条件/改样式/改范围":
- 更新条件格式规则 → `cond-format update`
- 未传入的字段保持原有值不变
- 传入 `--condition` 会替换原有条件类型
用户说"删除条件格式/移除条件格式/取消条件格式":
- 删除条件格式规则 → `cond-format delete --yes`
- 删除不可恢复,执行前必须向用户确认,同意后才加 `--yes` 执行
- 规则已不存在时操作仍返回成功
#### 常见配置错误(必须注意)
- **创建后必须验证**:条件格式创建后必须调用 `cond-format list` 验证规则是否生效。如果验证发现规则未生效或配置不正确,应立即修复并重试
- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏也不要过度扩大
- **`backgroundColor` vs `fontColor` 的中文语义**:用户中文语境下的"标红/高亮/染色/标记"指**单元格背景色**,用 `backgroundColor`;"文字红/字体红/把字变红"才用 `fontColor`。默认无说明时选 `backgroundColor`
- **日期/空值比较必须防空**:用户说"过期的标红"时,公式必须排除空单元格,否则空白格也会被误判为过期而全表标红。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期)
- **公式引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 使用相对引用使公式随行变化,而非 `=$E$1<=TODAY()` 只比较一个格)
- **创建前必须确认列对应**:仅读表头不够——如果表头语义含糊,formula 里引用的列字母可能张冠李戴。建议先读 3-5 行数据样本(如 `range read --range "A1:Z5"`)确认列名对应的实际值和数据类型
#### 辅助列+条件格式两步走(高频致命错误防护)
**用户明确要求"辅助列+条件格式"两步走时,禁止用 `formulaCondition` 绕过**:
当用户说以下任意一种表达时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),**禁止**直接用一个 `formulaCondition` 公式一步完成:
- "**增加辅助列**,再/然后标记……"
- "**先计算/判断** XX **是否** YY,**再**标记……"
- "**新建一列**放结果,再用结果染色"
- 明确要求用"辅助列"、"辅助字段"、"判断列"、"标记列"
**正确做法(两步走)**:
```bash
# Step 1: 用 range update 在新列写判断公式(形成"是/否"辅助列)
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "H2:H100" \
--values '[["=IF(A2>B2,\"是\",\"否\")"],...]'
# Step 2: 基于辅助列值做条件格式(用 formulaCondition 引用辅助列)
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID \
--ranges '["A2:H100"]' \
--condition '{"formulaCondition":{"formula":"=$H2=\"是\""}}' \
--cell-style '{"backgroundColor":"#FFECEC"}'
```
**错误做法(一步走绕过辅助列)**:
```bash
# 虽然逻辑等价,但产物里缺辅助列 → 用户打开表格看不到"是/否"列
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID \
--ranges '["A2:H100"]' \
--condition '{"formulaCondition":{"formula":"=$A2>$B2"}}' \
--cell-style '{"backgroundColor":"#FFECEC"}'
```
**为什么禁止一步走**:用户明确要求辅助列是有**业务意图**的——让人肉眼能在表里看到判断结果列;条件格式只是视觉辅助。一步 `formulaCondition` 虽然效果对了,但用户打开表格看不到辅助列,被视为"操作不完整"。
> `formulaCondition` 单独使用的场景是:用户**没有**明确要求辅助列、只要"标红符合条件的行"时。
#### 典型工作流
```
1. 先读取现有条件格式了解当前配置
dws sheet cond-format list --node NODE_ID --sheet-id SHEET_ID
2. 创建/更新/删除条件格式规则
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID --ranges '["A1:E100"]' \
--condition '{"numberCondition":{"operator":"greater","value1":"80"}}' \
--cell-style '{"backgroundColor":"#FFCDD2","fontColor":"#B71C1C","bold":true}'
3. 再次读取验证结果是否生效
dws sheet cond-format list --node NODE_ID --sheet-id SHEET_ID
```
#### 条件类型参考
条件类型(`--condition` JSON 的 key,每次只能选一种):
| 条件类型 | 说明 | 参数 |
|---------|------|------|
| `numberCondition` | 数值比较 | operator: equal/not-equal/greater/greater-equal/less/less-equal/between/not-between + value1 + value2 |
| `textCondition` | 文本匹配 | operator: contains/not-contains/starts-with/ends-with + value |
| `emptyCondition` | 空值判断 | operator: is-empty/is-not-empty |
| `errorCondition` | 错误值 | operator: error/no-error |
| `duplicateCondition` | 重复/唯一值 | operator: duplicate/unique |
| `formulaCondition` | 自定义公式 | formula: "=A1>100" |
| `rankCondition` | 排名 | value + isPercent + isBottom |
| `averageCondition` | 高于/低于平均值 | isAbove + andEqual |
| `stdevCondition` | 标准差 | value + isAbove + andEqual |
| `dataBarCondition` | 数据条 | minPoint + maxPoint(每个含 type + value) |
| `iconSetCondition` | 图标集 | iconSet(数组)+ showIconOnly |
| `colorScaleCondition` | 色阶 | criterias(数组,每项含 type + value + color) |
## 命令详细参考
### 获取条件格式规则
```
Usage:
dws sheet cond-format list [flags]
Example:
# 获取所有条件格式规则
dws sheet cond-format list --node <NODE_ID> --sheet-id <SHEET_ID>
# 获取单个规则的详情
dws sheet cond-format list --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--rule-id string 条件格式规则 ID (可选,不传则返回全部)
```
- **用途**:查看指定工作表中已有的条件格式规则,或获取单个规则的详情。
- **场景**:创建/更新/删除条件格式前后验证规则状态;获取 ruleId 供后续 update/delete 使用。
- **返回**:rules 数组,每条规则包含 id、type、ranges、条件参数、cellStyle/dataBarStyle 等。
### 创建条件格式规则
```
Usage:
dws sheet cond-format create [flags]
Example:
# 数值条件:大于 80 时标红加粗
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
--ranges '["A1:A100"]' \
--condition '{"numberCondition":{"operator":"greater","value1":"80"}}' \
--cell-style '{"backgroundColor":"#FFCDD2","fontColor":"#B71C1C","bold":true}'
# 文本条件:包含"延期"时加删除线
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
--ranges '["B1:B50"]' \
--condition '{"textCondition":{"operator":"contains","value":"延期"}}' \
--cell-style '{"backgroundColor":"#FFF3E0","strikethrough":true}'
# 数据条
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
--ranges '["C1:C20"]' \
--condition '{"dataBarCondition":{"minPoint":{"type":"auto"},"maxPoint":{"type":"auto"}}}' \
--data-bar-style '{"fill":["#4CAF50","#F44336"],"isGradient":true}'
# 色阶(三色)
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
--ranges '["D1:D50"]' \
--condition '{"colorScaleCondition":{"criterias":[{"type":"maxmin","color":"#F44336"},{"type":"percentile","value":"50","color":"#FFEB3B"},{"type":"maxmin","color":"#4CAF50"}]}}'
# 重复值高亮
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
--ranges '["E1:E100"]' \
--condition '{"duplicateCondition":{"operator":"duplicate"}}' \
--cell-style '{"backgroundColor":"#FCE4EC"}'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--ranges string 应用范围 JSON 数组 (必填),如 '["A1:E10"]'
--condition string 条件类型及参数 JSON 对象 (必填)
--cell-style string 单元格样式 JSON 对象 (可选)
--data-bar-style string 数据条样式 JSON 对象 (可选,仅数据条类型)
```
- **用途**:在指定工作表中创建一条条件格式规则。
- **注意事项**:
- 创建后**必须**用 `cond-format list` 验证规则是否生效
- 中文"标红/高亮/染色"默认指 `backgroundColor`,"字体红"才是 `fontColor`
- 日期/空值公式必须防空:`=AND(E1<>"", E1<=TODAY())` 而非 `=E1<=TODAY()`
- 公式中用相对引用使公式随行变化
- 创建前建议先 `range read` 读 3-5 行数据确认列对应关系
- **条件类型**(`--condition` JSON 的 key,每次只能选一种):numberCondition / textCondition / emptyCondition / errorCondition / duplicateCondition / formulaCondition / rankCondition / averageCondition / stdevCondition / dataBarCondition / iconSetCondition / colorScaleCondition
### 更新条件格式规则
```
Usage:
dws sheet cond-format update [flags]
Example:
# 修改条件(改为大于 90)
dws sheet cond-format update --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> \
--condition '{"numberCondition":{"operator":"greater","value1":"90"}}'
# 修改样式
dws sheet cond-format update --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> \
--cell-style '{"backgroundColor":"#C8E6C9","fontColor":"#1B5E20"}'
# 修改应用范围
dws sheet cond-format update --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> \
--ranges '["A1:F200"]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--rule-id string 条件格式规则 ID (必填)
--ranges string 应用范围 JSON 数组 (可选)
--condition string 条件类型及参数 JSON 对象 (可选,传入后替换原有条件)
--cell-style string 单元格样式 JSON 对象 (可选)
--data-bar-style string 数据条样式 JSON 对象 (可选,仅数据条类型)
```
- **用途**:更新已有条件格式规则的部分或全部配置。
- **场景**:修改阈值、切换条件类型、调整样式、扩大应用范围。
- **注意**:未传入的字段保持原有值不变;`--ranges`/`--condition`/`--cell-style`/`--data-bar-style` 至少传入一个。
- **ruleId 获取**:通过 `cond-format list` 获取。
### 删除条件格式规则
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws sheet cond-format delete [flags]
Example:
# 删除条件格式规则(必须加 --yes 确认)
dws sheet cond-format delete --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> --yes
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--rule-id string 条件格式规则 ID (必填)
```
- **用途**:删除指定条件格式规则。
- **幂等性**:规则已不存在时操作仍返回成功。
- **ruleId 获取**:通过 `cond-format list` 获取。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `cond-format list` | `rules` 数组(含 `id`、`type`、`ranges`、条件参数、`cellStyle`/`dataBarStyle`) | 获取 ruleId 供 update / delete 使用;验证规则是否生效 |
| `cond-format create` | 新创建规则的 `id` | 用于后续 update / delete 的 --rule-id |
| `cond-format update` | 更新后的规则信息 | 确认更新结果 |
| `cond-format delete` | 操作结果 | 确认删除完成 |
| `list` | 工作表的 `sheetId` | cond-format list / create / update / delete 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **强制走条件格式的触发词**:用户说"标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变"时,强制走 `cond-format create/update/delete`,禁止用 `range set-style` 写静态背景色代替
- **创建后必须验证**:条件格式创建后必须调用 `cond-format list` 验证规则是否生效
- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏也不要过度扩大
- **`backgroundColor` vs `fontColor` 的中文语义**:用户中文语境下的"标红/高亮/染色/标记"指**单元格背景色**,用 `backgroundColor`;"文字红/字体红/把字变红"才用 `fontColor`。默认无说明时选 `backgroundColor`
- **日期/空值比较必须防空**:用户说"过期的标红"时,公式必须排除空单元格。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期)
- **公式引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 使用相对引用使公式随行变化)
- **创建前必须确认列对应**:仅读表头不够——如果表头语义含糊,formula 里引用的列字母可能张冠李戴。建议先读 3-5 行数据样本(如 `range read --range "A1:Z5"`)确认列名对应的实际值和数据类型
- **辅助列+条件格式两步走**:用户明确要求"辅助列"时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),禁止直接用 `formulaCondition` 一步绕过
- **大数据量优势**:当数据量 > 1000 行时,条件格式是首选——它由服务端自身渲染,不需要逐行调用 `range set-style`,性能远优于静态样式写入
- **判断标准**:交付后 `cond-format list` 必须能返回该规则;否则视为违规
@@ -0,0 +1,275 @@
# 行列操作 (dimension operations)
## 使用场景
### 行列操作
用户说"插入行/插入列/在某行前插入/在某列前插入":
- 插入行或列 → `insert-dimension`
- 在末尾追加 → `append`(insert-dimension 不支持末尾追加)
用户说"删除行/删除列/删掉第几行/删掉某列/移除行/移除列":
- 删除行或列 → `delete-dimension`
- 仅清空内容但保留行/列 → `range clear`(默认清除值保留格式)
用户说"隐藏行/隐藏列/显示行/显示列/设置行高/设置列宽/调整行高/调整列宽/行列属性":
- 隐藏/显示行或列 → `update-dimension --hidden` / `--hidden=false`
- 设置行高/列宽 → `update-dimension --pixel-size`
- 同时修改尺寸与显隐 → `update-dimension --pixel-size --hidden`
用户说"移动行/移动列/调整行顺序/调整列顺序/行列拖拽/把第N行移到第M行":
- 移动行或列 → `move-dimension`
- 请勿用 `range read` + `range update` 读取再重写来模拟移动,`move-dimension` 是原子操作,能保留格式和合并状态
用户说"追加空行/追加空列/增加行数/增加列数/扩展表格/在末尾加空行":
- 追加空行/空列 → `add-dimension`
- 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行
- 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度
**结构预检**:插入、删除、移动行列前,必须先执行 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查看 `mergedRanges`。合并区域跨过操作位置时,行列变更可能导致表头/分组标题断裂、空白或错位;需要先向用户说明影响,必要时取消合并后操作,再按原模式重新 `merge-cells`。
## 命令详细参考
### 在指定位置插入行或列
```
Usage:
dws sheet insert-dimension [flags]
Example:
# 在第 3 行之前插入 2 行
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "3" --length 2
# 在 A 列之前插入 1 列
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "A" --length 1
# 使用工作表前缀(忽略 --sheet-id)
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "Sheet1!3" --length 5
# 在 AB 列之前插入 3 列
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "AB" --length 3
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--dimension string 插入维度: ROWS 或 COLUMNS (必填)
--position string 插入位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A"
--length string 插入数量,正整数 (必填),最大 5000
```
在钉钉表格指定工作表的指定位置之前插入若干空行或空列。
`--dimension ROWS` 时,`--position` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--position` 为列字母。
支持在 `--position` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
若需要在末尾追加行/列,请使用 `append` 命令。
### 删除指定位置的行或列
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws sheet delete-dimension [flags]
Example:
# 从第 3 行开始删除 2 行
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "3" --length 2
# 从 A 列开始删除 1 列
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "A" --length 1
# 使用工作表前缀(忽略 --sheet-id)
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "Sheet1!3" --length 5
# 从 AB 列开始删除 3 列
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "AB" --length 3
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--dimension string 删除维度: ROWS 或 COLUMNS (必填)
--position string 删除起始位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A"
--length string 删除数量,正整数 (必填),最大 5000
```
在钉钉表格指定工作表中,从指定位置起删除若干连续的行或列。
`--dimension ROWS` 时,`--position` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--position` 为列字母。
支持在 `--position` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
删除后后续的行/列会向前移动填补空位;若需要仅清空内容但保留行/列占位,请使用 `range clear`。
### 更新指定范围行/列属性
```
Usage:
dws sheet update-dimension [flags]
Example:
# 隐藏第 3~4 行
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "3" --length 2 --hidden
# 显示 A~B 列
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --start-index "A" --length 2 --hidden=false
# 设置第 1~5 行行高为 40px
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "1" --length 5 --pixel-size 40
# 设置 C 列列宽为 200px 并隐藏
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --start-index "C" --length 1 --pixel-size 200 --hidden
# 使用工作表前缀(忽略 --sheet-id)
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "Sheet1!3" --length 2 --hidden
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--dimension string 更新维度: ROWS 或 COLUMNS (必填)
--start-index string 起始位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A"
--length string 更新数量,正整数 (必填),最大 5000
--hidden 是否隐藏 (true=隐藏, false=显示),与 --pixel-size 至少填其一
--pixel-size int 行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽,与 --hidden 至少填其一
```
批量更新钉钉表格指定工作表中连续多行/多列的属性,支持设置显隐状态(hidden)与行高/列宽(pixelSize)。
`--dimension ROWS` 时,`--start-index` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--start-index` 为列字母。
支持在 `--start-index` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
`--hidden` 与 `--pixel-size` 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
`--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽。
### 移动行或列
```
Usage:
dws sheet move-dimension [flags]
Example:
# 将第 2 行移动到第 5 行的位置
dws sheet move-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --start-index "2" --end-index "2" --destination-index "5"
# 将第 2~4 行(共 3 行)移动到第 1 行的位置(最前面)
dws sheet move-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --start-index "2" --end-index "4" --destination-index "1"
# 将 B~C 列(共 2 列)移动到 D 列的位置
dws sheet move-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension COLUMNS --start-index "B" --end-index "C" --destination-index "D"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--dimension string 维度类型: ROWS 或 COLUMNS (必填)
--start-index string 源起始位置,A1 表示法 (必填)
--end-index string 源结束位置,A1 表示法 (必填)
--destination-index string 目标位置,A1 表示法 (必填)
```
startIndex、endIndex 和 destinationIndex 均使用 A1 表示法:`--dimension ROWS` 时为 1-based 行号(如 "2"),`--dimension COLUMNS` 时为列字母(如 "B")。
源行/列将移动到 destinationIndex 所指的位置。destinationIndex 不能落在源范围 [startIndex, endIndex] 内。
**合并单元格注意**:如果源范围或目标位置涉及合并单元格,操作会报错中断。移动前先通过 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查询 `mergedRanges`,必要时先用 `unmerge-cells` 取消合并再移动,移动后再用 `merge-cells` 恢复需要保留的合并区域。
### 追加空行或空列
```
Usage:
dws sheet add-dimension [flags]
Example:
dws sheet add-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --length 5
dws sheet add-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --length 3
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--dimension string 维度类型: ROWS 或 COLUMNS (必填)
--length int 追加数量,正整数,最多 5000 (必填)
```
在工作表末尾追加指定数量的空行或空列。
## 核心工作流
```bash
# ── 工作流 6: 插入行或列 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 在第 3 行之前插入 2 行
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --position "3" --length 2 --format json
# 3. 在 A 列之前插入 1 列
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension COLUMNS --position "A" --length 1 --format json
# 4. 使用工作表前缀指定位置
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --position "Sheet1!5" --length 3 --format json
```
```bash
# ── 工作流 6b: 删除行或列 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 从第 3 行开始删除 2 行
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --position "3" --length 2 --format json
# 3. 从 A 列开始删除 1 列
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension COLUMNS --position "A" --length 1 --format json
# 4. 使用工作表前缀指定位置
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --position "Sheet1!5" --length 3 --format json
```
```bash
# ── 工作流 6c: 更新行/列属性(显隐、行高/列宽) ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 隐藏第 3~4 行
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --start-index "3" --length 2 --hidden --format json
# 3. 显示 A~B 列
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension COLUMNS --start-index "A" --length 2 --hidden=false --format json
# 4. 设置第 1~5 行行高为 40px
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --start-index "1" --length 5 --pixel-size 40 --format json
# 5. 设置 C 列列宽为 200px 并隐藏
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension COLUMNS --start-index "C" --length 1 --pixel-size 200 --hidden --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `insert-dimension` | `a1Notation` 新插入区域范围 | 确认插入位置和范围 |
| `delete-dimension` | `a1Notation` 被删除区域范围 | 确认删除位置和范围 |
| `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 |
| `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
| `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `sheet info` 的 `mergedRanges` 是行列结构操作的重要预检信息。插入列时尤其要检查多行表头合并区,原有合并区域通常不会自动扩展到新列,必要时需重新设置合并区域
- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append`
- `insert-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
- `insert-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
- `insert-dimension` 的 `--length` 最大为 5000
- `delete-dimension` 从指定位置起删除若干连续的行或列,删除后后续行/列向前移动填补空位
- `delete-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
- `delete-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
- `delete-dimension` 的 `--length` 最大为 5000
- `delete-dimension` 若需仅清空内容但保留行/列占位,请使用 `range clear`(默认清除值保留格式,比手动构造空数组更简洁)
- `update-dimension` 批量更新连续行/列的显隐状态与行高/列宽
- `update-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
- `update-dimension` 的 `--start-index` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
- `update-dimension` 的 `--length` 最大为 5000
- `update-dimension` 的 `--hidden` 与 `--pixel-size` 至少必须提供一个
- `update-dimension` 的 `--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽
- `update-dimension` 当同时提供 `--hidden` 与 `--pixel-size` 时,将先应用尺寸再应用显隐,任一失败整体失败
- ★ `move-dimension` vs `range update`:需要移动行或列时,必须使用 `move-dimension` 命令,禁止用 `range update` 读取数据后手动重写来模拟移动效果。`move-dimension` 是原子操作,能保留单元格的格式、合并状态等属性
- `move-dimension` 的 `--start-index`、`--end-index` 和 `--destination-index` 均使用 A1 表示法(ROWS 时为 1-based 行号,COLUMNS 时为列字母)
- `move-dimension` 的 `--destination-index` 不能落在源范围 [startIndex, endIndex] 内
- `move-dimension` 的源范围 [startIndex, endIndex] 最大跨度为 5000
- `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果
- `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同
- `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000
@@ -0,0 +1,94 @@
# 下拉列表 (dropdown)
## 使用场景
### 下拉列表
用户说"设置下拉列表/下拉选项/下拉菜单/添加下拉/配置下拉":
- 设置下拉列表 → `set-dropdown`
- 设置多选下拉 → `set-dropdown --multi-select`
用户说"查看下拉列表/获取下拉配置/下拉列表有哪些选项":
- 获取下拉列表配置 → `get-dropdown`
用户说"删除下拉列表/移除下拉/取消下拉/清除下拉":
- 删除下拉列表 → `delete-dropdown`
## 命令详细参考
### 设置下拉列表
```
Usage:
dws sheet set-dropdown [flags]
Example:
# 设置单选下拉列表
dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A100" \
--options '[{"value":"选项1"},{"value":"选项2"},{"value":"选项3"}]'
# 设置带颜色的多选下拉列表
dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "B2:B50" \
--options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \
--multi-select
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 目标单元格范围,A1 表示法,如 A2:A100 (必填)
--options string 下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]'
--multi-select 是否允许多选(默认单选)
```
在指定单元格范围内设置下拉列表。设置后用户可从预定义选项中选择值。
- **用途**:为单元格配置下拉列表,支持自定义选项颜色和多选。
- **场景**:规范数据输入,如状态选择(完成/进行中/待处理)、优先级(高/中/低)等。
- **注意**:选项值不能包含英文逗号;如果目标范围已存在下拉列表,会被新配置覆盖。
### 获取下拉列表配置
```
Usage:
dws sheet get-dropdown [flags]
Example:
dws sheet get-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A100"
dws sheet get-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 查询范围,A1 表示法,如 A1:A100 (必填)
```
查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。
- **用途**:查看单元格已设置的下拉列表选项和配置。
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。
### 删除下拉列表
```
Usage:
dws sheet delete-dropdown [flags]
Example:
dws sheet delete-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A100"
dws sheet delete-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "B1:D10"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 要删除下拉列表的范围,A1 表示法 (必填)
```
删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。
- **用途**:移除不再需要的下拉列表约束。
- **注意**:已填写的单元格值不会被清除;目标范围不存在下拉列表时操作仍返回成功。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 |
| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 |
| `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖
- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
@@ -0,0 +1,95 @@
# 导出 (export)
## 使用场景
### 导出
用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel":
- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载)
- 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl)
- 需要落盘到本地 → `dws sheet export --node <NODE_ID> --output <path>`,命令自动下载 xlsx
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性)
- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟)
## 命令详细参考
### 导出表格为 xlsx(异步任务一站式)
```
Usage:
dws sheet export [flags] # 一站式:提交 → 轮询 → 可选下载
Example:
# 仅导出,返回 downloadUrl(链接有时效性,请尽快下载)
dws sheet export --node <NODE_ID>
dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
# 导出并自动下载为本地文件
dws sheet export --node <NODE_ID> --output ./report.xlsx
# --output 为目录时,自动按下载链接中的文件名保存
dws sheet export --node <NODE_ID> --output ./
Flags:
--node string 表格文档 ID 或 URL (必填)
--output string 本地保存路径(可选,支持文件路径或目录)
```
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。
**内部流程**:
1. 调 `submit_export_job` 获取 `jobId`
2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时
3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件
**内置轮询策略(CLI 内实现,无需关心)**:
- 第 1~5 次:每次间隔 2 秒
- 第 6~10 次:每次间隔 5 秒
- 第 11~20 次:每次间隔 10 秒
- 第 21~30 次:每次间隔 15 秒
- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误
**命令返回**:
- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: <path>`
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下
**失败处理(命令内部已处理,Agent 仅需转述)**:
- MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。
## 核心工作流
```bash
# ── 工作流 12: 导出表格为 xlsx(单命令一站式)──
# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl)
dws sheet export --node <NODE_ID> --format json
# 传入 URL 也可:
# dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# 场景 B:导出并自动下载为本地文件
dws sheet export --node <NODE_ID> --output ./report.xlsx
# 场景 C:下载到目录,自动按链接推断文件名
dws sheet export --node <NODE_ID> --output ./
# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。
# 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `export` | `downloadUrl`(未指定 --output)/ `导出完成: <path>`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 |
## 注意事项
- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令
- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可
- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试
- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载
- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性)
@@ -0,0 +1,344 @@
# 筛选视图 (filter-view)
## 使用场景
### 筛选视图
用户说"筛选视图/查看筛选视图/有哪些筛选视图/筛选视图列表":
- 获取所有筛选视图 → `filter-view list`
用户说"筛选视图详情/查看某个筛选视图/筛选视图信息/筛选视图配置":
- 获取单个筛选视图详情 → `filter-view info`
用户说"创建筛选视图/新建筛选视图/添加筛选视图":
- 创建筛选视图 → `filter-view create`
用户说"更新筛选视图/修改筛选视图/改筛选视图名称/改筛选视图范围":
- 更新筛选视图属性 → `filter-view update`
用户说"删除筛选视图/移除筛选视图":
- 删除筛选视图 → `filter-view delete`
用户说"设置筛选条件/添加筛选条件/配置筛选视图条件/按值筛选/按条件筛选/按颜色筛选":
- 设置筛选视图列条件 → `filter-view update-criteria`
用户说"查看筛选条件/有哪些筛选条件/筛选视图设了什么条件/列出筛选条件":
- 列出所有列条件 → `filter-view list-criteria`
- 查看某一列的条件 → `filter-view get-criteria --column N`
用户说"清除筛选条件/移除筛选条件/取消筛选条件":
- 清除筛选视图列条件 → `filter-view delete-criteria`
- 注意与 `filter-view delete`(删除整个筛选视图)区分:`delete-criteria` 仅清除指定列的条件,不删除筛选视图本身
## 命令详细参考
### 获取所有筛选视图
```
Usage:
dws sheet filter-view list [flags]
Example:
dws sheet filter-view list --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet filter-view list --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --sheet-id "Sheet1"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
```
获取指定工作表的所有筛选视图列表,返回每个筛选视图的 ID、名称和范围信息。
- **用途**:查看当前工作表上已创建的所有筛选视图,获取视图 ID、名称和范围。
- **场景**:在对筛选视图进行 update / delete / update-criteria 等操作前,先用 list 获取可用的 filterViewId。
- **区分**:筛选视图(filter-view)是个人化的数据过滤方式,与全局筛选不同。每个用户可以创建自己的筛选视图,互不影响原始数据。如果没有筛选视图,返回空列表。
### 创建筛选视图
```
Usage:
dws sheet filter-view create [flags]
Example:
# 创建不带筛选条件的筛选视图
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> --name "我的视图" --range "A1:E10"
# 创建带按值筛选条件的筛选视图
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> --name "销售筛选" --range "A1:E10" \
--criteria '[{"column":0,"filterType":"values","visibleValues":["销售部"]}]'
# 创建带按条件筛选的筛选视图(大于等于 200000)
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> --name "高预算" --range "A1:C10" \
--criteria '[{"column":1,"filterType":"condition","conditions":[{"operator":"greater-equal","value":"200000"}]}]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--name string 筛选视图名称 (必填)
--range string 筛选视图范围,A1 表示法,如 A1:E10 (必填)
--criteria string 筛选条件,JSON 数组 (可选)
```
在指定工作表中创建一个筛选视图。
- **用途**:为指定数据区域创建一个可命名的个人化筛选视图,可选同时设置筛选条件。
- **场景**:用户需要针对某个数据区域建立固定的筛选视角(如"高绩效员工""研发部数据"),方便反复查看。
- **区分**:与全局筛选不同,筛选视图是个人化的,不影响其他用户看到的数据。如果只需创建视图不设条件,后续可通过 `update-criteria` 单独设置;如果要一步到位,可通过 `--criteria` 在创建时直接设置。
`--criteria` 为 JSON 数组,每个元素包含 `column`(列偏移量,从 0 开始)和筛选条件字段。支持三种筛选类型:
- `values`:按值筛选,通过 `visibleValues` 指定允许显示的值列表
- `condition`:按条件筛选,通过 `conditions` 指定条件列表(最多 2 个),每个条件包含 `operator` 和 `value`。支持的操作符(kebab-case):`equal`、`not-equal`、`contains`、`not-contains`、`starts-with`、`not-starts-with`、`ends-with`、`not-ends-with`、`greater`、`greater-equal`、`less`、`less-equal`。多条件之间通过 `conditionOperator` 指定逻辑关系:`and`(且,默认)或 `or`(或)
- `color`:按颜色筛选,通过 `backgroundColor` 或 `fontColor` 指定颜色值(十六进制,如 `#FF0000`),二选一
### 更新筛选视图属性
```
Usage:
dws sheet filter-view update [flags]
Example:
# 更新筛选视图名称
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --name "新名称"
# 更新筛选视图范围
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --range "A1:F20"
# 更新筛选条件
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--criteria '[{"column":1,"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
--name string 筛选视图新名称
--range string 筛选视图新范围,A1 表示法
--criteria string 筛选条件,JSON 数组
```
更新筛选视图的名称、范围和/或筛选条件,`--name`、`--range`、`--criteria` 至少传入一个。
- **用途**:修改已有筛选视图的名称、数据范围或筛选条件。
- **场景**:数据区域扩展后需要扩大筛选视图范围,或重命名视图,或通过 `--criteria` 一次性批量更新多列筛选条件。
- **区分**:`update` 可同时修改名称、范围和条件,适合批量更新;`update-criteria` 只能设置单列条件,适合精确控制某一列的筛选逻辑。`--criteria` 指定列的条件会被替换,未指定的列保持不变。
`--criteria` 为 JSON 数组,格式与 `filter-view create` 的 `--criteria` 相同,支持的筛选类型和操作符参见「创建筛选视图」说明。
### 删除筛选视图
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws sheet filter-view delete [flags]
Example:
dws sheet filter-view delete --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
```
删除指定的筛选视图。
- **用途**:永久删除一个不再需要的筛选视图及其所有筛选条件。
- **场景**:筛选视图已过时或不再需要时,清理无用的视图。
- **区分**:`delete` 删除整个筛选视图(包括所有列的条件),操作不可恢复;`delete-criteria` 只删除某一列的筛选条件,视图本身保留。此操作不影响全局筛选或其他筛选视图,也不影响原始数据。
### 更新筛选视图列条件
```
Usage:
dws sheet filter-view update-criteria [flags]
Example:
# 按值筛选:只显示"销售部"和"市场部"
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 0 --filter-criteria '{"filterType":"values","visibleValues":["销售部","市场部"]}'
# 按条件筛选:大于 100
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 2 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}'
# 按条件筛选:大于等于 200000
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 1 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater-equal","value":"200000"}]}'
# 按条件筛选:小于 100
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 1 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"less","value":"100"}]}'
# 多条件筛选:大于等于 60 且 小于等于 90
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 2 --filter-criteria '{"filterType":"condition","conditionOperator":"and","conditions":[{"operator":"greater-equal","value":"60"},{"operator":"less-equal","value":"90"}]}'
# 按颜色筛选:背景色为红色
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 1 --filter-criteria '{"filterType":"color","backgroundColor":"#FF0000"}'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
--column int 列偏移量,从 0 开始 (必填)
--filter-criteria string 筛选条件,JSON 对象 (必填)
```
更新筛选视图中某一列的筛选条件。
- **用途**:为筛选视图的指定列创建或更新筛选条件,控制该列哪些数据行可见。
- **场景**:只显示某些特定值的行(如"只看研发部")→ `filterType: values`;按数值条件筛选(如"绩效 ≥ 85")→ `filterType: condition` + `operator: greater-equal`;按文本条件筛选(如"名称包含关键字")→ `filterType: condition` + `operator: contains`。
- **区分**:`update-criteria` 精确控制单列条件,适合逐列设置不同的筛选逻辑;`filter-view update --criteria` 可以批量更新多列条件;`delete-criteria` 是 `update-criteria` 的逆操作,删除指定列的条件。
`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。
例如筛选视图范围为 `B1:E10`,则 `--column 0` 代表 B 列,`--column 1` 代表 C 列。
`--filter-criteria` 为 JSON 对象,支持三种筛选类型:
- `values`:按值筛选,通过 `visibleValues` 指定允许显示的值列表
- `condition`:按条件筛选,通过 `conditions` 指定条件列表(最多 2 个),每个条件包含 `operator` 和 `value`。支持的操作符:`equal`、`not-equal`、`contains`、`not-contains`、`starts-with`、`not-starts-with`、`ends-with`、`not-ends-with`、`greater`、`greater-equal`、`less`、`less-equal`。多条件之间通过 `conditionOperator` 指定逻辑关系:`and`(且,默认)或 `or`(或)
- `color`:按颜色筛选,通过 `backgroundColor` 或 `fontColor` 指定颜色值(十六进制,如 `#FF0000`),二选一
### 删除筛选视图列条件
```
Usage:
dws sheet filter-view delete-criteria [flags]
Example:
# 删除第 1 列(A 列)的筛选条件
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 0
# 删除第 3 列(C 列)的筛选条件
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 2
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
--column int 列偏移量,从 0 开始 (必填)
```
清除筛选视图中指定列的筛选条件。
- **用途**:移除筛选视图中指定列的筛选条件,使该列不再参与过滤。
- **场景**:之前通过 `update-criteria` 设置了某列的筛选条件,现在需要取消该列的筛选以显示全部数据。
- **区分**:`delete-criteria` 只清除指定列的条件,筛选视图本身和其他列的条件保持不变;`delete` 会删除整个筛选视图。如果指定列没有设置筛选条件,调用此命令不会报错(幂等操作)。
### 获取单个筛选视图详情
```
Usage:
dws sheet filter-view info [flags]
Example:
# 查看指定筛选视图的详情
dws sheet filter-view info --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
```
获取指定筛选视图的完整信息,包括 ID、名称、范围和筛选条件。
- **用途**:查看某个筛选视图的当前配置,包括已设置的所有筛选条件详情。
- **场景**:在修改或删除筛选视图前,先确认其当前状态;或在 `update-criteria` 后验证条件是否生效。
- **区分**:`info` 返回单个视图的完整信息(含 criteria);`list` 返回所有视图的列表概要。`info` 需要指定 `--filter-view-id`,ID 可通过 `list` 获取。
- **实现**:内部调用 `get_filter_views` 获取全部列表后按 ID 过滤。
### 列出筛选视图所有列条件
```
Usage:
dws sheet filter-view list-criteria [flags]
Example:
# 列出筛选视图的所有条件
dws sheet filter-view list-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
```
列出指定筛选视图中已设置的所有列筛选条件。
- **用途**:查看某个筛选视图当前设置了哪些列的筛选条件,包括每列的条件类型和具体规则。
- **场景**:在管理筛选条件(修改/删除特定列条件)前,先了解当前视图有哪些条件;或排查筛选结果不符合预期时检查条件配置。
- **区分**:`list-criteria` 返回所有列的条件(按列偏移量为 key 的对象);`get-criteria` 只返回指定列的条件。如果没有设置任何条件,返回空对象 `{}`。
- **实现**:内部调用 `get_filter_views` 获取视图详情后提取 `criteria` 字段。
### 获取单列筛选条件
```
Usage:
dws sheet filter-view get-criteria [flags]
Example:
# 查看第 1 列(偏移量 0)的筛选条件
dws sheet filter-view get-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 0
# 查看第 3 列(偏移量 2)的筛选条件
dws sheet filter-view get-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 2
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
--column int 列偏移量,从 0 开始 (必填)
```
获取指定筛选视图中某一列的筛选条件详情。
- **用途**:查看某个筛选视图中指定列当前设置的筛选条件,包括条件类型、运算符和比较值。
- **场景**:在修改某列条件前,先查看其当前配置;或验证 `update-criteria` 后该列条件是否正确。
- **区分**:`get-criteria` 只返回指定列的条件;`list-criteria` 返回所有列的条件。`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。
- **实现**:内部调用 `get_filter_views` 获取视图详情后按列偏移量过滤 `criteria` 中的对应条件。
## 核心工作流
```bash
# ── 工作流 11: 筛选视图管理 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> -f json
# 2. 查看已有筛选视图
dws sheet filter-view list --node <NODE_ID> --sheet-id <SHEET_ID> -f json
# 3. 创建筛选视图(不带条件)
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
--name "我的筛选" --range "A1:E100" -f json
# 4. 为筛选视图设置列条件(按值筛选)
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 0 --filter-criteria '{"filterType":"values","visibleValues":["销售部","市场部"]}' -f json
# 5. 为筛选视图设置列条件(按条件筛选)
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 2 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}' -f json
# 6. 更新筛选视图名称和范围
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--name "销售数据筛选" --range "A1:F200" -f json
# 7. 清除某列的筛选条件
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 0 -f json
# 8. 删除筛选视图
dws sheet filter-view delete --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> -f json
```
```bash
# ── 工作流 11b: 创建带条件的筛选视图(一步完成) ──
# 创建筛选视图时直接指定筛选条件
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
--name "高销售额视图" --range "A1:E100" \
--criteria '[{"column":0,"filterType":"values","visibleValues":["销售部"]},{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"50000"}]}]' \
-f json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `filter-view list` | `filterViews` 筛选视图列表(含 `id`、`name`、`range`) | 获取 filterViewId 用于 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria |
| `filter-view info` | `id`、`name`、`range`、`criteria` | 查看单个视图完整配置,确认条件是否生效 |
| `filter-view create` | `id` 筛选视图 ID、`name`、`range` | 用于后续 update / delete / update-criteria / delete-criteria 的 --filter-view-id |
| `filter-view update` | `id`、`name`、`range`、`criteria` | 确认更新结果 |
| `filter-view delete` | `id` 被删除的筛选视图 ID | 确认删除完成 |
| `filter-view update-criteria` | `id` 筛选视图 ID | 确认条件设置完成 |
| `filter-view delete-criteria` | `id` 筛选视图 ID | 确认条件清除完成 |
| `filter-view list-criteria` | 所有列条件(按列偏移量为 key 的对象) | 了解当前视图已设置哪些列的条件 |
| `filter-view get-criteria` | 指定列的条件详情(`filterType`、`conditions` 等) | 查看某列的具体筛选规则 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列
- `filter-view list` 获取指定工作表的所有筛选视图列表,返回的 `id` 可用于后续 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria 的 `--filter-view-id`
- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),内部复用 `get_filter_views` MCP 按 ID 过滤
- `filter-view list-criteria` 列出指定筛选视图已设置的所有列条件,返回按列偏移量为 key 的对象;无条件时返回空对象 `{}`
- `filter-view get-criteria` 获取指定列的条件详情,`--column` 为列偏移量(从 0 开始);该列无条件时返回错误提示
- `filter-view create` 创建筛选视图时 `--range` 应包含表头行。`--criteria` 可选,不传则创建后无筛选条件,后续可通过 `filter-view update-criteria` 设置
- `filter-view update` 的 `--name`、`--range`、`--criteria` 至少需要传入一个,未指定的字段保持不变
- `filter-view update` 的 `--criteria` 中指定列的条件会被替换,未指定的列保持不变
- `filter-view delete` 删除后该视图及其所有筛选条件将被永久移除,不可恢复
- `filter-view delete` 不影响全局筛选或其他筛选视图
- `filter-view update-criteria` 的 `--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。例如筛选视图范围为 `B1:E10`,则 `--column 0` 代表 B 列
- `filter-view update-criteria` 设置条件后立即在该筛选视图中生效,仅影响当前视图,不影响全局筛选或其他筛选视图
- `filter-view update-criteria` 的 `--filter-criteria` 中 `conditions` 最多 2 个条件,多条件之间通过 `conditionOperator` 指定逻辑关系(`and` 或 `or`)
- `filter-view delete-criteria` 仅清除指定列的条件,不会删除整个筛选视图。如需删除整个筛选视图,请使用 `filter-view delete`
- `filter-view delete-criteria` 如果指定列没有设置筛选条件,调用不会报错
- 筛选视图相关操作需要"可阅读"权限(list / info / list-criteria / get-criteria)或"可编辑"权限(create / update / delete / update-criteria / delete-criteria),不支持跨组织操作
@@ -0,0 +1,181 @@
# 全局筛选 (filter)
## 使用场景
### 筛选视图
用户说"筛选/过滤/只看某些值/只显示满足条件的行/筛选数据/创建筛选/删除筛选/设置筛选条件/清除筛选/排序":
- 查看当前筛选 → `filter get`
- 创建筛选 → `filter create`
- 删除筛选 → `filter delete`
- 批量设置多列条件 → `filter update`
- 清除某一列条件 → `filter clear-criteria`
- 按列排序 → `filter sort`
- **区分全局筛选与筛选视图**:如果用户说"筛选视图"则走 `filter-view` 系列;如果只说"筛选/过滤/只看"则默认走全局 `filter` 系列
- **禁止替代方案**:当用户要求"筛选/只看/仅保留某些行"时,必须通过 `filter create` / `filter update` 创建真实的筛选器。禁止用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替——这些做法会让原数据丢失或不可恢复
## 命令详细参考
### 获取筛选信息
```
Usage:
dws sheet filter get [flags]
Example:
dws sheet filter get --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet filter get --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --sheet-id "Sheet1"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
```
获取指定工作表的全局筛选信息,返回筛选范围和各列的筛选条件详情。
- **用途**:查看当前工作表上是否存在全局筛选及其配置。
- **场景**:在修改或删除筛选前,先读取当前筛选配置;创建筛选前先确认是否已存在(每个工作表只能有一个筛选)。
- **区分**:全局筛选(filter)影响所有协作者看到的数据展示;筛选视图(filter-view)是个人化的。
- **返回**:`range`(筛选范围,A1 表示法)和 `columnFilterCriteria`(各列条件,key 为列偏移量)。如果未设置筛选,返回筛选信息为空。
### 创建筛选
```
Usage:
dws sheet filter create [flags]
Example:
# 创建筛选框架(不设条件)
dws sheet filter create --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E100"
# 创建筛选并同时设置条件(按值筛选)
dws sheet filter create --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E100" --criteria '[{"column":1,"filterType":"values","visibleValues":["北京","上海"]}]'
# 创建筛选并设置条件筛选
dws sheet filter create --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E100" --criteria '[{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 筛选范围,A1 表示法,须包含表头行 (必填)
--criteria string 筛选条件 JSON 数组 (可选)
```
在工作表中创建全局筛选。
- **用途**:为工作表建立筛选器,使数据可按条件过滤展示。
- **约束**:每个工作表只能有一个全局筛选,已存在时会报错。应先 `filter get` 确认不存在后再创建。
- **range 规范**:必须包含表头行(如 `A1:E100`),不能只包含数据行。
- **criteria 格式**:JSON 数组,每个元素含 `column`(列偏移量,从 0 开始)和筛选条件字段。不传则仅创建空筛选框架,后续可通过 `filter update` 设置条件。
### 删除筛选
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws sheet filter delete [flags]
Example:
dws sheet filter delete --node <NODE_ID> --sheet-id <SHEET_ID> --yes
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
```
删除工作表的全局筛选。
- **用途**:移除筛选器,所有被隐藏的行将重新显示。
- **不可逆**:删除后所有筛选条件丢失,需重新创建。
- **前置**:工作表没有筛选时调用会报错,应先 `filter get` 确认存在。
### 批量更新筛选条件
```
Usage:
dws sheet filter update [flags]
Example:
# 同时设置多列的筛选条件
dws sheet filter update --node <NODE_ID> --sheet-id <SHEET_ID> --criteria '[{"column":0,"filterType":"values","visibleValues":["已完成","进行中"]},{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"50"}]}]'
# 按颜色筛选
dws sheet filter update --node <NODE_ID> --sheet-id <SHEET_ID> --criteria '[{"column":1,"filterType":"color","backgroundColor":"#FF0000"}]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--criteria string 筛选条件 JSON 数组 (必填)
```
批量更新筛选条件,可同时设置多列的筛选条件。
- **用途**:一次性设置或替换多列的筛选条件。
- **前置**:工作表必须已创建筛选(通过 `filter create`)。
- **覆盖式**:指定列的条件会被替换,未指定的列保持不变。如只想修改某一列,建议先 `filter get` 读取现有配置。
- **criteria 格式**:JSON 数组,支持三种 `filterType`:
- `values`:按值筛选,指定 `visibleValues` 数组
- `condition`:按条件筛选,指定 `conditions` 数组(最多 2 个)和可选的 `conditionOperator`(`and`/`or`)
- `color`:按颜色筛选,指定 `backgroundColor` 或 `fontColor`(二选一)
### 清除单列筛选条件
```
Usage:
dws sheet filter clear-criteria [flags]
Example:
# 清除第 2 列(B 列)的筛选条件
dws sheet filter clear-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --column 1
# 清除第 1 列(A 列)的筛选条件
dws sheet filter clear-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --column 0
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--column number 列偏移量,从 0 开始 (必填)
```
清除筛选中某一列的筛选条件。
- **用途**:移除某列的筛选条件,该列不再参与筛选计算。
- **区分**:仅清除指定列的条件,不删除整个筛选。如需删除整个筛选,使用 `filter delete`。
- **幂等**:指定列没有设置筛选条件时调用不会报错。
### 筛选排序
```
Usage:
dws sheet filter sort [flags]
Example:
# 按第 1 列(A 列)升序排序
dws sheet filter sort --node <NODE_ID> --sheet-id <SHEET_ID> --column 0 --ascending
# 按第 3 列(C 列)降序排序
dws sheet filter sort --node <NODE_ID> --sheet-id <SHEET_ID> --column 2 --ascending=false
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--column number 排序列偏移量,从 0 开始 (必填)
--ascending 是否升序,默认 true (可选)
```
对筛选范围内的数据按指定列排序。
- **用途**:对数据行按某一列的值进行升序或降序排列。
- **前置**:工作表必须已创建筛选(通过 `filter create`)。
- **注意**:排序会实际改变工作表中数据行的物理顺序,不可撤销。
- **column**:列偏移量从 0 开始,相对于筛选范围首列。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `filter get` | `range`(筛选范围)、`columnFilterCriteria`(各列条件) | 查看当前筛选配置,确认筛选是否存在 |
| `filter create` | 筛选创建成功的确认 | 确认筛选已建立,后续可通过 `filter update` 设置条件 |
| `filter delete` | 删除成功的确认 | 确认筛选已删除 |
| `filter update` | 更新成功的确认 | 确认条件已设置 |
| `filter clear-criteria` | 清除成功的确认 | 确认指定列的条件已清除 |
| `filter sort` | 排序成功的确认 | 确认排序已完成 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列
- `filter get` 获取工作表的全局筛选信息,返回 `range`(筛选范围)和 `columnFilterCriteria`(各列条件)。无筛选时返回空
- `filter create` 创建全局筛选时 `--range` 必须包含表头行(如 `A1:E100`),不能只包含数据行。每个工作表只能有一个筛选,已存在时报错
- `filter create` 的 `--criteria` 可选,不传则仅创建空筛选框架,后续通过 `filter update` 设置条件
- `filter delete` 删除后所有筛选条件丢失且所有被隐藏行重新显示,不可恢复
- `filter delete` 工作表没有筛选时调用会报错,应先 `filter get` 确认存在
- `filter update` 是覆盖式:指定列的条件会被替换,未指定的列保持不变。如只想修改某一列,建议先 `filter get` 读取现有配置再 patch
- `filter update` 前置:工作表必须已创建筛选
- `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等)
- `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选
- ★ **筛选操作规范**(参照飞书 core-operations):
- 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替
- 创建/更新筛选后**必须** `filter get` 回读验证配置正确
- 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作
- 筛选条件的列索引(`column`)必须与实际数据列精确对应,不要凭猜测填写
- 筛选不支持正则表达式,传入正则会当成普通文本处理
@@ -0,0 +1,239 @@
# 媒体上传与图片 (media & image)
## 使用场景
### 媒体上传
用户说"上传附件/传文件到表格/上传文件到表格/上传到表格":
- 上传附件 → `media-upload`(需表格 ID 或 URL + 本地文件路径)
- 用户指定了上传后的名称 → `media-upload --name "自定义名称"`
- `media-upload` 的 `--name` 参数用于指定附件在表格中显示的名称(不改变本地文件名);不传时默认使用本地文件名
用户说"写入图片/插入图片/加图片/放图片到单元格/嵌入图片到表格":
- 写入图片 → `write-image`(需表格 ID + 工作表 ID + 单元格范围 + 本地图片路径)
- 禁止使用 `range update` 写入图片,因为 `update_range` 的 MCP 工具不支持图片类型参数,调用必定失败。必须使用 `write-image` 命令
- 用户指定了图片尺寸 → `write-image --width N --height M`
### 浮动图片
用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图":
- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image`
- 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同
用户说"查看浮动图片/有哪些浮动图片/浮动图片列表":
- 列出所有浮动图片 → `list-float-images`
- 查看某个浮动图片详情 → `get-float-image`
用户说"移动浮动图片/调整浮动图片大小/修改浮动图片/更新浮动图片":
- 更新浮动图片属性 → `update-float-image`(可更新锚点位置、尺寸、偏移量、图片资源路径)
用户说"删除浮动图片/移除浮动图片":
- 删除浮动图片 → `delete-float-image`
关键区分:`write-image`(单元格内嵌图片,占据单元格内容)vs `create-float-image`(浮动图片,悬浮于单元格之上,不占内容)
## 命令详细参考
### 上传附件到表格
```
Usage:
dws sheet media-upload [flags]
Example:
dws sheet media-upload --node <NODE_ID> --file ./report.pdf
dws sheet media-upload --node <NODE_ID> --file ./data.bin --name "数据文件.dat" --mime-type application/octet-stream
Flags:
--node string 目标表格文档的标识,支持传入 URL 或 ID (必填)
--file string 本地文件路径 (必填)
--name string 附件显示名称 (默认使用文件名)
--mime-type string 文件 MIME 类型 (默认根据扩展名推断)
```
### 上传图片并写入表格单元格
```
Usage:
dws sheet write-image [flags]
Example:
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range A1:A1 --file ./chart.png
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./logo.png --width 200 --height 100
Flags:
--node string 目标表格文档的标识,支持传入 URL 或 ID (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 目标单元格区域地址,如 A1:A1 (必填)
--file string 本地图片文件路径 (必填)
--name string 图片显示名称 (默认使用文件名)
--mime-type string 文件 MIME 类型 (默认根据扩展名推断)
--width int 图片显示宽度 (可选)
--height int 图片显示高度 (可选)
```
### 创建浮动图片
```
Usage:
dws sheet create-float-image [flags]
Example:
# 先上传图片获取 resourceUrl
dws sheet media-upload --node <NODE_ID> --file ./chart.png
# 输出: resourceUrl: /core/api/resources/img/xxxx...
# 再创建浮动图片
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--src "/core/api/resources/img/xxxx..." --range A1 --width 400 --height 300
# 带偏移量
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--src "/core/api/resources/img/xxxx..." --range B2 --width 200 --height 150 --offset-x 10 --offset-y 20
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填)
--range string 锚点单元格,A1 表示法,如 A1、B3 (必填)
--width int 图片宽度,像素,正整数 (必填)
--height int 图片高度,像素,正整数 (必填)
--offset-x int 水平偏移量,像素 (默认 0)
--offset-y int 垂直偏移量,像素 (默认 0)
```
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL
- `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
- `--width` / `--height` 为必填,单位像素,必须为正整数
- `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数
### 获取浮动图片详情
```
Usage:
dws sheet get-float-image [flags]
Example:
dws sheet get-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--float-image-id string 浮动图片 ID (必填)
```
获取单个浮动图片的详细信息,包括 ID、图片资源路径、锚点位置、尺寸和偏移量。
`--float-image-id` 可通过 `list-float-images` 获取。
### 列出工作表所有浮动图片
```
Usage:
dws sheet list-float-images [flags]
Example:
dws sheet list-float-images --node <NODE_ID> --sheet-id <SHEET_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
```
列出指定工作表中所有浮动图片,返回 `floatImages` 数组和 `totalCount`。
### 更新浮动图片属性
```
Usage:
dws sheet update-float-image [flags]
Example:
# 移动浮动图片到新位置
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --range C5
# 调整尺寸
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --width 600 --height 400
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
--src "/core/api/resources/img/xxxx..."
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--float-image-id string 浮动图片 ID (必填)
--src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl
--range string 新的锚点单元格,A1 表示法
--width int 新的图片宽度,像素
--height int 新的图片高度,像素
--offset-x int 新的水平偏移量,像素
--offset-y int 新的垂直偏移量,像素
```
更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。
`--float-image-id` 可通过 `list-float-images` 获取。
### 删除浮动图片
```
Usage:
dws sheet delete-float-image [flags]
Example:
dws sheet delete-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--float-image-id string 浮动图片 ID (必填)
```
删除指定的浮动图片,操作不可恢复。`--float-image-id` 可通过 `list-float-images` 获取。
## 核心工作流
```bash
# ── 工作流 9: 上传附件到表格 ──
# 1. 基本用法: 上传本地文件到表格
dws sheet media-upload --node <NODE_ID> --file ./report.pdf -f json
# 2. 自定义附件显示名称 (--name 指定上传后在表格中显示的名称)
dws sheet media-upload --node <NODE_ID> --file ./data.csv --name "销售数据.csv" -f json
# 3. 指定 MIME 类型 (文件扩展名无法推断时)
dws sheet media-upload --node <NODE_ID> --file ./data.bin --name "导出数据.dat" --mime-type application/octet-stream -f json
# 4. 完整流程: 创建表格 → 上传附件
dws sheet create --name "项目资料" -f json
# 提取 nodeId 后:
dws sheet media-upload --node <NODE_ID> --file ./design.pdf -f json
dws sheet media-upload --node <NODE_ID> --file ./timeline.xlsx --name "项目时间线.xlsx" -f json
# ── 工作流 10: 写入图片到表格单元格 ──
# 1. 基本用法: 写入图片到指定单元格
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range A1:A1 --file ./chart.png -f json
# 2. 指定显示尺寸
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./logo.png --width 200 --height 100 -f json
# 3. 自定义图片名称
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range C3:C3 --file ./photo.jpg --name "产品图.jpg" -f json
# 4. 完整流程: 创建表格 → 写表头 → 写入图片
dws sheet create --name "产品目录" -f json
# 提取 nodeId 后:
dws sheet range update --node <NODE_ID> --sheet-id Sheet1 --range "A1:B1" --values '[["产品名称","产品图片"]]' -f json
dws sheet range update --node <NODE_ID> --sheet-id Sheet1 --range "A2:A2" --values '[["MacBook Pro"]]' -f json
dws sheet write-image --node <NODE_ID> --sheet-id Sheet1 --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `media-upload` | `resourceId`、`resourceUrl` | 附件已上传到表格;`resourceUrl` 可用于 `create-float-image` 的 `--src` |
| `write-image` | `resourceId` | 图片已写入指定单元格 |
| `create-float-image` | `floatImage`(含 `id`、`src`、`range`、`width`、`height`、`offsetX`、`offsetY`) | `id` 用于后续 get / update / delete 的 `--float-image-id` |
| `get-float-image` | `floatImage`(完整信息) | 查看单个浮动图片详情 |
| `list-float-images` | `floatImages` 数组、`totalCount` | 获取所有浮动图片的 `id`,用于后续操作 |
| `update-float-image` | `floatImage`(更新后的完整信息) | 确认更新结果 |
| `delete-float-image` | `message` | 确认删除完成 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作
- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 底层调用的 `update_range` MCP 工具不支持图片类型参数,调用会失败
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数
- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl`
- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个
- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete)
- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除
@@ -0,0 +1,153 @@
# 区域操作
## 使用场景
用户说"清空/清除区域/擦除内容/清除格式":
- 清除区域 → `range clear`
- 仅清除值 → `range clear --type content`(默认)
- 仅清除格式 → `range clear --type format`
- 全部清除 → `range clear --type all`
- 请勿用 `range update` 写入空字符串来模拟清空,`range clear` 更简洁且支持按类型清除
用户说"排序/给数据排序/按某列排序/升序/降序":
- 区域排序 → `range sort`
- **排序前必须先 `range read` 前 3-5 行**:读取排序范围的前几行(如范围是 A1:D100 则读 A1:D5),对比首行与后续行的模式来判断是否有表头:
- 首行全文本 + 后续行含数字/日期 → 有表头,加 `--has-header`
- 首行与后续行模式一致(都是数字或都是文本) → 无表头,不加
- 首行值语义像列标题(如"姓名""金额""日期")且与后续行明显不同 → 有表头
禁止不读就排——表头误排入数据是不可撤销的破坏性操作
- 请勿用 `range read` 读取数据后客户端排序再 `range update` 写回,`range sort` 是服务端原子操作
用户说"自动填充/填充序列/向下填充/拖拽填充/序列递增":
- 自动填充 → `range fill`
- 请勿用 `range read` 读取源数据后手动计算规律再 `range update` 写入,`range fill` 支持服务端智能填充
用户说"复制区域/把这块数据复制到/复制到另一个工作表":
- 复制区域 → `range copy-to`
- 跨工作表 → `range copy-to --target-sheet-id Sheet2` 或 `--target-range "Sheet2!A1"`
- 请勿用 `range read` + `range update` 读取再写入来模拟复制,`range copy-to` 是原子操作,保留公式引用调整
用户说"移动区域/把数据移到/剪切粘贴/移到另一个工作表":
- 移动区域 → `range move-to`
- 跨工作表 → `range move-to --target-sheet-id Sheet2` 或 `--target-range "Sheet2!A1"`
- 请勿用 `range read` + `range update` + `range clear` 读取-写入-清空来模拟移动,`range move-to` 是原子操作
## 命令详细参考
### 清除区域
```
Usage:
dws sheet range clear [flags]
Example:
dws sheet range clear --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3"
dws sheet range clear --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" --type format
dws sheet range clear --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" --type all
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 清除范围,A1 表示法 (必填)
--type string 清除类型: content(仅值,默认) / format(仅格式) / all(全部)
```
### 区域排序
```
Usage:
dws sheet range sort [flags]
Example:
dws sheet range sort --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" \
--sort-keys '[{"column":"A","ascending":true}]'
dws sheet range sort --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" \
--sort-keys '[{"column":"A","ascending":true},{"column":"C","ascending":false}]' --has-header
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 排序范围,A1 表示法 (必填)
--sort-keys string 排序规则 JSON 数组 (必填)
--has-header 首行是否为表头(不参与排序)
```
`--sort-keys` 格式:`[{"column":"A","ascending":true}]`,`column` 使用字母列名(如 "A"、"B"、"AA")。多级排序按数组顺序优先级递减。
### 区域自动填充
```
Usage:
dws sheet range fill [flags]
Example:
dws sheet range fill --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:A5" --target-range "A6:A20"
dws sheet range fill --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:A5" --target-range "A6:A20" --fill-type copy
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--source-range string 源数据范围,A1 表示法 (必填)
--target-range string 目标填充范围,A1 表示法 (必填)
--fill-type string 填充类型: series(序列,默认) / copy(复制) / onlystyle(仅格式) / withoutstyle(仅值)
```
目标范围须与源范围在行或列维度对齐(不支持对角填充)。
### 复制区域
```
Usage:
dws sheet range copy-to [flags]
Example:
dws sheet range copy-to --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:C5" --target-range "D1"
dws sheet range copy-to --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:C5" --target-range "A1" --target-sheet-id "Sheet2"
dws sheet range copy-to --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:C5" --target-range "D1" --paste-type values
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 源工作表 ID 或名称 (必填)
--source-range string 源范围,A1 表示法 (必填)
--target-range string 目标位置,A1 表示法 (必填)
--target-sheet-id string 目标工作表 ID 或名称(可选,不传则复制到同一工作表)
--paste-type string 粘贴类型: values(仅值) / formulas(仅公式) / formats(仅格式) / all(全部,默认)
```
支持跨工作表复制,两种方式指定目标工作表:
- `--target-sheet-id "Sheet2"` 显式指定
- `--target-range "Sheet2!A1"` 在目标范围中携带工作表前缀
源和目标范围不能重叠(同表时)。
### 移动区域
```
Usage:
dws sheet range move-to [flags]
Example:
dws sheet range move-to --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:C5" --target-range "D1"
dws sheet range move-to --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:C5" --target-range "A1" --target-sheet-id "Sheet2"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 源工作表 ID 或名称 (必填)
--source-range string 源范围,A1 表示法 (必填)
--target-range string 目标位置,A1 表示法 (必填)
--target-sheet-id string 目标工作表 ID 或名称(可选,不传则移动到同一工作表)
```
支持跨工作表移动,两种方式指定目标工作表:
- `--target-sheet-id "Sheet2"` 显式指定
- `--target-range "Sheet2!A1"` 在目标范围中携带工作表前缀
源和目标范围不能重叠(同表时)。移动后源区域将被清空。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | 工作表的 `sheetId` | range clear / range sort / range fill / range copy-to / range move-to 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
- ★ **清空区域用 `range clear` 不用 `range update`**:`range clear` 支持按类型(值/格式/全部)清除,比手动构造全空数组更简洁可靠
- ★ **复制区域用 `range copy-to` 不用 `range read` + `range update`**:原子操作,保留公式引用自动调整,支持跨工作表
- ★ **移动区域用 `range move-to` 不用 `range read` + `range update` + `range clear`**:原子操作,源区域自动清空,支持跨工作表
- ★ **排序用 `range sort` 不用 `range read` + 客户端排序 + `range update`**:服务端原子操作,支持多级排序
- ★ **排序前必须 `range read` 前几行判断表头**:读取排序范围前 3-5 行,对比首行与后续行的数据模式(类型、语义)来判断是否有表头。禁止不读就排,表头被排入数据不可撤销
- ★ **填充用 `range fill` 不用 `range read` + 手动计算 + `range update`**:服务端智能填充,支持序列递增、公式扩展等
@@ -0,0 +1,204 @@
# 数据读取
## 使用场景
用户说"读数据/看表格内容":
- 快速查看纯值数据、批量处理、大表分批读 → `csv-get`(token 消耗低,防爆保护)
- 需要结构化信息(值+样式+数据验证+富文本+单元格级超链接)、查看公式或原始值 → `range read`
- 需要查看合并单元格 / 表头合并结构 → `sheet info`,读取返回的 `mergedRanges`;不要在 `csv-get` 或 `range read` 里找合并信息
## 命令选择
| 读取目的 | 推荐命令 | 说明 |
|---------|---------|------|
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 |
| 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation |
| 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) |
| 查看单元格级超链接 | `range read` | 返回 per-cell 结构,含 hyperlink;富文本片段链接仍在 richText 内 |
| 查看公式文本 | `range read --value-render-option formula` | value 返回公式 |
| 获取原始值(数字/布尔而非格式化字符串) | `range read --value-render-option raw_value` | value 返回原始类型 |
| 查看合并单元格范围 | `sheet info` | 返回 `mergedRanges`,这是工作表结构信息,不属于单元格值读取 |
## 命令详细参考
### 以 CSV 格式读取工作表数据(推荐)
```
Usage:
dws sheet csv-get [flags]
Example:
dws sheet csv-get --node <NODE_ID>
dws sheet csv-get --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10"
dws sheet csv-get --node <NODE_ID> --range "A1:Z500" --value-render-option raw_value
dws sheet csv-get --node <NODE_ID> --range "A1:D10" --max-chars 50000
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (不传则默认第一个工作表)
--range string 读取范围,A1 表示法 (不传则读取全部非空数据)
--value-render-option string 取值模式: formatted_value(默认) | raw_value | formula
--max-chars int CSV 最大字符数 (默认 200000,超出截断)
```
**返回字段说明**:
- `csv` — CSV 文本,每逻辑行前加 `[row=N]` 前缀标注真实表格行号。行号一律从此前缀读取,禁止手算
- `colIndices` — 列字母映射数组(如 `["A","B","C"]`)。定位列字母用 `colIndices[j]`,禁止手数逗号
- `rowIndices` — 行号映射数组(如 `[1,2,3]`)
- `hasMore` — 是否因 maxChars 截断。为 true 时需要调整 `--range` 继续分页读取
`csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。
**取值模式说明**:
| 模式 | 返回内容 | 适用场景 |
|------|---------|---------|
| `formatted_value` | 格式化展示值(如 ¥1,000.00、2025-06-01) | 只看数据 |
| `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 |
| `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 |
**大表分批读取**:当 `hasMore=true` 或数据量很大时,按行窗口分批:
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
- 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` ……
- 单次建议 ≤5000 单元格
### 读取工作表数据(per-cell 结构化信息)
```
Usage:
dws sheet range read [flags] # 别名: dws sheet range get
Example:
dws sheet range read --node <NODE_ID>
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet range read --node <NODE_ID> --sheet-id "Sheet1" --range "A1:D10"
dws sheet range read --node <NODE_ID> --range "Sheet1!A1:D10"
dws sheet range read --node <NODE_ID> --value-render-option raw_value
dws sheet range read --node <NODE_ID> --value-render-option formula
# 使用 get 别名,与 read 等价
dws sheet range get --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (不传则默认第一个工作表)
--range string 读取范围,A1 表示法 (如 A1:D10,不传则读取全部数据)
--value-render-option string 取值模式: formatted_value(默认) | raw_value | formula
```
**返回字段说明**:
- `cells` — 二维数组,第一维为行,第二维为列。每个元素为 per-cell 对象,字段如下:
| 字段 | 类型 | 是否必有 | 说明 |
|------|------|---------|------|
| `value` | string / number / boolean / null | 始终存在 | 单元格值。`formatted_value` 模式为 string;`raw_value` 模式为原始类型(number/boolean/string/null);`formula` 模式为公式字符串或回退原始值 |
| `dataValidation` | object | 仅有数据验证时出现,无则省略 | 数据验证配置,见下表 |
| `hyperlink` | object | 仅有单元格级超链接时出现,无则省略 | 整格超链接,结构为 `{type, link, text?}`,见下表 |
| `richText` | object | 仅富文本单元格出现 | 富文本结构(含超链接、附件、图片、样式片段等),普通纯文本不含此字段 |
| `cellStyles` | object | 仅有显式设置的样式时出现;MCP 序列化层也可能返回全 null 空壳 | cell-level 样式,见下表。读取时只看非 null 字段;全 null 等同不存在 |
`range read` / `range get` 不返回合并单元格结构。要看合并单元格,请先或另行调用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json`,使用其中的 `mergedRanges`。
**dataValidation 结构**:
| type | 字段 | 说明 |
|------|------|------|
| `dropdown` | `options: [{value: string, color?: string}]` | 下拉选项列表 |
| `dropdown` | `enableMultiSelect: boolean` | 是否允许多选 |
| `checkbox` | `checked: boolean` | 当前勾选状态 |
**hyperlink 结构**:
| type | 字段 | 说明 |
|------|------|------|
| `path` | `link` + 可选 `text` | 外部 URL 链接 |
| `sheet` | `link` + 可选 `text` | 工作表链接,`link` 为工作表 ID 或名称 |
| `range` | `link` + 可选 `text` | 单元格范围链接,`link` 为 A1 表示法,如 `Sheet1!A4` |
**richText 结构**:
`richText` 表示单元格内的富文本片段,常见结构为 `{type:"richText", texts:[...]}`。`texts` 数组内每个子项代表一个片段:
| 子项 type | 常见字段 | 说明 |
|-----------|----------|------|
| `text` | `text` / `style` | 普通文本片段;`style` 是片段级样式 |
| `link` | `text` / `link` / `subType` / `style` | 富文本片段链接。`subType` 不存在时按 `path` 理解;`path` 表示外部 URL,`sheet` 表示工作表链接,`range` 表示单元格范围链接 |
| `attachment` | `text` / `resourceId` / `mimeType` / `size` | 附件片段 |
| `image` | `resourceId` / `resourceUrl` / `width` / `height` | 图片片段 |
`richText.texts[].link.subType` 与 cell-level `hyperlink.type` 含义一致,但作用范围不同:`hyperlink` 是整个单元格可点击,richText `link` 只作用于该文本片段。读取到 `subType:"sheet"` 时,`link` 通常是真实工作表名称;读取到 `subType:"range"` 时,`link` 通常是 A1 范围(如 `Sheet2!A1:B20`)。不要把 richText 片段链接误当成整格 `hyperlink`。
**cellStyles 字段说明**(仅关注显式设置过的非 null 属性;未设置属性不存在或为 null,应忽略):
| 字段 | 类型 | 说明 |
|------|------|------|
| `fontWeight` | string | `bold` / `normal` |
| `fontColor` | string | 字体颜色,`#RRGGBB` |
| `fontSize` | number | 字号 |
| `fontStyle` | string | `italic` / `normal` |
| `backgroundColor` | string | 背景色,`#RRGGBB` |
| `horizontalAlignment` | string | `left` / `center` / `right` / `general` |
| `verticalAlignment` | string | `top` / `middle` / `bottom` |
| `wordWrap` | string | `overflow` / `clip` / `autoWrap` |
| `numberFormat` | string | 数字格式代码,如 `@`、`#,##0.00`、`yyyy/m/d`;`@` 表示文本 |
| `textUnderline` | boolean | 下划线 |
| `textLineThrough` | boolean | 删除线 |
**返回示例**:
```json
{
"cells": [
[
{"value": "姓名", "cellStyles": {"fontWeight": "bold", "backgroundColor": "#FFF2CC"}},
{"value": "状态", "cellStyles": {"fontWeight": "bold"}, "dataValidation": {"type": "dropdown", "options": [{"value": "进行中"}, {"value": "已完成", "color": "#52C41A"}], "enableMultiSelect": false}}
],
[
{"value": "张三"},
{"value": "钉钉", "hyperlink": {"type": "path", "link": "https://dingtalk.com", "text": "钉钉"}}
]
],
"message": "Successfully retrieved cell data.",
"success": true
}
```
说明:第一行表头有 `cellStyles`(加粗 + 背景色),第二行第二格有单元格级 `hyperlink`。注意:MCP 平台序列化会将未设置的字段填充为 null(如 `"fontStyle": null`),读取时应忽略值为 null 的字段,仅关注非 null 的属性;如果 `cellStyles` 全字段都是 null,视同不存在。`richText` 字段同理——无富文本的普通单元格可能返回 `{"type": null, "texts": null}`,视同不存在。
**取值模式说明**:
| 模式 | value 返回内容 | 适用场景 |
|------|---------|---------|
| `formatted_value` | 格式化展示值(如 ¥1,000.00、2025-06-01) | 只看数据(默认) |
| `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 |
| `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 |
**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略:
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
- 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` ……
- 避免不传 `--range` 直接读取整个大工作表
## 核心工作流
```bash
# ── 工作流: 读取已有表格数据 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 查看工作表详情(行列数、最后非空位置、mergedRanges 等)
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 3. 读取全部数据
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 4. 读取指定区域
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | 工作表的 `sheetId` | info / range read 的 --sheet-id |
| `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` | 确定数据范围、分页读取边界、识别合并单元格结构 |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
- `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据
- `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`)
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 获取边界后分批读取,避免不传 `--range` 直接读取整个大工作表
- ★ 当用户要求搜索/查找表格数据时,使用 `find` 命令,不要用 `range read` 读取全量数据后自行过滤——`find` 支持服务端搜索,效率更高、语义更准确
@@ -0,0 +1,118 @@
# 搜索与替换
## 使用场景
用户说"搜索/查找/找单元格/搜内容/精确搜索/精确匹配/完全匹配/全字匹配":
- 搜索单元格 → `find`
- 精确匹配(只匹配完全等于的,不匹配包含的) → `find --match-entire-cell`
- 正则搜索 → `find --use-regexp`
- 搜索公式 → `find --match-formula`
- 不要用 `range read` 读取全量数据后在客户端过滤来替代 `find`,必须使用 `find` 命令的服务端搜索能力
用户说"替换/查找替换/全局替换/批量替换/把A替换成B/把所有的X改成Y":
- 查找替换 → `replace`
- 精确匹配后替换(只替换内容完全等于的单元格) → `replace --match-entire-cell`
- 正则替换 → `replace --use-regexp`
- 删除匹配内容 → `replace --replacement ""`
- 请勿用 `find` + `range update`、`range read` + `range update` 等组合来模拟替换,`replace` 是服务端原子操作,效率更高且返回替换计数
## 命令详细参考
### 在工作表中搜索单元格内容
```
Usage:
dws sheet find [flags]
Example:
# 基本搜索
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "销售额"
# 在指定范围内搜索
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "合计" --range "A1:D100"
# 正则表达式搜索(不区分大小写)
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "^total" --use-regexp --match-case=false
# 精确匹配整个单元格内容
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "完成" --match-entire-cell
# 搜索公式文本
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "SUM" --match-formula
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--find string 搜索文本 (必填)
--range string 搜索范围,A1 表示法 (如 A1:D10)
--match-case 区分大小写 (默认 true)
--match-entire-cell 精确匹配整个单元格内容
--use-regexp 启用正则表达式搜索
--match-formula 搜索公式文本而非显示值
--include-hidden 包含隐藏单元格
```
### 全局查找替换
```
Usage:
dws sheet replace [flags]
Example:
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "旧文本" --replacement "新文本"
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "待处理" --replacement "已完成" --match-entire-cell
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "\\d{4}" --replacement "****" --use-regexp
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "旧" --replacement "新" --range "A1:D100"
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "临时" --replacement ""
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--find string 查找文本 (必填)
--replacement string 替换文本 (必填,可为空字符串表示删除)
--range string 替换范围,A1 表示法 (如 A1:D100)
--match-case 区分大小写 (默认 false)
--match-entire-cell 完整单元格匹配
--use-regexp 启用正则表达式匹配
--include-hidden 包含隐藏行/列
```
返回被替换的单元格数量。`--replacement` 可以为空字符串,表示删除匹配内容。
## 核心工作流
```bash
# ── 工作流: 搜索表格数据 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 基本搜索 — 在指定工作表中查找文本
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "销售额" --format json
# 3. 在指定范围内搜索
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "合计" --range "A1:D100" --format json
# 4. 正则搜索(不区分大小写)
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "^total" --use-regexp --match-case=false --format json
# 5. 精确匹配整个单元格
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "完成" --match-entire-cell --format json
# 6. 搜索公式文本
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "SUM" --match-formula --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | 工作表的 `sheetId` | find / replace 的 --sheet-id |
| `find` | `matchedCells` 中的 `a1Notation` | 定位目标单元格,用于 range read / range update |
| `replace` | `replaceCount` 被替换的单元格数量 | 确认替换结果 |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
- ★ **搜索用 `find` 不用 `range read`**:`find` 是服务端搜索,禁止用 `range read` 全量读取后客户端过滤
- ★ **替换用 `replace` 不用 `range update`**:`replace` 是服务端原子操作,返回替换计数
- `find` 返回匹配单元格的地址(A1 表示法)和值,无匹配时返回空数组
- `find` 的 `--match-entire-cell` 用于精确匹配:只返回单元格内容完全等于搜索文本的结果,不会匹配包含该文本的单元格(例如搜索"苹果"时,只匹配"苹果",不匹配"苹果手机""苹果汁"等)。用户说"精确搜索/完全匹配/只搜等于XX的"时必须使用此参数
- `find` 的 `--match-case` 默认为 true(区分大小写),设为 false 可忽略大小写
- `find` 的 `--use-regexp` 启用后,`--find` 参数作为正则表达式处理
- `replace` 的 `--find` 不能为空字符串,`--replace` 可以为空字符串(表示删除匹配内容)
- `replace` 的 `--match-case` 默认为 false(不区分大小写),与 `find` 的默认行为不同
@@ -0,0 +1,274 @@
# 单元格格式与合并 (style & format)
## 三种样式设置方式
钉钉表格支持三种样式设置方式,适用不同场景:
| 方式 | 命令 / 字段 | 适用场景 | 粒度 |
|------|------------|---------|------|
| **`set-style` / `batch-set-style`** | `dws sheet range set-style` | 批量刷整片区域的统一样式(表头加粗居中、数字格式等) | range 级别(2D 数组或全 range 统一值) |
| **`cellStyles`**(`range update` 内) | `--values` 中每个 cell 的 `cellStyles` 字段 | 写值同时附带样式,少量 cell 一步到位 | per-cell 级别 |
| **`style`**(richText 片段样式) | `--values` 中 richText 子项(`text`/`link`)的 `style` | 同一单元格内不同文字有不同字体样式 | 文本片段级别 |
选择建议:
- 只设样式不改值 → `set-style` / `batch-set-style`
- 写值 + 样式一步到位(少量 cell) → `range update` + `cellStyles`
- 文本内部分段样式("重要"红色加粗,其余正常) → `range update` + `type:"richText"` 子项 `style`
- 大面积统一样式 → `set-style`(单值刷 range)或 `batch-set-style`(多 range 批量)
注意:`set-style` / `batch-set-style` 和 `range update` 的 `cellStyles` 最终都作用于 cell-level 样式,效果相同。区别在于调用方式——前者是独立命令,后者嵌在写值调用中。`range read` 返回的 `cellStyles` 字段能读回所有显式设置过的 cell-level 样式,无论是通过哪种方式设置的。
`type:"text"` 顶层旧 `style` 字段不要作为新写法使用;整格样式用 `cellStyles`,分段样式才用 richText 子项 `style`。
## 使用场景
### 单元格格式
用户说"设置样式/改颜色/设背景色/加粗/居中/换行/字体颜色/字号":
- 仅设样式不改值 → `range set-style`
- 批量设置不同 range 的样式 → `range batch-set-style --batch ./styles.json`(内部顺序循环调 `update_range`)
- 写值同时附带样式 → `range update --values` 中使用 `cellStyles` 字段(参见 sheet-write-data.md)
- 请勿用 `range update --values` 写空/重写来模拟纯样式变更
用户说"设置数字格式/改成百分比/用人民币显示/按日期显示/文本格式/保留几位小数":
- 批量设置数字格式 → `range set-style --number-format <格式代码>`(如 `0%` / `"¥"#,##0.00` / `yyyy/m/d` / `@`)
- 写值时顺带设置数字格式 → `range update` 中 `cellStyles.numberFormat`
用户说"合并单元格/合并/合并区域/按行合并/按列合并":
- 合并所有单元格 → `merge-cells`(默认 mergeAll)
- 按行合并 → `merge-cells --merge-type mergeRows`
- 按列合并 → `merge-cells --merge-type mergeColumns`
用户说"取消合并/拆分单元格/还原合并":
- 取消合并单元格 → `unmerge-cells`
## 命令详细参考
### 设置单元格样式
```
Usage:
dws sheet range set-style [flags]
Example:
# 给 A1:B3 打上黄底粗体居中
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" \
--bg-color "#FFF2CC" --font-weight bold --h-align center
# 给 C1:C5 逐单元格设置不同背景色
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "C1:C5" \
--bg-colors-json '[["#FF0000"],["#00FF00"],["#0000FF"],["#FFFF00"],["#FF00FF"]]'
# 整片 range 启用自动换行
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E10" --word-wrap autoWrap
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 目标区域,如 A1:B3 (必填)
--bg-color string 背景色(#RRGGBB),一键刷整个 range;与 --bg-colors-json 二选一
--bg-colors-json string 背景色二维 JSON 数组,维度需与 --range 一致
--font-size int 字号,一键刷整个 range;与 --font-sizes-json 二选一
--font-sizes-json string 字号二维 JSON 数组
--h-align string 水平对齐:left/center/right/general
--h-aligns-json string 水平对齐二维 JSON 数组
--v-align string 垂直对齐:top/middle/bottom
--v-aligns-json string 垂直对齐二维 JSON 数组
--font-color string 字体颜色(#RRGGBB)
--font-colors-json string 字体颜色二维 JSON 数组
--font-weight string 字体粗细:bold/normal
--font-weights-json string 字体粗细二维 JSON 数组
--word-wrap string 换行方式:overflow/clip/autoWrap(整个 range 共用)
--number-format string 数字格式代码,如 General/@/#,##0/#,##0.00/0%/0.00%/yyyy/m/d/h:mm:ss
```
**特性说明**:
- 每个样式维度提供两种写法,二选一:`--xxx`(单值刷整个 range,CLI 本地展开为二维数组)vs `--xxx-json`(逐单元格指定,维度需与 `--range` 完全一致)
- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000
- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center` 等
### 批量设置单元格样式
```
Usage:
dws sheet range batch-set-style [flags]
Example:
dws sheet range batch-set-style --node <NODE_ID> --batch ./styles.json
dws sheet range batch-set-style --node <NODE_ID> --batch ./styles.json --continue-on-error
Flags:
--node string 表格文档 ID 或 URL (必填)
--batch string 批次配置 JSON 文件路径 (必填)
--continue-on-error 遇到失败时继续执行后续条目(默认遇错即停)
```
配置文件格式(JSON 数组,每个元素一条批次项):
```json
[
{
"sheetId": "Sheet1",
"range": "A1:B3",
"bgColor": "#FFF2CC",
"fontSize": 12,
"hAlign": "center",
"vAlign": "middle",
"fontColor": "#333333",
"fontWeight": "bold",
"wordWrap": "autoWrap",
"numberFormat": "General"
},
{
"sheetId": "Sheet1",
"range": "C1:C5",
"bgColorsJson": "[[\"#FF0000\"],[\"#00FF00\"],[\"#0000FF\"],[\"#FFFF00\"],[\"#FF00FF\"]]"
}
]
```
**特性说明**:
- CLI 侧顺序循环逐条调用 `update_range`(非服务端批量),运行时输出 `[N/M]` 进度
- 每条记录执行与 `set-style` 一致的校验:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法
- 默认遇错即停(返回非 0),`--continue-on-error` 时所有条目跑完再返回首个错误
### 合并单元格
```
Usage:
dws sheet merge-cells [flags]
Example:
# 合并所有单元格(默认)
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3"
# 按行合并
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeRows
# 按列合并
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeColumns
# 使用带工作表前缀的范围(忽略 --sheet-id)
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "Sheet1!A1:B3"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 目标单元格区域地址,如 A1:B3 (必填)
--merge-type string 合并方式: mergeAll(默认)/mergeRows/mergeColumns
```
支持三种合并方式:
- `mergeAll`(默认):合并所有单元格,将选定区域内的所有单元格合并成一个
- `mergeRows`:按行合并,在选定区域内将同一行相邻的单元格合并
- `mergeColumns`:按列合并,在选定区域内将同一列相邻的单元格合并
注意:合并时只保留左上角单元格的值,其他单元格的值会被丢弃。
`--range` 支持带工作表前缀的写法(如 `Sheet1!A1:B3`),此时将优先使用前缀解析出的工作表,忽略 `--sheet-id`。
合并完成后,可通过 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查看 `mergedRanges` 验证合并结构。
### 取消合并单元格
```
Usage:
dws sheet unmerge-cells [flags]
Example:
dws sheet unmerge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D5"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 取消合并的范围,A1 表示法 (必填)
```
取消指定范围内所有合并的单元格,恢复为独立单元格。
## number-format 格式 code
适用范围:`number-format` 在 `range set-style` / `range batch-set-style` 中接受(CLI 对应 `--number-format`,batch 配置文件对应 `numberFormat`)。`range update` 没有 `--number-format` 参数,但可在写入值时通过每个 cell 的 `cellStyles.numberFormat` 设置同样的数字格式。
商品 ID、规格 ID、SKU、订单号、手机号、工号等数字形态标识符,使用文本格式 code:`@`。
常用格式:
| 格式类型 | 推荐 code | 展示示例 | 适用场景 |
| --- | --- | --- | --- |
| 常规 | `General` | `1234` / `普通文本` | 普通文本/数字展示 |
| 文本 | `@` | `528545015680` | 商品 ID、规格 ID、SKU、订单号、手机号、工号 |
| 整数 | `0` | `1235` | 数量、计数 |
| 两位小数 | `0.00` | `1234.50` | 单价、评分 |
| 整数千分位 | `#,##0` | `1,235` | 数量、金额整数 |
| 千分位两位小数 | `#,##0.00` | `1,234.50` | 金额、单价 |
| 百分比 | `0%` / `0.00%` | `85%` / `85.00%` | 转化率、占比 |
| 日期 | `yyyy/m/d` | `2026/3/15` | 日期列 |
| 日期时间 | `yyyy/m/d h:mm` | `2026/3/15 14:30` | 日期时间列 |
| 时间 | `h:mm` / `h:mm:ss` | `14:30` / `14:30:05` | 时间列 |
| 科学计数法 | `0.00E+00` / `##0.0E+0` | `1.23E+05` | 科学数据 |
| 人民币 | `"¥"#,##0_);("¥"#,##0)` / `"¥"#,##0.00_);("¥"#,##0.00)` | `¥1,235` / `¥1,234.50` | 金额列 |
| 美元 | `$#,##0_);($#,##0)` / `$#,##0.00_);($#,##0.00)` | `$1,235` / `$1,234.50` | 金额列 |
选择规则:没有特殊展示要求时,优先使用上面的常用格式。只有用户明确要求负数显示方式、中文日期、12 小时制、累计时长、分数或会计格式时,再选择下面的可选变体。
可选变体:
| 用户要求 | 推荐 code | 推荐展示示例 | 可选 code(差异) |
| --- | --- | --- | --- |
| 负数用括号显示 | `#,##0 ;(#,##0)` | `(1,235)` | `#,##0.00;(#,##0.00)`:保留两位小数,如 `(1,234.50)` |
| 负数标红显示 | `#,##0 ;[red](#,##0)` | 红色 `(1,235)` | `#,##0.00;[red](#,##0.00)`:保留两位小数,如红色 `(1,234.50)` |
| 分数 | `# ?/?` | `1 1/2` | `# ??/??`:分母最多两位,如 `1 23/32` |
| 英文月份日期 | `d-mmm-yy` | `15-Mar-26` | `d-mmm`:省略年份,如 `15-Mar`;`mmm-yy`:只显示月年,如 `Mar-26` |
| 中文日期 | `yyyy"年"m"月"d"日"` | `2026年3月15日` | `yyyy"年"m"月"`:只显示年月,如 `2026年3月`;`m"月"d"日"`:只显示月日,如 `3月15日` |
| 12 小时制时间 | `h:mm AM/PM` | `2:30 PM` | `h:mm:ss AM/PM`:显示秒,如 `2:30:05 PM` |
| 中文上午/下午时间 | `上午/下午 h"时"mm"分"` | `下午 2时30分` | `上午/下午 h"时"mm"分"ss"秒"`:显示秒,如 `下午 2时30分05秒` |
| 分秒/累计时长 | `mm:ss` | `05:30` | `[h]:mm:ss`:累计小时,如 `27:05:30`;`mm:ss.0`:显示十分之一秒,如 `05:30.5` |
| 人民币负数标红 | `"¥"#,##0_);[red]("¥"#,##0)` | 红色 `(¥1,235)` | `"¥"#,##0.00_);[red]("¥"#,##0.00)`:保留两位小数,如红色 `(¥1,234.50)` |
| 美元负数标红 | `$#,##0_);[Red]($#,##0)` | 红色 `($1,235)` | `$#,##0.00_);[Red]($#,##0.00)`:保留两位小数,如红色 `($1,234.50)` |
| 会计数字 | `_(* #,##0_);_(* (#,##0);_(* "-"_);_(@_)` | `1,235`,零值显示 `-` | `_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)`:保留两位小数,如 `1,234.50` |
| 人民币会计格式 | `_("¥"* #,##0_);_("¥"* (#,##0);_("¥"*"-"_);_(@_)` | `¥ 1,235`,零值显示 `¥ -` | `_("¥"* #,##0.00_);_("¥"*(#,##0.00);_("¥"* "-"??_);_(@_)`:保留两位小数,如 `¥ 1,234.50` |
## 核心工作流
```bash
# ── 工作流 4: 写入数据并设置样式 ──
# 1. 写入数据
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" \
--values '[["商品","单价","数量"],["苹果",5.5,100],["香蕉",3.2,200]]' --format json
# 2. 设置数字格式(人民币)
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "B2:B3" \
--number-format '"¥"#,##0.00' --format json
# 3. 商品 ID / 规格 ID 按文本展示,避免科学计数法
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A3" \
--number-format "@" --format json
# 4. 写入单元格级超链接
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "D1" \
--values '[[{"type":"text","text":"详情","hyperlink":{"type":"path","link":"https://dingtalk.com"}}]]' --format json
```
```bash
# ── 工作流 8: 合并单元格 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 合并所有单元格(默认 mergeAll)
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" --format json
# 3. 按行合并
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeRows --format json
# 4. 按列合并
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeColumns --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `merge-cells` | `a1Notation` 实际被合并的范围、`mergeType` 生效的合并方式 | 确认合并结果 |
| `unmerge-cells` | `sheetId` 工作表 ID | 确认操作完成 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错
- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`;背景色/字体颜色统一使用 `#RRGGBB` 格式
- `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style`
- `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃
- `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格)
- `merge-cells` 的 `--range` 支持带工作表前缀的写法(如 `Sheet1!A1:B3`),此时忽略 `--sheet-id`
- `merge-cells` 如果目标区域与其他合并单元格、锁定区域或表格区域存在交集,合并将失败
- `unmerge-cells` 取消指定范围内所有合并单元格,使用 A1 表示法指定范围
- 对已有表格做格式延续、插入列后修复表头、或写入前临时取消合并时,先记录 `sheet info` 返回的 `mergedRanges`,操作后按需用 `merge-cells` 恢复
@@ -0,0 +1,242 @@
# 表格与工作表管理
## 使用场景
用户说"创建表格/新建电子表格":
- 创建表格文档 → `create`
用户说"看工作表/有哪些工作表/表格结构":
- 列出工作表 → `list`
- 工作表详情 → `info`
用户说"加工作表/新增Sheet":
- 新建工作表 → `new`
用户说"修改工作表名称/重命名工作表/移动工作表位置/隐藏工作表/显示工作表/冻结行/冻结列/取消冻结/更新工作表属性":
- 更新工作表属性 → `update`
- 重命名工作表 → `update --name "新名称"`
- 移动工作表位置 → `update --index N`
- 隐藏工作表 → `update --hidden`
- 显示工作表 → `update --hidden=false`
- 冻结行列 → `update --frozen-row-count N --frozen-column-count M`
- 取消冻结 → `update --frozen-row-count 0 --frozen-column-count 0`
用户说"复制工作表/拷贝工作表/克隆工作表/工作表副本":
- 复制工作表 → `copy`
- 复制并指定名称 → `copy --name "副本名称"`
- 复制并指定位置 → `copy --index N`
用户说"删除工作表/移除工作表/删掉这个Sheet":
- 删除工作表 → `delete-sheet`(不可逆操作,执行前必须向用户确认)
## 命令详细参考
### 创建钉钉表格文档
```
Usage:
dws sheet create [flags]
Example:
dws sheet create --name "销售数据"
dws sheet create --name "Q1 数据" --folder <FOLDER_ID>
dws sheet create --name "知识库表格" --workspace <WS_ID>
Flags:
--name string 表格名称 (必填)
--folder string 目标文件夹 ID (dentryUuid 格式) 或 URL;禁止传入纯数字 dentryId
--workspace string 目标知识库 ID
```
> **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。
### 获取全部工作表列表
```
Usage:
dws sheet list [flags]
Example:
dws sheet list --node <NODE_ID>
dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
Flags:
--node string 表格文档 ID 或 URL (必填)
```
### 获取指定工作表详情
```
Usage:
dws sheet info [flags]
Example:
dws sheet info --node <NODE_ID>
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet info --node <NODE_ID> --sheet-id "Sheet1"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (不传则返回第一个工作表)
```
返回字段中 `mergedRanges` 是当前工作表的合并单元格范围列表(A1 表示法,如 `["C7:D11"]`)。它属于工作表结构/布局元数据:读写单元格内容前,如需判断表头、分组标题、续写位置或避开合并冲突,应先看 `sheet info`,不要在 `range read` / `csv-get` 的单元格值里寻找合并信息。
### 新建工作表
```
Usage:
dws sheet new [flags]
Example:
dws sheet new --node <NODE_ID> --name "Sheet2"
dws sheet new --node <NODE_ID> --name "数据汇总"
Flags:
--node string 表格文档 ID (必填)
--name string 工作表名称 (必填)
```
### 更新工作表属性
```
Usage:
dws sheet update [flags]
Example:
# 改名 + 调整冻结
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --name "汇总表" --frozen-row-count 2 --frozen-column-count 1
# 隐藏工作表
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --hidden=true
# 显示工作表
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --hidden=false
# 移动工作表到第一个位置
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --index 0
# 取消冻结
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --frozen-row-count 0 --frozen-column-count 0
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--name string 新名称,最长 100 字符,不能包含 / \ ? * [ ] :
--index int 新位置(从 0 开始)
--hidden --hidden=true 隐藏,--hidden=false 取消隐藏
--frozen-row-count int 冻结行数,0 表示取消冻结
--frozen-column-count int 冻结列数,0 表示取消冻结
```
更新工作表名称、位置、隐藏状态、冻结行列。
`--name` / `--index` / `--hidden` / `--frozen-row-count` / `--frozen-column-count` 至少提供一个;多个属性可同时传入,将在同一次请求中更新。
注意:
- 至少需要保留一个可见的工作表,不能将所有工作表都隐藏
- 冻结行数/列数不能超过工作表的总行数/列数
### 复制工作表
```
Usage:
dws sheet copy [flags]
Example:
# 按默认位置复制
dws sheet copy --node <NODE_ID> --sheet-id <SHEET_ID>
# 指定副本名称和位置
dws sheet copy --node <NODE_ID> --sheet-id <SHEET_ID> --name "销售副本" --index 2
# 只指定名称
dws sheet copy --node <NODE_ID> --sheet-id <SHEET_ID> --name "备份"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 源工作表 ID 或名称 (必填)
--name string 副本名称,最长 100 字符,不能包含 / \ ? * [ ] : (不传则系统自动生成)
--index int 副本位置(从 0 开始)(不传则放在源工作表之后)
```
复制指定工作表,在同一表格中创建一个副本。
复制操作会将源工作表的所有内容(包括数据、格式、公式等)完整复制到新工作表中。
传 `--index` 时,CLI 会先复制,再追加一次位置更新,把副本移动到目标索引。
名称与已有工作表重复时系统会自动重命名。
### 删除工作表
```
Usage:
dws sheet delete-sheet [flags]
Example:
dws sheet delete-sheet --node <NODE_ID> --sheet-id <SHEET_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 要删除的工作表 ID 或名称 (必填)
```
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
删除指定的工作表及其所有数据。约束:
- 不能删除隐藏的工作表(需先通过 `sheet update --hidden false` 取消隐藏再删除)
- 不能删除最后一个可见工作表(至少保留一个可见工作表)
## 核心工作流
```bash
# ── 工作流 1: 创建表格并写入数据 ──
# 1. 创建表格文档 — 提取 nodeId
dws sheet create --name "销售数据" --format json
# 2. 查看工作表列表 — 提取 sheetId
dws sheet list --node <NODE_ID> --format json
# 3. 写入表头和数据
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C1" \
--values '[["姓名","部门","销售额"]]' --format json
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:C4" \
--values '[["张三","销售部",50000],["李四","市场部",38000],["王五","销售部",62000]]' --format json
# ── 工作流 2: 读取已有表格数据 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 查看工作表详情(行列数、最后非空位置等)
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 3. 读取全部数据
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 4. 读取指定区域
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --format json
# ── 工作流 3: 多工作表管理 ──
# 1. 新建工作表
dws sheet new --node <NODE_ID> --name "汇总" --format json
# 2. 在新工作表中写入汇总公式
dws sheet range update --node <NODE_ID> --sheet-id <NEW_SHEET_ID> --range "A1:B1" \
--values '[["指标","数值"]]' --format json
dws sheet range update --node <NODE_ID> --sheet-id <NEW_SHEET_ID> --range "A2:B2" \
--values '[["总销售额","=SUM(Sheet1!C2:C100)"]]' --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `create` | `nodeId` | list / info / new / range read / range update / find 的 --node |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
| `new` | 新工作表的 `sheetId` | range read / range update / find 的 --sheet-id |
| `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` | 确定数据范围、追加写入起始行、判断合并单元格结构 |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:所有涉及 `--sheet-id` 参数的命令,除非用户主动提供了工作表 ID 或工作表名称,否则在 `sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
- `mergedRanges` 中的范围表示一个整体语义区域。合并区域内非左上角单元格为空并不代表无内容,通常应以左上角单元格的值作为该合并区域的含义。
- `create` 不传 `--folder` 和 `--workspace` 时,默认创建在"我的文档"根目录
- `list` 返回所有工作表的 ID 和名称,是后续操作的必要前置步骤
- `info` 不传 `--sheet-id` 时默认返回第一个工作表的详情
- `new` 创建工作表时,如名称与已有工作表重复,系统会自动重命名
- `update` 的 `--name`、`--index`、`--hidden`、`--frozen-row-count`、`--frozen-column-count` 至少必须提供一个
- `update` 的 `--name` 最长 100 字符,不能包含 `/ \ ? * [ ] :` 等特殊字符
- `update` 的 `--index` 为 0-based 非负整数,0 表示移动到最前面
- `update` 的 `--hidden` 设为 true 时,至少需要保留一个可见的工作表,不能将所有工作表都隐藏
- `update` 的 `--frozen-row-count` / `--frozen-column-count` 为非负整数,不能超过工作表的总行数/列数,设为 0 表示取消冻结
- `update` 当同时提供多个属性时,所有属性将在同一次请求中更新
- `copy` 复制操作会将源工作表的所有内容(包括数据、格式、公式等)完整复制到新工作表
- `copy` 的 `--name` 可选,不传时系统自动生成名称(通常为"源名称 副本"或类似格式)
- `copy` 的 `--name` 最长 100 字符,不能包含 `/ \ ? * [ ] :` 等特殊字符
- `copy` 当指定名称与已有工作表重复时,系统会自动重命名为合法值
- `copy` 的 `--index` 可选,不传时副本将放置在源工作表之后的默认位置
- `delete-sheet` 为不可逆操作,执行前必须向用户确认
- `delete-sheet` 不能删除隐藏的工作表,需先通过 `update --hidden=false` 取消隐藏再删除
- `delete-sheet` 不能删除最后一个可见工作表,至少保留一个可见工作表
- ★ 关键区分: sheet(电子表格/单元格读写) vs aitable(AI多维表/结构化记录/字段定义) vs doc(文档编辑/阅读)

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