Compare commits

...
17 Commits
Author SHA1 Message Date
修雨 be80790172 docs(changelog): add [1.0.44] — phantom guard, report contents-file, @file, sheet parity 2026-06-29 10:53:03 +08:00
修雨 2dbbca1ec9 docs(skill): align dws skill references with the real CLI
Every documented command / flag / example now matches `dws <svc> --help`:
- drop phantom commands (attendance class/group/vacation/..., contact label,
  ding message list/receiver-status) that map to undeployed tools.
- fix runtime-failure flags (mail --body→--content, doc/calendar/minutes
  pagination, chat send-by-bot @-flags, wiki member --users).
- route role/duty "who is responsible" queries to `aisearch person --dimension
  duty` instead of the removed `contact label`.
- realign the multi/dingtalk-report skill to entry submit / inbox list / outbox
  list.
2026-06-29 10:53:03 +08:00
修雨 b214c0a06c fix(sheet): wukong parity for range read/update
- accept scalar cells in range update
- flat values projection on read; null clears a cell
- add --hyperlinks flag to range update
2026-06-29 10:53:03 +08:00
修雨 d3087d170b feat(compat): native @file / --contents-file input for structured JSON flags
`dws report entry submit --contents-file <f>` (and `--contents -` stdin) silently
submitted `contents:[null]`. Root cause: the `--contents-file` flag had no
transform, so its value mapped to an unused param while `--contents` stayed empty.

- file_read_json transform + a build-time report hook resolve --contents-file /
  --contents - / @file natively in Go (priority: file > stdin > inline) and
  declare a contents / contents-file one-of group so a file-only invocation is
  no longer rejected at parse time.
- generalizes to @file / @- input for any structured JSON-array flag.
2026-06-29 10:53:03 +08:00
修雨 49637d982e feat(compat): hide phantom override commands from --help (tool-existence guard)
Override leaves whose backing MCP tool isn't actually deployed rendered in
`dws <svc> --help` but failed at invocation with "tool not found" (43 phantom
commands across 391 overrides; attendance declared 38, only 4 deployed).

BuildDynamicCommands now takes an existingTools oracle (CLI slug -> live tool
set from the tools/ cache). A leaf whose tool is missing from its resolved
server's set is marked Hidden; groups left childless collapse. Safety rails:
acts only when a server's tool set is KNOWN and non-empty (cold cache / overlay
/ plugin paths pass nil and no-op, never blanking the tree); serverOverride
leaves resolve against the target server; pipeline leaves are never hidden.

Adds scripts/dev/check-phantom-overrides.py as a publish-time gate, and
phantom_guard_test.go covering hide / cold-cache-keep / serverOverride / pipeline
/ empty-group-collapse.
2026-06-29 10:53:03 +08:00
修雨 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
198 changed files with 21216 additions and 8038 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 }}
+44
View File
@@ -6,6 +6,50 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.44] - 2026-06-28
This release hardens the dynamic-command surface and finishes the dws-wukong parity pass for structured input. Phantom override commands whose backing MCP tool isn't deployed are hidden from `--help`; `report entry submit` reads `--contents-file` / stdin natively; structured JSON flags accept `@file` / `@-`; and `sheet range update` / `range read` now accept the same plain shapes wukong does (scalar cells, flat `values`, null-clears-cell, a `--hyperlinks` flag). On the wukong01 sandbox this lifts the full open-edition cli_to_mcp pass rate from 77.6% to 95.5% (sheet 28.5% → 99.8%, report → 100%); the remaining failures are account / org / out-of-scope, not CLI defects.
### 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.
- **`report entry submit --contents-file <path>` / `--contents -` (stdin) read natively** (#514, `internal/compat/report_hooks.go`) — the envelope publishes `entry submit` (MCP `create_report`) with a `--contents` (json_parse, required) flag plus a sibling `--contents-file` that had no transform / mapsTo, so a `--contents-file`-only submit silently sent `contents: [null]` and the report failed (only inline `--contents` worked, which is why `report create` succeeded while `report entry submit --contents-file` did not). A build-time compat hook now resolves the file / stdin natively (10MB cap, UTF-8 check, wukong priority `--contents-file` > `--contents -` > inline) and relaxes the individual `required` on `--contents` into a `contents` / `contents-file` one-of group. No discovery-config change needed.
- **`@file` / `@-` input for structured JSON flags** (`internal/compat/transform.go`) — `json_parse` / `json_parse_strict` now expand a leading `@` before parsing (`@-` reads stdin, `@<path>` reads a file), so long / complex payloads (many records, big 2D cell ranges, filter criteria) skip shell-quoting hell. A JSON / YAML value never starts with `@`, so the sentinel is unambiguous; the error hint that already advertised `@path/to/file.json` is now truthful. `sheet`'s shared `sheetParseJSONFlag` routes through `cli.ResolveInputSource` so the same support reaches `--values` / `--criteria` / `--sort-keys`.
- **`sheet range update --hyperlinks`** (`internal/helpers/sheet.go`) — a wukong-shaped 2D hyperlink grid (`[[{"type":"path","link":"...","text":"..."}]]`) overlaid onto the cells grid as each cell's `hyperlink` field; `--values` or `--hyperlinks` is now required (at least one).
### Changed
- **Phantom override commands hidden from `--help`** (#515, `internal/compat/dynamic_commands.go`) — override leaves whose backing MCP tool isn't actually deployed used to render in `dws <svc> --help` and then fail at invocation with *tool not found*. A tool-existence guard now hides them, and command groups left empty by the hidden leaves are collapsed, so `--help` reflects only invokable commands. Skill references are re-aligned to the real CLI surface (phantom commands dropped; role/duty "who is responsible" queries routed to `aisearch`, not `contact`).
- **`sheet range update` accepts scalar cells; `sheet range read` projects a flat `values`; `--values '[[null]]'` clears a cell** (`internal/helpers/sheet.go`, `internal/helpers/sheet_cell_validation.go`) — dws-wukong parity. `range update` (set_cell_range) auto-wraps a scalar cell (string / number / bool) into `{type:text,text:"..."}` instead of rejecting it, so the plain `[["姓名","部门"]]` shape that `sheet append` and wukong's update_range accept now works; a null cell clears content (matching wukong); `{}` still means keep-original. `range read` (get_cell_infos) now also exposes a flat `values` 2D array next to the rich `cells` payload, matching wukong's get_range shape without dropping cell styles.
- **report skill aligned to `entry submit` / `inbox list` / `outbox list`** (`skills/multi/dingtalk-report/`, `skills/mono/references/intent-guide.md`) — the multi skill tree was two versions behind and still taught the deprecated flat aliases (`report create` / `sent` / `list` / `detail` / `stats`) and falsely claimed `report inbox` was unimplemented. Re-aligned to the canonical resource.verb commands consistently (old aliases still execute with a stderr deprecation notice).
## [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())
+45 -1
View File
@@ -351,15 +351,59 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
existingTools := loadCachedToolNames(store, servers)
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID, existingTools)
RecordTiming(ctx, "build_commands", time.Since(buildStart))
return cmds
}
// loadCachedToolNames reads the live tools/list snapshot from disk cache for
// each server and returns a map from CLI server ID (slug) → set of tool names
// the server actually exposes. This is the existence oracle BuildDynamicCommands
// uses to hide phantom override leaves (commands whose backing MCP tool is not
// deployed) from `--help`.
//
// Source note: this reads the `tools/` partition (populated by `dws cache
// refresh` / discovery, keyed by server.Key), NOT the `detail/` partition used
// by loadCachedDetailsFast — the latter is frequently empty even after a
// refresh, so it is unusable as an existence signal.
//
// Keyed by cli.ID so serverOverride routing (e.g. contact → hrmregister)
// resolves against the target server's tool set. A server with no cached tools
// is simply absent from the map; the build guard treats "absent / empty" as
// "unknown" and keeps the command, so a cold cache never blanks the tree.
func loadCachedToolNames(store *cache.Store, servers []market.ServerDescriptor) map[string]map[string]struct{} {
result := make(map[string]map[string]struct{})
if store == nil {
return result
}
partition := editionPartition()
for _, server := range servers {
slug := strings.TrimSpace(server.CLI.ID)
if slug == "" || strings.TrimSpace(server.Key) == "" {
continue
}
snap, _, err := store.LoadTools(partition, server.Key)
if err != nil || len(snap.Tools) == 0 {
continue
}
names := make(map[string]struct{}, len(snap.Tools))
for _, t := range snap.Tools {
if n := strings.TrimSpace(t.Name); n != "" {
names[n] = struct{}{}
}
}
if len(names) > 0 {
result[slug] = names
}
}
return result
}
// loadCachedDetailsFast reads Detail API tool metadata from disk cache only —
// no network calls. Returns whatever is available (fresh or stale).
func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor) map[string][]market.DetailTool {
+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{
+4 -1
View File
@@ -148,8 +148,11 @@ func registerStdioServerFromOverlay(
}
}
// nil existingTools: this overlay is built from the plugin's own live tool
// list (detailsByID is derived from it), so there are no phantom leaves to
// guard against here.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered from overlay",
"plugin", p.Manifest.Name, "server", sc.Key,
+37 -2
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),
@@ -1471,8 +1503,10 @@ func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.T
}
}
// nil existingTools: single-server overlay built from a live tool list, so
// no phantom-leaf guard is needed (see BuildDynamicCommands doc).
return compat.BuildDynamicCommands(
[]market.ServerDescriptor{srv}, runner, detailsByID)
[]market.ServerDescriptor{srv}, runner, detailsByID, nil)
}
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
@@ -1659,8 +1693,9 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
detailsByID := toolsToDetails(tools, overlay.ID)
// nil existingTools: overlay built from this plugin's live tool list.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered",
"plugin", p.Manifest.Name, "server", sc.Key,
+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,
+7 -1
View File
@@ -18,6 +18,7 @@ package cobracmd
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -43,7 +44,7 @@ func FlagChanged(cmd *cobra.Command, name string) bool {
// NewGroupCommand creates a non-leaf parent command that shows help when invoked.
func NewGroupCommand(use, short string) *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
@@ -53,6 +54,11 @@ func NewGroupCommand(use, short string) *cobra.Command {
return cmd.Help()
},
}
// Tag as a group container: its RunE only prints help, so cobra's
// Runnable() can't distinguish it from a real leaf — callers that need to
// collapse empty groups rely on this annotation.
cmdutil.MarkGroup(cmd)
return cmd
}
// NewHiddenGroupCommand creates a hidden non-leaf parent command.
+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)
}
+94 -1
View File
@@ -51,8 +51,16 @@ var runtimeDefaultWhitelist = map[string]bool{
// detailsByID maps CLI server ID → []DetailTool from the MCP Detail API.
// When provided, tool Short/Long descriptions and typed flags are enriched from Detail API data.
//
// existingTools maps CLI server ID (slug) → set of tool names that server
// actually exposes (from the live tools/list cache). When a server's set is
// present and non-empty, override leaves whose backing MCP tool is missing from
// it are hidden from `--help` (phantom-command guard). When the set is absent or
// empty (cold cache, or a server we have no tools snapshot for) the guard does
// nothing for that server, so an unpopulated cache never blanks the command
// tree. Pass nil to disable the guard entirely.
//
// Conversion rules reference: docs/mcp-to-cli-conversion.md
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool) []*cobra.Command {
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool, existingTools map[string]map[string]struct{}) []*cobra.Command {
type builtCmd struct {
cmd *cobra.Command
parent string // cli.Parent: attach as sub-command of this top-level command
@@ -222,6 +230,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
cmd := NewDirectCommand(route, runner)
// §guard.toolexists: keep `--help` honest under envelope/deployment
// drift. When we know the resolved server's live tool set and it does
// NOT contain this leaf's backing tool, the command is a phantom
// (renders in help but fails at invocation with "tool not found"), so
// hide it. Safety rails:
// - only acts when the set is KNOWN and non-empty (absent/empty =
// unknown = keep; a cold tools cache must never blank the tree);
// - skips pipeline leaves, which orchestrate multiple tools and have
// no single backing toolName to check;
// - Hidden (not removed) so the command stays invocable for anyone
// who calls it directly — it just leaves the help surface.
if len(override.Pipeline) == 0 && existingTools != nil {
if known, ok := existingTools[canonicalProduct]; ok && len(known) > 0 {
if _, exists := known[toolName]; !exists {
cmd.Hidden = true
}
}
}
// Enrich flags with typed parameters from Detail API toolRequest JSON Schema.
if dt, ok := detailIndex[toolName]; ok && dt.ToolRequest != "" {
buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags)
@@ -239,6 +266,20 @@ 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)
// §report-hook: native --contents-file / --contents - (stdin)
// resolution + one-of(contents, contents-file) relaxation (see
// report_hooks.go). No-op for non-report.
installReportHook(cmd, canonicalProduct, toolName)
// §1.4: Add to the right parent group
attachToGroup(rootCmd, override.Group, groupCmds, cmd)
}
@@ -305,9 +346,61 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
for _, name := range topOrder {
commands = append(commands, topLevel[name])
}
// §guard.emptygroups: a group whose every leaf is hidden would still show in
// help as an empty heading (e.g. attendance `vacation`/`overtime` once their
// tools are gone). Collapse those. This runs unconditionally because leaves
// get hidden by TWO independent mechanisms — the runtime tool-existence
// guard above AND envelope `hidden:true` overrides — and an envelope-emptied
// group must collapse even when the guard is inert (cold tools cache). It is
// safe regardless of cache state: hideEmptyGroups only ever hides a group all
// of whose children are already hidden; it never hides a leaf, so it cannot
// blank a tree on its own.
for _, c := range commands {
// Collapse empty sub-groups within each product, but never the product
// root itself: a root can legitimately be empty at this point and gain
// visible leaves later from helper/overlay merges, so hiding it here
// could wrongly drop a whole product from `dws --help`.
for _, sub := range c.Commands() {
hideEmptyGroups(sub)
}
}
return commands
}
// hideEmptyGroups recursively hides group commands whose every subcommand is
// hidden — the collateral of the tool-existence guard emptying a group of all
// its leaves. Returns true if cmd is (now) hidden. A command with no
// subcommands is a leaf: its own Hidden flag is returned unchanged. A group is
// only newly hidden when it HAS subcommands and they are ALL hidden, so a group
// retaining at least one visible leaf always stays visible.
func hideEmptyGroups(cmd *cobra.Command) bool {
subs := cmd.Commands()
if len(subs) == 0 {
// No children. A real leaf stands on its own Hidden flag. A group
// container with no children is empty — this happens when every
// override in a group is `hidden:true` (those leaves are never built,
// leaving the group childless) — so hide it. Group containers and leaves
// both have a RunE, so cobra's Runnable() can't tell them apart; the
// group annotation can.
if cmdutil.IsGroup(cmd) {
cmd.Hidden = true
return true
}
return cmd.Hidden
}
allHidden := true
for _, sub := range subs {
if !hideEmptyGroups(sub) {
allHidden = false
}
}
if allHidden {
cmd.Hidden = true
}
return cmd.Hidden
}
// buildDetailIndex creates a map from toolName → DetailTool for fast lookup.
func buildDetailIndex(tools []market.DetailTool) map[string]market.DetailTool {
idx := make(map[string]market.DetailTool, len(tools))
+38 -38
View File
@@ -77,7 +77,7 @@ func TestBuildDynamicCommands_ParentNesting(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
// Should produce only one top-level command: "chat"
if len(cmds) != 1 {
@@ -130,7 +130,7 @@ func TestBuildDynamicCommands_ParentNotFound(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
// Parent not found, should fall back to top-level
if len(cmds) != 1 {
@@ -169,7 +169,7 @@ func TestBuildDynamicCommands_ShorthandFlag(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("expected 1 cmd, got %d", len(cmds))
}
@@ -212,7 +212,7 @@ func TestBuildDynamicCommands_RequiredFlag(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -253,7 +253,7 @@ func TestBuildDynamicCommands_RequiredIgnoredWhenPositional(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -292,7 +292,7 @@ func TestBuildDynamicCommands_PositionalArg(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -347,7 +347,7 @@ func TestBuildDynamicCommands_PositionalArgInjection(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -399,7 +399,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
},
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
article := findChild(cmds[0], "article")
if article == nil {
t.Fatal("article group not found")
@@ -552,7 +552,7 @@ func TestBuildDynamicCommands_PositionalArityMixed(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "do")
if leaf == nil {
t.Fatal("do leaf not found")
@@ -606,7 +606,7 @@ func TestBuildDynamicCommands_ServerOverride(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
leaf := findChild(cmds[0], "bot-list")
if leaf == nil {
t.Fatal("bot-list leaf not found")
@@ -641,7 +641,7 @@ func TestBuildDynamicCommands_ServerOverrideFallback(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
leaf := findChild(cmds[0], "list")
if leaf == nil {
t.Fatal("list leaf not found")
@@ -680,7 +680,7 @@ func TestBuildDynamicCommands_DescriptionOverridesUsage(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -732,7 +732,7 @@ func TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -775,7 +775,7 @@ func TestBuildDynamicCommands_BodyWrapper(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
create := findChild(cmds[0], "create")
if create == nil {
t.Fatal("create leaf not found")
@@ -858,7 +858,7 @@ func TestBuildDynamicCommands_MutuallyExclusive(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
cmds[0].SetArgs([]string{"list", "--group", "g1", "--user", "u1"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -901,7 +901,7 @@ func TestBuildDynamicCommands_RequireOneOf(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
cmds[0].SetArgs([]string{"list"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -940,7 +940,7 @@ func TestBuildDynamicCommands_RequireOneOfSatisfied(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"list", "--group", "g1"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -974,7 +974,7 @@ func TestBuildDynamicCommands_RedirectTo(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
history := findChild(cmds[0], "history")
if history == nil {
t.Fatal("history stub not found")
@@ -1029,7 +1029,7 @@ func TestBuildDynamicCommands_Hints(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
root := cmds[0]
// history hint attached directly under root.
@@ -1094,7 +1094,7 @@ func TestBuildDynamicCommands_UnknownFlagConstraintSkipped(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found (constraint validation must not abort build)")
@@ -1135,7 +1135,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimarySet(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--query", "hello"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1193,7 +1193,7 @@ func TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--keyword", "hi"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1234,7 +1234,7 @@ func TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet(t *testin
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1275,7 +1275,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet(t *testing.
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--query", "primary", "--keyword", "fallback"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1316,7 +1316,7 @@ func TestBuildDynamicCommands_MultipleAliases_MultiAliasChain(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"get", "--user-ids", "u1,u2"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1383,7 +1383,7 @@ func TestBuildDynamicCommands_MultipleAliases_Dedup(t *testing.T) {
// If ApplyBindings panics (duplicate pflag) we fail. Otherwise the cmd
// should build and execute fine.
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--keyword", "ok"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1421,7 +1421,7 @@ func TestBuildDynamicCommands_NoParent(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 2 {
t.Fatalf("expected 2 top-level commands, got %d", len(cmds))
@@ -1457,7 +1457,7 @@ func TestBuildDynamicCommands_ExampleField(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
approval := findChild(cmds[0], "approval")
if approval == nil {
t.Fatal("approval group not found")
@@ -1503,7 +1503,7 @@ func TestApplyBindings_VisibleFlagDefault_String(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "list-forms")
if leaf == nil {
t.Fatal("list-forms leaf not found")
@@ -1548,7 +1548,7 @@ func TestApplyBindings_VisibleFlagDefault_Int(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "list-forms")
if leaf == nil {
t.Fatal("list-forms leaf not found")
@@ -1786,7 +1786,7 @@ func TestBuildDynamicCommands_ParentMergeSameName(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 || cmds[0].Name() != "chat" {
t.Fatalf("expected single top-level 'chat', got %d cmds", len(cmds))
}
@@ -1860,7 +1860,7 @@ func TestBuildDynamicCommands_ParentMergeRecursive(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 || cmds[0].Name() != "chat" {
t.Fatalf("expected single top-level 'chat', got %d", len(cmds))
}
@@ -1934,7 +1934,7 @@ func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("expected 1 top-level, got %d", len(cmds))
}
@@ -1987,7 +1987,7 @@ func TestBuildFlagsFromDetailSchema_FormatEnumAnnotations(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found")
@@ -2052,7 +2052,7 @@ func TestBuildDynamicCommands_MapsTo_WithoutTransform(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "# 标题"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2107,7 +2107,7 @@ func TestBuildDynamicCommands_MapsTo_WithFileReadTransform(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content-file", path})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2157,7 +2157,7 @@ func TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "literal body"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2207,7 +2207,7 @@ func TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "x", "--content-file", "/tmp/y"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
+257
View File
@@ -0,0 +1,257 @@
// 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 (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
// findLeaf returns the first leaf command with the given Use anywhere under
// root (depth-first), or nil.
func findLeaf(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
if got := findLeaf(c, name); got != nil {
return got
}
}
return nil
}
func toolSet(names ...string) map[string]struct{} {
s := make(map[string]struct{}, len(names))
for _, n := range names {
s[n] = struct{}{}
}
return s
}
// attendanceLike builds one server with a real tool and a phantom tool, the
// exact shape of the production drift (e.g. attendance: only a handful of the
// declared overrides map to deployed tools).
func attendanceLike() []market.ServerDescriptor {
return []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
ToolOverrides: map[string]market.CLIToolOverride{
"get_attendance_summary": {CLIName: "summary"}, // real
"get_overtime_rule": {CLIName: "overtime"}, // phantom
},
},
},
}
}
// TestPhantomGuard_HidesWhenToolSetKnown is the core behaviour: when the live
// tool set is known and non-empty, a leaf whose backing tool is absent is
// hidden from --help while the real leaf stays visible.
func TestPhantomGuard_HidesWhenToolSetKnown(t *testing.T) {
t.Parallel()
existing := map[string]map[string]struct{}{
"attendance": toolSet("get_attendance_summary"), // overtime is NOT deployed
}
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, existing)
summary := findLeaf(cmds[0], "summary")
overtime := findLeaf(cmds[0], "overtime")
if summary == nil || overtime == nil {
t.Fatalf("both leaves should still be registered (invocable); summary=%v overtime=%v", summary, overtime)
}
if summary.Hidden {
t.Error("real command 'summary' must stay visible in --help")
}
if !overtime.Hidden {
t.Error("phantom command 'overtime' must be hidden from --help")
}
}
// TestPhantomGuard_ColdCacheKeepsEverything is the safety rail that the prior
// (source-blind) plan got wrong: with no tool set available (nil map), the
// guard must do nothing — never blank the command tree on a cold cache.
func TestPhantomGuard_ColdCacheKeepsEverything(t *testing.T) {
t.Parallel()
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, nil)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("cold cache (nil existingTools) must not hide %q", name)
}
}
}
// TestPhantomGuard_EmptyOrAbsentSetKeepsEverything: an empty set for a server,
// or a server missing from the map entirely, both mean "unknown" — keep all.
func TestPhantomGuard_EmptyOrAbsentSetKeepsEverything(t *testing.T) {
t.Parallel()
cases := []struct {
name string
existing map[string]map[string]struct{}
}{
{"empty set for server", map[string]map[string]struct{}{"attendance": {}}},
{"server absent from map", map[string]map[string]struct{}{"someother": toolSet("x")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, tc.existing)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("%s: must not hide %q when tool set is unknown", tc.name, name)
}
}
})
}
}
// TestPhantomGuard_ServerOverrideRoutesToTargetSet: a leaf with serverOverride
// must be checked against the TARGET server's tool set, not the host's. This is
// what prevents false-flagging legit cross-server routes (contact→hrmregister,
// doc→doc-comment).
func TestPhantomGuard_ServerOverrideRoutesToTargetSet(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-contact",
CLI: market.CLIOverlay{
ID: "contact",
Command: "contact",
ToolOverrides: map[string]market.CLIToolOverride{
// routed to hrmregister; the tool lives there, not in contact
"get_roster": {CLIName: "roster", ServerOverride: "hrmregister"},
},
},
},
}
// contact's own set is empty of get_roster, but hrmregister has it.
existing := map[string]map[string]struct{}{
"contact": toolSet("search_user"),
"hrmregister": toolSet("get_roster"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
roster := findLeaf(cmds[0], "roster")
if roster == nil {
t.Fatal("roster leaf should be registered")
}
if roster.Hidden {
t.Error("serverOverride leaf must resolve against the target server's set and stay visible")
}
}
// TestPhantomGuard_EmptyGroupCollapses: a group all of whose overrides are
// hidden:true (so none of its leaves are built) must itself be hidden from
// help, while a group keeping at least one visible leaf stays. This runs
// regardless of the tools-cache oracle (envelope hidden:true is cache-
// independent), so existingTools is nil here.
func TestPhantomGuard_EmptyGroupCollapses(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
Groups: map[string]market.CLIGroupDef{
"vacation": {Description: "假期管理"}, // all leaves hidden -> collapse
"record": {Description: "考勤记录"}, // keeps a visible leaf
},
ToolOverrides: map[string]market.CLIToolOverride{
"get_leave_types": {CLIName: "types", Group: "vacation", Hidden: true},
"get_leave_balance_quota": {CLIName: "balance", Group: "vacation", Hidden: true},
"get_user_attendance_record": {CLIName: "get", Group: "record"},
},
},
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
vacation := findGroup(cmds[0], "vacation")
record := findGroup(cmds[0], "record")
if vacation == nil || record == nil {
t.Fatalf("both groups should exist as commands; vacation=%v record=%v", vacation, record)
}
if !vacation.Hidden {
t.Error("group 'vacation' with only hidden leaves must collapse (be hidden)")
}
if record.Hidden {
t.Error("group 'record' with a visible leaf must stay visible")
}
}
// findGroup returns a direct child of root with the given name (groups attach
// directly under the product root).
func findGroup(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
// TestPhantomGuard_PipelineLeafNeverHidden: pipeline leaves orchestrate multiple
// tools and have no single backing toolName, so the guard must skip them even
// when the override key is not a deployed tool.
func TestPhantomGuard_PipelineLeafNeverHidden(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-im",
CLI: market.CLIOverlay{
ID: "im",
Command: "im",
ToolOverrides: map[string]market.CLIToolOverride{
"download_media": {
CLIName: "download-media",
Pipeline: []market.PipelineStep{
{Tool: "get_resource_download_url"},
},
},
},
},
},
}
// download_media itself is not a deployed tool name, but the pipeline is.
existing := map[string]map[string]struct{}{
"im": toolSet("get_resource_download_url"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
dl := findLeaf(cmds[0], "download-media")
if dl == nil {
t.Fatal("download-media leaf should be registered")
}
if dl.Hidden {
t.Error("pipeline leaf must never be hidden by the tool-existence guard")
}
}
+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
+2 -2
View File
@@ -83,7 +83,7 @@ func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
}
// Must not panic; the command must build and stay executable.
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
}
@@ -120,7 +120,7 @@ func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
boom, _, err := cmds[0].Find([]string{"boom"})
if err != nil {
t.Fatalf("find boom: %v", err)
+186
View File
@@ -0,0 +1,186 @@
// 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.
// report_hooks.go — CLI-side input resolution for the `report` product.
//
// The envelope publishes `report entry submit` (MCP tool create_report) with a
// `--contents` flag (json_parse, required) and a sibling `--contents-file`
// flag (omitWhen empty, no transform/mapsTo). On its own, `--contents-file`
// therefore goes nowhere: its value maps to the unused `contentsFile` param and
// the real `contents` param stays empty, so a `--contents-file`-only (or
// `--contents -` stdin) submit silently sends `contents: [null]` and the report
// fails. The literal-only `--contents` path works, which is why
// `report create` (the helper, inline-only) succeeds while
// `report entry submit --contents-file` does not.
//
// The wukong reference implementation reads the file/stdin natively inside its
// hand-written cobra RunE (dws-wukong/wukong/products/report.go
// resolveReportContentsFromFlags, priority: --contents-file > --contents -
// (stdin) > --contents '<json>'). The open-source CLI is envelope-driven, so we
// attach the equivalent native resolution as a build-time hook here, mirroring
// AttachReportListReadableEnrichment (which layers wukong-equivalent list
// enrichment onto the same envelope leaves). No discovery-config change is
// needed: the hook populates the real `--contents` flag before the envelope's
// json_parse transform runs, and the broken `contentsFile` override is left
// inert.
//
// Two build-time adjustments make `--contents-file`-only valid:
//
// 1. The envelope marks `--contents` individually required (cobra
// MarkFlagRequired, enforced at parse time, before PreRunE). We clear that
// annotation and instead declare a `contents` / `contents-file` one-of
// group (MarkFlagsOneRequired, validated by ValidateFlagGroups — also
// before PreRunE, but satisfied when either flag is set). Supplying
// neither still errors, now naming both flags.
// 2. A chained PreRunE resolves the chosen source into `--contents` so the
// downstream json_parse transform sees inline JSON regardless of origin.
package compat
import (
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// reportContentsMaxBytes caps the contents payload at 10MB, matching the
// wukong upstream limit (dws-wukong/wukong/products/report.go
// reportContentsMaxBytes). Oversized input is rejected rather than truncated.
const reportContentsMaxBytes = 10 * 1024 * 1024
// reportToolsWithContentsFile lists every report toolName whose `--contents` /
// `--contents-file` pair needs native file/stdin resolution. Today only
// create_report (the `report entry submit` leaf) carries the pair.
var reportToolsWithContentsFile = map[string]bool{
"create_report": true,
}
// installReportHook wires report-specific input resolution onto leaf commands
// emitted by BuildDynamicCommands. It is a no-op for non-report products and
// for report tools that do not expose the contents/contents-file pair.
//
// The hook chain preserves the cmd.PreRunE that NewDirectCommand already
// installed (currently validateRequireTogether) by invoking it first.
func installReportHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil {
return
}
if strings.TrimSpace(canonicalProduct) != "report" {
return
}
if !reportToolsWithContentsFile[toolName] {
return
}
contents := cmd.Flags().Lookup("contents")
file := cmd.Flags().Lookup("contents-file")
if contents == nil || file == nil {
// Envelope shape changed (renamed/removed flags) — do not block the
// command; leave whatever the envelope declared untouched.
return
}
// (1) Relax the individually-required `--contents` into a one-of group so
// `--contents-file`-only (or `--contents -`) is accepted. Clearing the
// required annotation must happen before parse-time ValidateRequiredFlags;
// this hook runs at build time, so it does.
if contents.Annotations != nil {
delete(contents.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.MarkFlagsOneRequired("contents", "contents-file")
// (2) Resolve the chosen source into --contents before the RunE transform.
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 resolveReportContents(c)
}
}
// resolveReportContents applies the wukong source priority — `--contents-file`
// (file) > `--contents -` (stdin) > `--contents '<json>'` (literal) — and
// writes the resolved JSON string back into the `--contents` flag so the
// downstream json_parse transform decodes it uniformly. When a file or stdin
// source is used, `--contents-file` is cleared so the envelope's omitWhen:empty
// drops the now-redundant param.
func resolveReportContents(cmd *cobra.Command) error {
filePath, _ := cmd.Flags().GetString("contents-file")
if strings.TrimSpace(filePath) != "" {
data, err := readReportContentsFile(filePath)
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from --contents-file")
}
_ = cmd.Flags().Set("contents-file", "")
return nil
}
raw, _ := cmd.Flags().GetString("contents")
if strings.TrimSpace(raw) == "-" {
data, err := readReportContentsLimited(cmd.InOrStdin(), "--contents -")
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from stdin")
}
}
return nil
}
// readReportContentsFile opens a file path and reads its contents under the
// 10MB cap and UTF-8 check. Error wording mirrors wukong so agents and humans
// see a stable message across both editions.
func readReportContentsFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return "", apperrors.NewValidation(
fmt.Sprintf("--contents-file: file not found: %s", path),
apperrors.WithHint("确认路径存在,且指向一个 JSON 文件"),
)
}
return "", apperrors.NewValidation(fmt.Sprintf("--contents-file: cannot read %s: %v", path, err))
}
defer file.Close()
return readReportContentsLimited(file, fmt.Sprintf("--contents-file %s", path))
}
// readReportContentsLimited reads from r enforcing the 10MB cap and UTF-8
// validity. A LimitReader at cap+1 detects overflow without reading unbounded.
func readReportContentsLimited(r io.Reader, source string) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, int64(reportContentsMaxBytes)+1))
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("%s: read failed: %v", source, err))
}
if len(data) > reportContentsMaxBytes {
return "", apperrors.NewValidation(
fmt.Sprintf("%s: contents exceed maximum size of 10MB", source),
apperrors.WithHint("精简内容或拆分为多份日志提交"),
)
}
if !utf8.Valid(data) {
return "", apperrors.NewValidation(fmt.Sprintf("%s: not valid UTF-8", source))
}
return string(data), nil
}
+198
View File
@@ -0,0 +1,198 @@
// 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 (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newReportSubmitStub mirrors the leaf command shape emitted by
// BuildDynamicCommands for `report entry submit` (envelope: create_report).
// Only the flags the hook touches are registered. --contents is marked
// required to reproduce the envelope's MarkFlagRequired so the relaxation
// behaviour can be asserted.
func newReportSubmitStub() *cobra.Command {
cmd := &cobra.Command{Use: "submit", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("contents", "", "contents JSON array")
cmd.Flags().String("contents-file", "", "contents JSON file")
cmd.Flags().String("template-id", "", "template id")
_ = cmd.MarkFlagRequired("contents")
return cmd
}
const reportContentsPayload = `[{"key":"今日完成工作","sort":"0","content":"done","contentType":"markdown","type":"1"}]`
func TestResolveReportContents_FromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(file): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from file: %q", got)
}
// contents-file must be cleared so omitWhen:empty drops the dead param.
if cf, _ := cmd.Flags().GetString("contents-file"); cf != "" {
t.Fatalf("--contents-file should be cleared after resolution, got %q", cf)
}
}
func TestResolveReportContents_FromStdin(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", "-"); err != nil {
t.Fatal(err)
}
cmd.SetIn(strings.NewReader(reportContentsPayload))
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(stdin): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from stdin: %q", got)
}
}
func TestResolveReportContents_InlineUntouched(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(inline): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("inline --contents must be left untouched, got %q", got)
}
}
func TestResolveReportContents_FilePriorityOverInline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatal(err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", `[{"stale":"inline"}]`); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents: %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents-file must win over inline --contents, got %q", got)
}
}
func TestResolveReportContents_MissingFileErrors(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", filepath.Join(t.TempDir(), "nope.json")); err != nil {
t.Fatal(err)
}
err := resolveReportContents(cmd)
if err == nil {
t.Fatal("expected error for missing --contents-file path")
}
if !strings.Contains(err.Error(), "file not found") {
t.Fatalf("unexpected error: %v", err)
}
}
// ── installReportHook composition ──────────────────────────────
func TestInstallReportHook_RelaxesRequiredToOneOf(t *testing.T) {
cmd := newReportSubmitStub()
// Before the hook, --contents carries the cobra required annotation.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("precondition: --contents should start out required")
}
installReportHook(cmd, "report", "create_report")
// After the hook, the individual required annotation must be cleared so a
// --contents-file-only invocation is not rejected at parse time.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] != nil {
t.Fatal("installReportHook should clear the individual required on --contents")
}
// And a PreRunE must now be installed to resolve the source.
if cmd.PreRunE == nil {
t.Fatal("installReportHook should install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherProduct(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "chat", "create_report")
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("non-report product must not touch required annotation")
}
if cmd.PreRunE != nil {
t.Fatal("non-report product must not install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherReportTool(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "report", "get_received_report_list")
if cmd.PreRunE != nil {
t.Fatal("non-target report tool must not install a PreRunE")
}
}
func TestInstallReportHook_ChainsExistingPreRunE(t *testing.T) {
cmd := newReportSubmitStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
installReportHook(cmd, "report", "create_report")
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE was dropped")
}
}
func TestInstallReportHook_BailsIfChainedPreRunEFails(t *testing.T) {
cmd := newReportSubmitStub()
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
installReportHook(cmd, "report", "create_report")
err := cmd.PreRunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "original boom") {
t.Fatalf("expected original PreRunE error to bubble, got %v", err)
}
}
func TestInstallReportHook_NilCmdSafe(t *testing.T) {
installReportHook(nil, "report", "create_report")
}
+121 -3
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 {
@@ -168,6 +243,18 @@ func transformJSONParse(value any) (any, error) {
if s == "" {
return value, nil
}
// @file / @- expansion — read the JSON/YAML payload from a file or stdin
// before parsing. A leading "@" is an unambiguous file sentinel because a
// JSON/YAML value never starts with "@"; this is what the error hint below
// promises and lets long/complex payloads (many records, big cell ranges)
// avoid shell-quoting hell.
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
// Strict JSON first — fast path and unambiguous type promotion (numbers
// stay numbers, etc.).
var parsed any
@@ -182,10 +269,32 @@ func transformJSONParse(value any) (any, error) {
return nil, apperrors.NewValidation(
"json_parse: input is not valid JSON or YAML; " +
"quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " +
"or pass `@path/to/file.json` to read from a file",
"or pass `@path/to/file.json` (or `@-` for stdin) to read from a file",
)
}
// resolveJSONSource expands an @file / @- reference used by the json_parse
// transforms. A leading "@" is the file sentinel: "@-" reads stdin, "@<path>"
// reads the file (UTF-8, via transformFileRead). Any value not starting with
// "@" is returned unchanged. JSON/YAML payloads never start with "@", so this
// is unambiguous for structured flags.
func resolveJSONSource(s string) (string, error) {
if !strings.HasPrefix(s, "@") {
return s, nil
}
ref := strings.TrimSpace(s[1:])
if ref == "" {
return "", apperrors.NewValidation(
"json_parse: `@` must be followed by a file path, or `@-` to read from stdin")
}
out, err := transformFileRead(ref)
if err != nil {
return "", err
}
loaded, _ := out.(string)
return strings.TrimSpace(loaded), nil
}
// transformJSONParseStrict is the strict variant of json_parse: only accepts
// well-formed JSON, rejecting input that the YAML fallback would otherwise
// silently coerce to a scalar string. Use when the upstream tool requires a
@@ -199,12 +308,21 @@ func transformJSONParseStrict(value any) (any, error) {
if s == "" {
return value, nil
}
// @file / @- expansion — same sentinel as json_parse (see resolveJSONSource).
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return nil, apperrors.NewValidation(
"json_parse_strict: input is not valid JSON; " +
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
"as strict JSON (e.g. '[{\"key\":\"value\"}]') or use `json_parse` for YAML-tolerant parsing",
"as strict JSON (e.g. '[{\"key\":\"value\"}]'), pass `@path/to/file.json` " +
"(or `@-` for stdin), or use `json_parse` for YAML-tolerant parsing",
)
}
return parsed, nil
+59
View File
@@ -127,6 +127,65 @@ func TestJSONParse_InvalidInput(t *testing.T) {
}
}
func TestJSONParse_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "payload.json")
if err := os.WriteFile(path, []byte(`[{"k":"长内容\n多行","n":1}]`), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse", nil)
if err != nil {
t.Fatalf("json_parse @file: %v", err)
}
arr, ok := got.([]any)
if !ok || len(arr) != 1 {
t.Fatalf("expected 1-element array from @file, got %T %v", got, got)
}
item := arr[0].(map[string]any)
if item["k"] != "长内容\n多行" {
t.Fatalf("@file content mismatch: %v", item)
}
}
func TestJSONParse_AtFileMissing(t *testing.T) {
if _, err := ApplyTransform("@"+filepath.Join(t.TempDir(), "nope.json"), "json_parse", nil); err == nil {
t.Fatal("json_parse @missing-file should error")
}
}
func TestJSONParse_BareAtErrors(t *testing.T) {
_, err := ApplyTransform("@", "json_parse", nil)
if err == nil {
t.Fatal("bare @ should error (needs a path or -)")
}
}
func TestJSONParseStrict_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "p.json")
if err := os.WriteFile(path, []byte(`{"a":[1,2,3]}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse_strict", nil)
if err != nil {
t.Fatalf("json_parse_strict @file: %v", err)
}
if _, ok := got.(map[string]any); !ok {
t.Fatalf("expected object, got %T", got)
}
}
func TestJSONParse_AtPassthroughNonAt(t *testing.T) {
// A value not starting with "@" must be parsed inline, untouched.
got, err := ApplyTransform(`[{"x":1}]`, "json_parse", nil)
if err != nil {
t.Fatalf("inline json_parse: %v", err)
}
if arr, ok := got.([]any); !ok || len(arr) != 1 {
t.Fatalf("inline parse regressed: %T %v", got, got)
}
}
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
// read in full and surfaced as a string value. This is the contract the
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
+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
+514
View File
@@ -0,0 +1,514 @@
// 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)
}
}
}
// sheetProjectFlatValues walks an MCP get_cell_infos response and, next to any
// rich `cells` array, adds a flat `values` 2D array of each cell's scalar
// `value`. This gives consumers the simple wukong get_range shape
// (values: [["姓名","部门"]]) without dropping the rich `cells` payload.
// Idempotent: skips when `values` already exists or there are no cells.
func sheetProjectFlatValues(v any) {
switch t := v.(type) {
case map[string]any:
if cellsRaw, ok := t["cells"]; ok {
if _, exists := t["values"]; !exists {
if flat := sheetFlatValuesFromCells(cellsRaw); flat != nil {
t["values"] = flat
}
}
}
for _, child := range t {
sheetProjectFlatValues(child)
}
case []any:
for _, child := range t {
sheetProjectFlatValues(child)
}
}
}
func sheetFlatValuesFromCells(cellsRaw any) []any {
rows, ok := cellsRaw.([]any)
if !ok {
return nil
}
out := make([]any, 0, len(rows))
for _, row := range rows {
rowSlice, ok := row.([]any)
if !ok {
return nil
}
flatRow := make([]any, 0, len(rowSlice))
for _, cell := range rowSlice {
if cellMap, ok := cell.(map[string]any); ok {
flatRow = append(flatRow, cellMap["value"])
} else {
flatRow = append(flatRow, cell)
}
}
out = append(out, flatRow)
}
return out
}
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)
}
+27
View File
@@ -52,3 +52,30 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool {
}
return cmd.Annotations[SourceAnnotation] == SourceEnvelope
}
// KindAnnotation records the structural role of a command. It distinguishes a
// pure group container (a heading whose RunE only prints help) from a runnable
// leaf. Both have a RunE set, so cobra's Runnable() cannot tell them apart —
// this annotation can. Kept here next to SourceAnnotation so the literals stay
// in one place.
const KindAnnotation = "dws.kind"
// KindGroup marks a command created as a group container (see NewGroupCommand).
const KindGroup = "group"
// MarkGroup stamps cmd as a group container. Safe on a command without an
// existing Annotations map.
func MarkGroup(cmd *cobra.Command) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[KindAnnotation] = KindGroup
}
// IsGroup reports whether cmd was created as a group container.
func IsGroup(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[KindAnnotation] == KindGroup
}
+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)
}
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0 (the "License").
#
# check-phantom-overrides.py — publish-time guard against "phantom" CLI
# commands: toolOverrides in the discovery envelope whose backing MCP tool is
# not actually deployed on the server. Such overrides render in `dws <svc>
# --help` but fail at invocation ("tool not found"). This is the discovery-side
# (Plan A) complement to the runtime guard in internal/compat/dynamic_commands.go.
#
# TRUTH SOURCE: the live tools/list snapshots written by `dws cache refresh`
# into <cache-dir>/<partition>/tools/*.json, mapped to servers via
# <cache-dir>/<partition>/market/servers.json (cli.id slug -> server key).
#
# An override is a phantom ONLY if its tool name is absent from the resolved
# server's live tool set AND it is not otherwise explained:
# - serverOverride: resolve against the TARGET server's tool set;
# - pipeline: orchestrates multiple tools, no single backing tool;
# - redirectTo/target: a redirect stub, not a real tool invocation;
# - hidden:true: explicitly acknowledged (e.g. lead metadata for a tool
# not yet deployed) — reported as INFO, never fails CI.
#
# Exit code: 1 if any UN-acknowledged phantom override is found, else 0.
#
# Usage:
# dws cache refresh
# python3 scripts/dev/check-phantom-overrides.py
# python3 scripts/dev/check-phantom-overrides.py --envelope envelope/discovery.pre.json
import argparse
import glob
import json
import os
import sys
CLI_META_KEY = "com.dingtalk.mcp.registry/cli"
def load_real_tools(cache_dir, partition):
"""Return (name2tools, slug2tools): display-name and cli.id slug -> set(tool names)."""
base = os.path.join(cache_dir, partition)
real_by_key = {}
for f in glob.glob(os.path.join(base, "tools", "*.json")):
try:
d = json.load(open(f))
except (OSError, ValueError):
continue
names = {(t.get("name") or "").strip() for t in (d.get("tools") or [])}
names.discard("")
real_by_key[d.get("server_key")] = names
servers_path = os.path.join(base, "market", "servers.json")
if not os.path.exists(servers_path):
sys.exit(f"error: {servers_path} not found — run `dws cache refresh` first")
servers = json.load(open(servers_path)).get("servers", [])
name2tools, slug2tools = {}, {}
for s in servers:
ts = real_by_key.get(s.get("key"), set())
if s.get("display_name"):
name2tools[s["display_name"].strip()] = ts
slug = ((s.get("cli") or {}).get("id") or "").strip()
if slug:
slug2tools[slug] = ts
return name2tools, slug2tools, real_by_key
def cli_block(server):
"""toolOverrides live under _meta['com.dingtalk.mcp.registry/cli'] (flat slash key)."""
return (server.get("_meta", {}) or {}).get(CLI_META_KEY, {}) or {}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--envelope", default="envelope/discovery.pre.json")
ap.add_argument("--cache-dir", default=os.path.expanduser("~/.dws/cache"))
ap.add_argument("--partition", default="default_default")
args = ap.parse_args()
if not os.path.exists(args.envelope):
sys.exit(f"error: envelope not found: {args.envelope}")
name2tools, slug2tools, real_by_key = load_real_tools(args.cache_dir, args.partition)
if not real_by_key:
sys.exit("error: no tools snapshots in cache — run `dws cache refresh` first")
env = json.load(open(args.envelope))
phantom, acknowledged = [], []
for s in env.get("servers", []):
cli = cli_block(s)
name = (s.get("server", {}).get("name") or "").strip()
own = name2tools.get(name, set())
ov = cli.get("toolOverrides") or {}
for tool, o in ov.items():
so = (o.get("serverOverride") or "").strip()
if o.get("pipeline") or o.get("redirectTo") or o.get("target"):
continue
target = slug2tools.get(so, set()) if so else own
if tool in target:
continue
entry = (name, tool, o.get("cliName", ""), o.get("group", ""), so or "self")
(acknowledged if o.get("hidden") else phantom).append(entry)
if acknowledged:
print(f"INFO: {len(acknowledged)} acknowledged (hidden:true) phantom overrides — OK:")
for name, tool, cn, g, via in acknowledged:
print(f" [hidden] {name}: {tool} (cli={cn!r} group={g!r} via={via})")
if phantom:
print(f"\nFAIL: {len(phantom)} un-acknowledged phantom override(s) "
f"(tool not deployed; will fail at invocation):")
for name, tool, cn, g, via in phantom:
print(f" {name}: {tool} -> cli={cn!r} group={g!r} via={via}")
print("\nFix: remove the override, route via serverOverride/pipeline, "
"or mark hidden:true if it is intentional lead metadata.")
return 1
print(f"\nOK: no un-acknowledged phantom overrides in {args.envelope}")
return 0
if __name__ == "__main__":
sys.exit(main())
+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"
+4 -4
View File
@@ -44,10 +44,10 @@ cli_version: ">=1.0.15"
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
| `aisearch` | AI搜问(搜人首选):按姓名/部门/职位/职责/上级/下级/手机号/工号维度找人,"谁负责 XX/XX 的负责人/某事项/某项目的人"统一走本产品 | [aisearch.md](./references/products/aisearch.md) |
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
| `attendance` | 考勤:打卡结果/打卡流水/考勤组查询/考勤规则/汇总统计/假期类型/假期余额(P0 已落地,部分管理类命令仍属 P1) | [attendance.md](./references/products/attendance.md) |
| `attendance` | 考勤:考勤组与规则查询(rules)/个人打卡详情(record get)/批量班次查询(shift list)/考勤统计摘要(summary),仅此 4 个命令组 | [attendance.md](./references/products/attendance.md) |
| `calendar` | 日历:日历列表/日程/参与者/附件/响应/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) |
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/**查询**已有机器人 | [chat.md](./references/products/chat.md) |
| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/花名册档案(学历/家庭/银行卡/合同)/离职员工查询(姓名/时间范围/部门)/部门查询(搜索/详情/子部门/成员)/角色查询(主管/管理员/财务/HR 等 label)/特别关注列表 | [contact.md](./references/products/contact.md) |
| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/花名册档案(学历/家庭/银行卡/合同)/离职员工查询(姓名/时间范围/部门)/部门查询(搜索/详情/子部门/成员)/特别关注列表(按角色/职责找人请走 `aisearch`) | [contact.md](./references/products/contact.md) |
| `dev` | 开放平台开发者:**新建/配置机器人(建号)**、建联调试(把机器人接到本地 agent 的 Stream)、应用生命周期(创建/更新/删除/凭证/权限/成员/事件订阅)、开放平台文档搜索 | [dev.md](./references/products/dev.md) |
| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) |
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
@@ -94,13 +94,13 @@ cli_version: ">=1.0.15"
## 意图判断决策树
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/某职责/某角色(主管/管理员/财务/HR 等)由谁担任/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`(按角色或职责找人用 `aisearch person --dimension duty`)
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
用户提到"考勤/打卡/排班" → `attendance`
用户提到"日程/日历/会议室/约会/时间建议" → `calendar`
用户提到"群聊/建群/群成员/群管理/发消息/发图片消息/发文件消息/发 Markdown 消息/截图发钉钉/转发消息/引用回复/@我/特别关注消息/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat`(仅 IM 操作;创建/配置/建联机器人走 `dev`,见下)
用户提到"创建机器人/新建机器人/建机器人/配置机器人/机器人建号/建联/把机器人连到/接入 agent/opencode/claude/qoder/连接 agent/开放平台应用/开发者应用/app 创建/应用凭证/事件订阅/dws dev" → `dev`
用户提到"通讯录/同事/部门/组织架构/子部门/部门多少人/离职员工/离职名单/离职花名册/花名册/员工档案/学历/家庭/银行卡/紧急联系人/合同/角色/主管角色/管理员角色/财务/HR/特别关注/星标联系人" → `contact`
用户提到"通讯录/同事/部门/组织架构/子部门/部门多少人/离职员工/离职名单/离职花名册/花名册/员工档案/学历/家庭/银行卡/紧急联系人/合同/特别关注/星标联系人" → `contact`(按角色/职责找人不在此,走 `aisearch`)
用户提到"开发/API/调用错误 文档" → `devdoc`
用户提到"DING/紧急消息/电话提醒" → `ding`
用户提到"钉钉文档/云文档/读写文档/知识库里的文档/浏览知识库内容/知识库内搜索文档/块级编辑/文档评论/文档复制移动" → `doc`
@@ -5,7 +5,7 @@
## 专用规则(#8 非 lite 步骤必守)
- **角色类查人优先 label**:用户说"角色为XX的员工/XX角色的员工/XX角色的人员""所有主管/主管理员/财务/HR/总经理"等角色类型人员时,**优先** `contact label list` 获取全部角色 → 匹配目标角色 → `contact label list-members --id <labelId>`;若用户明确指定了角色名称(如"角色为总经理"),则先用 `contact label get --names <XX>` 精确匹配,**若精确匹配无结果,降级 `label list` 模糊匹配**(如用户说"管理员"可匹配到"主管理员"和"子管理员")。
- **角色/职责类查人优先 aisearch**:用户说"角色为XX的员工/XX角色的人员""所有主管/财务/HR/总经理""谁负责 XX"等角色/职责类查询时,**优先** `aisearch person --keyword "<角色或职责>" --dimension duty`(`contact label` 角色查询命令已下线,不再可用)。
- **脚本优先**:按部门拉成员**优先** `python scripts/contact_dept_members.py --query "<部门名>"`(`--dry-run` / `--format json`);失败再 `dept search` → `dept list-members --depts`。
- **详情链路**:用户要子部门、职位、联系方式、汇报关系等,在 `user search` 之后**必须**再 `contact user get --ids <userId>`;禁止仅用 search 的浅表字段交差。
- **`user get` 后部门仍空**:不得过早结束或只建议用户去 App;须在 CLI 能力内尝试 **用户点名的部门** `dept search` + `dept list-members` 等与 `userId` 交叉核对,再结构化汇总「返回中有哪些字段 / 哪些为空及可能原因」。
@@ -20,7 +20,7 @@
## 与其他场景消歧
- **按角色/职位类型查人(主管/管理员/财务等)** → 优先 `contact label list` + `label list-members`;label 精确命中角色维度,返回完整名单;aisearch 是语义模糊搜索不保证完整性。
- **按角色/职位类型查人(主管/管理员/财务等)** → 优先 `aisearch person --dimension duty`(按职责维度找人);`contact label` 角色查询命令已下线。
- **搜人/找人/找同事/查工号/查手机号** → 首选 **`aisearch person`**(AI 语义搜索,支持姓名/部门/职责/上下级/手机号/工号维度),见 `aisearch`(开源版未引入,悟空内部产品)。
- **需要 userId 做后续操作 / 按手机号查 / 按 userId 查详情** → `contact`(精确查询)。
- **纯查部门与子部门成员 / 验证归属 / 组织关系** → `contact`。
@@ -31,7 +31,7 @@
| Recipe | 步骤 |
|--------|------|
| `lookup-label-members` | 1. `contact label list` → 浏览全部角色,匹配目标角色的 labelId<br>2. `contact label list-members --id <labelId>` → 该角色下的成员列表 |
| `lookup-role-members` | 1. `aisearch person --keyword "<角色或职责>" --dimension duty` → 按职责维度找人(`contact label` 已下线) |
| `search-user-by-mobile` | 1. `contact user search-mobile --mobile "<手机号>"` → 按需 `contact user get --ids <userId>` |
| `lookup-dept-id` | 1. `contact dept search --query "<部门关键词>"` → 回显 `deptId`(多命中须消歧) |
| `list-subdepts` | 1. 已有父 `deptId` → `contact dept list-children --dept <父deptId>` 直接取直属子部门列表<br>2. 只有部门名 → 先 `lookup-dept-id` 取 `deptId`,再 `list-children` |
+2 -2
View File
@@ -82,10 +82,10 @@
**用 `report` 的场景**:
- "帮我看看收到的日报" — 日志收件箱
- "帮我写/提交今天的日报(钉钉日志模版)" — 先 `report template list` / `template detail`,再 `report create`
- "帮我写/提交今天的日报(钉钉日志模版)" — 先 `report template list` / `template get`,再 `report entry submit --contents-file <tmp>.json`
- "有什么日志模版" — 查看模版
- "看看这个日志的已读统计" — 阅读状态
- "我发过的日志有哪些" — 已发送列表 (`report sent`)
- "我发过的日志有哪些" — 已发送列表 (`report outbox list`)
- 用户提到"日报"、"周报"、"日志"
**用 `todo` 的场景**:
+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 端(数据表页面 → 自动化)完成。
File diff suppressed because it is too large Load Diff
+22 -22
View File
@@ -101,8 +101,6 @@ Flags:
**默认行为**:不传 `--start` / `--end` 时,默认返回今天的日程(00:00:00 ~ 23:59:59)。
**权限**:查询共享日历下的日程时,至少要有reader权限。
补充:当前用户的个人日程也可用 `dws calendar event list-mine` 查询,参数与 `event list` 一致。
### 获取日程详情
```
Usage:
@@ -219,12 +217,13 @@ Flags:
Usage:
dws calendar attendee add [flags]
Example:
dws calendar attendee add --event <EVENT_ID> --attendees <USER_ID_1>,<USER_ID_2>
dws calendar attendee add --event <EVENT_ID> --attendees <USER_ID> --optional
dws calendar attendee add --event <EVENT_ID> --users <USER_ID_1>,<USER_ID_2>
dws calendar attendee add --event <EVENT_ID> --users <USER_ID> --optional true
Flags:
--event string 日程 ID (必填)
--attendees string 参会人 userId 列表,逗号分隔 (必填,最多500人)
--optional 参会人可选 (默认必选参会人)
--event string 日程 eventId (必填)
--users string 参会人 userId 列表,逗号分隔 (必填)
--optional string 是否可选参会人 (可选)
--calendar-id string 日历 ID (可选)
```
### 移除参会人
@@ -235,10 +234,11 @@ Flags:
Usage:
dws calendar attendee delete [flags]
Example:
dws calendar attendee delete --event <EVENT_ID> --attendees <USER_ID> --yes
dws calendar attendee delete --event <EVENT_ID> --users <USER_ID> --yes
Flags:
--event string 日程 ID (必填)
--attendees string 参会人 userId 列表,逗号分隔 (必填)
--event string 日程 eventId (必填)
--users string 参会人 userId 列表,逗号分隔 (必填)
--calendar-id string 日历 ID (可选)
```
### 搜索会议室
@@ -317,10 +317,10 @@ Usage:
dws calendar room list-groups [flags]
Example:
dws calendar room list-groups
dws calendar room list-groups --limit 20 --page 0
dws calendar room list-groups --page-size 20 --page-index 0
Flags:
--limit string 页大小 (可选,不填默认 100,超过 100 按 100 处理)
--page string 分页起始位置 (可选,不填默认 0)
--page-size string 页大小 (可选,默认 100,上限 100)
--page-index string 分页起始位置 (可选,默认 0)
```
### 添加日程附件
@@ -419,8 +419,8 @@ Flags:
用户说"参会人/与会者":
- 查看 → `attendee list`
- 邀请/添加 → `attendee add --attendees <USER_ID>`(可选参会人加 `--optional`)
- 移除 → `attendee delete --attendees <USER_ID>`
- 邀请/添加 → `attendee add --users <USER_ID>`(可选参会人加 `--optional true`)
- 移除 → `attendee delete --users <USER_ID>`
用户说"会议室/订会议室":
- 哪个空闲 → `room search`
@@ -479,7 +479,7 @@ dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json
# Step 2: 添加参会人(必须用 Step 1 返回的 eventId)
dws calendar attendee add --event <EVENT_ID> --attendees userId1,userId2 --format json
dws calendar attendee add --event <EVENT_ID> --users userId1,userId2 --format json
# Step 3: 搜索空闲会议室
dws calendar room search --start ... --end ... --format json
@@ -519,19 +519,19 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
- 时间格式: `event create/update`、`event list`、`busy search` 和 `event suggest` 用 ISO-8601
- 时区: `event create/update` 和 `event suggest` 支持 `--timezone` 指定 IANA 时区(如 `Asia/Shanghai`、`America/New_York`),不传默认 `Asia/Shanghai`
- 创建日程时可通过 `--attendees` 直接指定参会人(最多500人),也可创建后用 `attendee add --attendees ...` 单独添加
- `--attendees` 和 `--open-dingtalk-ids` 至少传一个(如果需要指定参会人)
- 添加参会人时可通过 `--optional` 设为可选参会人(默认必选)
- 创建日程时可通过 `--attendees` 直接指定参会人(最多500人),也可创建后用 `attendee add --users ...` 单独添加
- `event create` 的 `--attendees` 和 `--open-dingtalk-ids` 至少传一个(如果需要指定参会人)
- `attendee add` 可通过 `--optional true` 设为可选参会人(默认必选)
- `event suggest` 根据参会人闲忙自动推荐合适时间,适合会议时间未确定时使用
- 创建日程**支持**通过 `--rooms` 一步预定会议室(`event create --rooms roomId1,roomId2`);若创建后再加,仍可用 `room add`
- `room search` 不带 `--group-id` 时查根目录;企业会议室超过 100 条会报错,此时需先 `room list-groups` 获取分组,再按分组逐一查询
- `room list-groups` 支持 `--limit` / `--page` 分页(schema 类型为字符串)
- `room list-groups` 支持 `--page-size` / `--page-index` 分页(schema 类型为字符串)
- **`event create --rooms` / `room add --rooms` 的唯一合法来源**:最近一次(同一会话、同一时段窗口)`room search` 返回体中的 `roomId`;禁止把用户自然语言会议室名当 `roomId` 传入(否则会 `roomId invalid` 等错误)
- **搜房无结果**:在符合早停/用户限定范围内,`room search`(含按分组逐组查)全部返回空或无空闲 → 应**直接向用户报错/说明失败**并结束订房;**禁止**假设 roomId、禁止无合法 `roomId` 时调用 `room add` / `event create --rooms` 试探、禁止用 `event get` 等绕路推断 roomId
- **评测 / 自动化断言**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId`
- **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `<fileId>:<name>` 元素逗号分隔
- **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历
- **已弃用入参**:`--max-results`(event list / list-mine)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用
- **已弃用入参**:`--max-results`(event list)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用
## 自动化脚本
@@ -544,4 +544,4 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
## 相关产品
- [conference](./simple.md) — 仅视频会议预约(返回入会链接),不含参会人/会议室管理
- [contact](./contact.md) — 搜索同事 userId,用于 attendee add --attendees
- [contact](./contact.md) — 搜索同事 userId,用于 attendee add --users
+21 -39
View File
@@ -18,9 +18,9 @@ Flags:
#### 查看群成员列表 — 分页查询指定群聊的成员
```
Usage:
dws chat group members [flags]
dws chat group members list [flags]
Example:
dws chat group members --id <openconversation_id>
dws chat group members list --id <openconversation_id>
Flags:
--cursor string 分页游标,首次从 0 开始
--id string 群 ID / openconversation_id (必填)
@@ -100,13 +100,11 @@ Usage:
dws chat group transfer-owner [flags]
Example:
dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId>
dws chat group transfer-owner --group <openConversationId> --user <userId>
# 查询群 ID: dws chat search --query "群名"
# 查询人员: dws aisearch person --keyword "姓名" --dimension name
Flags:
--group string 群聊 openConversationId (必填)
--new-owner string 新群主 openDingTalkId
--user string 新群主 userId
--new-owner string 新群主 openDingTalkId (必填)
```
#### 获取群邀请链接 — 获取指定群聊的邀请加入链接
@@ -511,28 +509,17 @@ Usage:
Example:
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --title "日报" --text "## 今日完成..."
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
dws chat message send-by-bot --robot-code <robot-code> --open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "请提交周报"
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-user-ids userId1,userId2 --title "提醒" --text "@userId1 @userId2 请查收本周报告"
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告"
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-all --title "通知" --text "请所有人注意"
Flags:
--group string 群聊 openConversationId(群聊时必填)
--robot-code string 机器人 Code (必填)
--text string 消息内容 Markdown (必填)
--title string 消息标题 (必填)
--users string 用户 userId 列表,逗号分隔,最多20个(单聊时必填)
--open-dingtalk-ids string 用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)
--at-user-ids string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选)
--at-open-dingtalk-ids string @指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)
--at-all @所有人(可选),服务端接收字符串 true/false
--users string 接收者 userId 列表,逗号分隔,最多20个(单聊时必填)
注意:
- 用户明确要求机器人发送时,必须使用 `chat message send-by-bot`;严禁使用 `chat message send` 以用户身份代发
- --group 与 --users/--open-dingtalk-ids 互斥,必须且只能指定其一
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-user-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @userId 对应文本
- --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @openDingtalkId 对应文本
- --at-all @所有人,仅群聊时生效;只需带上 --at-all flag 即可,服务端会自动处理
- --group(群聊)与 --users(单聊)互斥,必须且只能指定其一
- send-by-bot 不支持 @成员/@所有人 参数(无 --at-user-ids/--at-open-dingtalk-ids/--at-all);如需在群里 @人,用 `chat message send --group` 走用户身份发送
- userId 获取方式:`dws contact user search --query "姓名"` 搜人获取 userId
- **换行符**:--text 按 Markdown 渲染,换行规则同 `chat message send`:
1. 必须使用**真实换行符**(`U+000A`),而非字面量 `\n`,否则全部内容会渲染在同一行
@@ -1179,7 +1166,7 @@ Flags:
用户说"建群/创建群聊" → `chat group create`
用户说"搜索群/找群" → `chat search`
用户说"我创建的群/我管理的群/我是群主的群/我当管理员的群" → `chat group list-my-groups`
用户说"群成员/看群里有谁" → `chat group members`
用户说"群成员/看群里有谁" → `chat group members list`
用户说"拉人进群/加群成员" → `chat group members add`
用户说"踢人/移除群成员" → `chat group members remove`
用户说"加机器人到群" → `chat group members add-bot`
@@ -1351,7 +1338,7 @@ dws chat message send-by-bot --robot-code <robot-code> --group <openconversation
--title "通知" --text "内容" --format json
# Step 3: 邀请机器人进群
dws chat group members add-bot --group <openconversation_id> --robot-code <robot-code>
dws chat group members add-bot --id <openconversation_id> --robot-code <robot-code>
# Step 4: 重新发送
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
@@ -1370,27 +1357,22 @@ dws chat bot find --query "玉澜" --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --text "你好" --format json
```
### 机器人 @指定人发群消息
### @指定人发群消息(用户身份)
通过 `--at-user-ids` 传入 userId 列表或 `--at-open-dingtalk-ids` 传入 openDingtalkId 列表来 @指定成员,多个用逗号分隔。`--text` 中需包含 `@userId` 或 `@openDingtalkId` 文本(不要用尖括号,不要用姓名)。通过 `--at-all` @所有人。
`send-by-bot` 不支持 @ 参数。群里 @人/@所有人请用 `chat message send --group`(当前用户身份):通过 `--at-open-dingtalk-ids` 传入 openDingTalkId 列表 @指定成员(多个逗号分隔),`--text` 中需包含对应 `@openDingTalkId` 文本;`--at-all` @所有人。
```bash
# Step 1: 搜人获取 userId
# Step 1: 搜人获取 openDingTalkId
dws contact user search --query "张三" --format json
# Step 2: 用 userId 发送并 @(注意 text 中 @userId)
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--at-user-ids userId1,userId2 \
--title "提醒" --text "@userId1 @userId2 请查收本周报告" --format json
# 或者用 openDingtalkId 发送并 @
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
# Step 2: @指定成员发群消息(注意 text 中带 @openDingTalkId)
dws chat message send --group <openconversation_id> \
--at-open-dingtalk-ids openDingtalkId1,openDingtalkId2 \
--title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" --format json
--text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" --format json
# @所有人
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--at-all --title "通知" --text "请所有人注意" --format json
dws chat message send --group <openconversation_id> \
--at-all --text "请所有人注意" --format json
```
@@ -1467,8 +1449,8 @@ Flags:
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
| `chat group create` | `openConversationId` | 同上 |
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id |
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、list-by-sender 的 --sender-open-dingtalk-id |
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
@@ -1514,12 +1496,12 @@ Flags:
- `--user` 和 `--open-dingtalk-id` 本质上都是发起单聊操作,只是用户标识格式不同:userId 为企业内部应用常用标识,openDingTalkId 为三方应用或跨组织场景下的用户标识,服务端对两种 ID 的解析逻辑不同
- `--time` 格式: `yyyy-MM-dd HH:mm:ss`,为拉取消息的起始时间点;`--forward` 控制方向(默认 true,拉给定时间之后的消息),`--limit` 控制数量
- `chat search` 挂在 `chat` 下(非 `chat group` 下),路径为 `dws chat search`
- `send-by-bot` 群聊传 `--group`,单聊传 `--users` 或 `--open-dingtalk-ids`,与 `--group` 互斥且必选其一;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingtalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --group <openConversationId> --robot-code <robot-code>` 将机器人邀请进群后再发送
- `send-by-bot` 群聊传 `--group`,单聊传 `--users`,二者互斥且必选其一;**不支持 @成员/@所有人**(无 `--at-user-ids`/`--at-open-dingtalk-ids`/`--at-all`),群里 @人请用 `chat message send --group` 走用户身份;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --id <openConversationId> --robot-code <robot-code>` 将机器人邀请进群后再发送
- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回)
- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--text` 中包含 `@10`
- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user <userId>`
- 消息**换行符**(`send` / `send-by-bot` / `send-by-webhook` 的 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `<br>`(硬换行)
- `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner`
- `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主用 `--new-owner`(传 openDingTalkId)
- `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久)
- `chat group quit` 退出群聊,需传 --group(openConversationId)
- `chat group update-icon` 更新群头像,需传 --group(openConversationId)和 --icon-media-id(mediaId)
@@ -1553,5 +1535,5 @@ Flags:
## 相关产品
- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、send-by-bot --users、send-by-bot --at-user-ids、list-by-sender --sender-user-id;获取 openDingTalkId 用于 message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot --open-dingtalk-ids、send-by-bot --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id
- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-by-sender 的 --sender-open-dingtalk-id
- [drive](./drive.md) — 上传文件获取下载链接,用于 Markdown 图片/文件消息
+13 -86
View File
@@ -1,7 +1,8 @@
# 通讯录 (contact) 命令参考
> **CRITICAL — 命令合法性**:contact 只有 `user` / `dept` / `label` / `relation` 四个二级子命令。
> **CRITICAL — 命令合法性**:contact 只有 `user` / `dept` / `relation` 三个可操作的二级子命令。
> 不存在 `contact search`、`contact find`、`contact list`、`contact get`、`contact user find/list`。
> 角色/标签(label)查询命令已下线,不再提供 `contact label list/get/list-members`。
> 构造命令前必须确认路径在下方「命令总览」中存在;不确定时,**根据意图对照下方「意图判断」选择正确命令**。
>
> **CRITICAL — 根部门**:钉钉根部门 `deptId=1`。`dept` 系列命令查根部门统一传 `--dept 1` 或 `--depts 1`,不要传 `self / me / root / 0`。
@@ -187,52 +188,6 @@ Notes:
- 跨层级成员展开见 [08-directory.md](../best_practices/08-directory.md) 的 `cross-level-dept-members` recipe
```
### label (角色查询)
> **角色ID = labelId**:用户提到"角色ID"时,均指通讯录 label 系统中的角色ID,**不是部门ID也不是userId**。查角色成员用 `label list-members --id <角色ID>`,不要传给 `dept get-info` 或 `user get`。
#### 获取企业所有角色列表
```
Usage:
dws contact label list
Example:
dws contact label list
Flags:
无
Notes:
- 无需参数,返回当前企业全部角色列表(labelId、labelName等)
- 用于不知道准确角色名称时先浏览全部角色
- 典型场景:用户说“企业所有主管/查所有管理员/财务人员有哪些”→ 先 label list 浏览全部角色,匹配目标角色后 label list-members 获取成员
```
#### 根据角色名称查询角色
```
Usage:
dws contact label get [flags]
Example:
dws contact label get --names "管理员"
dws contact label get --names "管理员,财务"
Flags:
--names string 角色名称,逗号分隔 (必填)
Notes:
- 精确匹配角色名称,不支持模糊搜索
- 支持同时查询多个角色名称,逗号分隔
- 无需分页
```
#### 查询角色下的成员
```
Usage:
dws contact label list-members [flags]
Example:
dws contact label list-members --id 12345
Flags:
--id string 角色 ID (必填)
Notes:
- 根据角色ID直接查询成员列表;已有角色ID时直接用 `--id <labelId>`
- 不知道角色ID时:先 `dws contact label get --names "角色名"` 或 `dws contact label list` 获取 labelId
```
## 意图判断
> **搜人首选 `aisearch person`**:凡是“找人/搜人/找同事/谁负责/上级/下级”均优先用 [aisearch person](./aisearch.md),以下场景才用 contact。
@@ -247,32 +202,19 @@ Notes:
用户说"部门详情/部门信息/部门多少人" → `dept get-info`(返回部门ID、部门名称、部门人数;需 deptId,若只有部门名称需先 `dept search`)
用户说"子部门/下设部门/部门有哪些下级部门/枚举二级部门" → `dept list-children`(需父 deptId;只有部门名先 `dept search`)
用户说"部门有谁/部门成员/人员名单" → `dept list-members`(需 deptId;**仅本部门不含下级**,含下级先 `dept list-children` 再合并查)
用户查询涵盖"角色"(主管/管理员/财务/HR/总经理等任意角色名)→ 统一走 `contact label` 链路,按下方决策树选命令:
- 不知道角色名 / 枚举所有角色 → `label list`
- 已知角色名,查ID或成员 → 先 `label get --names <名>` 拿ID,查成员再调 `label list-members --id <ID>`;**精确匹配无结果时降级 `label list` 模糊匹配**
- 已知角色ID 查成员 → `label list-members --id <ID>`
用户查询涵盖"角色/职责"(主管/管理员/财务/HR/总经理/谁负责 XX 等)→ 优先用 [`aisearch person`](./aisearch.md) 按职责维度找人(`dws aisearch person --keyword "<角色或职责>" --dimension duty`)。
> [!IMPORTANT]
> **角色查询 3 步决策树**(不依赖字串匹配,按语义判定):
> 1. 不知道角色名 / 要枚举所有角色(列出企业有哪些角色、每个角色的名称和ID、不确定叫什么角色、负责XX的人有哪些等) → `label list`
> 2. 已知角色名 要查角色ID → `label get --names`
> 3. 已知角色ID 要查成员 → `label list-members --id`
>
> 任何含"角色"一词的查询默认走 `contact label` 链路。**唯一例外**:终点是"某个人是否具备某权限"(如"张三是不是管理员")→ `user get`。禁止路由到 `user profile fields`(那是花名册字段)、`dept list-members` 筛选、或 OA/chat 模块。
> [!IMPORTANT]
> **角色查人 vs 查某人的角色信息 — 判断口径**:先判断用户的终点是"人"还是"属性":
> - 终点是**人**("管理员角色有哪些人""管理员下都有谁""查XX角色的成员")→ 角色维度查人,**必须**走 `contact label` 链路(`label get`/`label list` → `label list-members`),**禁止**通过 `dept list-members` 筛选 `isAdmin` 等字段替代
> **角色查人 vs 查某人的属性 — 判断口径**:先判断用户的终点是"人"还是"属性":
> - 终点是**人**("管理员有哪些人""谁负责财务""找 XX 角色的成员")→ 走 [`aisearch person`](./aisearch.md)(职责维度找人)
> - 终点是**属性**("张三是不是管理员""查某人的主管/管理员权限")→ 已知 userId 查个人详情,走 `user get`(返回 isAdmin/leader 等字段)
>
> 反例对照:
> - "管理员角色下都有哪些人" → `label get --names 管理员` → `label list-members`(终点=角色下的**人员列表**)
> - "我想知道管理员这个角色下都有谁" → `label get --names 管理员` → `label list-members`(终点=角色下的**人员列表**)
> - "管理员都有哪些人" → `aisearch person --keyword 管理员 --dimension duty`(终点=人员列表)
> - "张三是不是管理员" → `user get --ids <userId>`(终点=某个人的**属性**)
> - "查一下张三的管理员权限" → `user get --ids <userId>`(终点=某个人的**属性**)
> - "角色ID为55808858的角色下有哪些成员" → `label list-members --id 55808858`(已有角色ID直接查成员)
>
> **角色 = 通讯录 label**:用户提到"角色"(角色ID/角色成员/角色名称/企业角色/查角色下的人)时,均指通讯录组织角色,应走 `contact label` 链路。OA 审批只管审批流程(待审批/同意/拒绝),**不支持**查询角色成员;群角色(chat group-role)只管群内身份,不涉及企业组织角色。
> 注意:`contact label` 角色查询命令已下线,不要再构造 `contact label list/get/list-members`。OA 审批只管审批流程(待审批/同意/拒绝),**不支持**查询角色成员;群角色(chat group-role)只管群内身份,不涉及企业组织角色。
用户说"我关注了谁/我的特别关注列表/我的星标联系人/特别关注的人有哪些" → `relation list-my-followings`
> [!IMPORTANT]
@@ -312,28 +254,19 @@ dws contact dept list-children --dept <父deptId> --format json
# 6. 查看部门成员
dws contact dept list-members --depts <deptId> --format json
# 7. 获取企业所有角色列表 — 不知道角色名时先浏览
dws contact label list --format json
# 8. 根据角色名称查询角色
dws contact label get --names "管理员" --format json
# 9. 查询角色下的成员
dws contact label list-members --id <labelId> --format json
# 10. 查询花名册有权限的字段列表
# 7. 查询花名册有权限的字段列表
dws contact user profile fields --format json
# 11. 根据字段 code 查询指定员工的花名册信息
# 8. 根据字段 code 查询指定员工的花名册信息
dws contact user profile get --staff-id <STAFF_ID> --fields fieldCode1,fieldCode2 --format json
# 12. 查询所有可见字段的花名册信息
# 9. 查询所有可见字段的花名册信息
dws contact user profile get --staff-id <STAFF_ID> --format json
# 13. 查询全部离职员工
# 10. 查询全部离职员工
dws contact user dismission search --format json
# 14. 按姓名/时间范围/部门筛选离职员工
# 11. 按姓名/时间范围/部门筛选离职员工
dws contact user dismission search --name "张三" --format json
dws contact user dismission search --start 2026-01-01 --end 2026-03-31 --format json
dws contact user dismission search --depts 123456,789012 --hide-retirement=false --format json
@@ -347,8 +280,6 @@ dws contact user dismission search --depts 123456,789012 --hide-retirement=false
| `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) |
| `user get-self/search` | `userId` | profile get 的 --staff-id |
| `user profile fields` | `fieldCode` | profile get 的 --fields |
| `label list` | `labelId` / `labelName` | `label get --names` 或 `label list-members --id` |
| `label get` | `labelId` | `label list-members` 的 --id |
| `dept search/list-children` | `deptId` | dept get-info/list-children/list-members 的 --dept/--depts |
| `dept search/list-children` | `deptId` | dismission search 的 --depts |
@@ -362,11 +293,7 @@ dws contact user dismission search --depts 123456,789012 --hide-retirement=false
- 建议先执行 `user profile fields` 获取可用字段列表,再根据需要的字段 code 执行 `user profile get`
- `user dismission search` 的 `--start`/`--end` 必须同时设置或同时不设置,不允许只传其中一个
- `user dismission search` 默认隐藏退休人员(`--hide-retirement` 默认 true),默认展示合作伙伴(`--hide-partner` 默认 false)
- `label list` 无需参数,适用于不知道准确角色名称的场景;当用户说“查所有主管/主管理员/财务”等角色类型人员时,优先 `label list` 列出企业全部角色,LLM 灵活匹配目标角色后调用 `label list-members`
- 角色类查询(主管、管理员、财务、HR 等任意角色)优先走 label 链路,而非 dept list-members 或 aisearch person;label 精确命中角色维度,返回完整名单
- `label get` 是精确匹配角色名称,不支持模糊搜索;支持逗号分隔同时查询多个角色名称
- **`label get` 精确匹配无结果时的降级策略**:若 `label get --names "XX"` 返回空结果,必须降级调用 `label list` 获取全部角色列表,从中模糊匹配包含XX关键词的角色(如用户说"管理员"可匹配到"主管理员"和"子管理员"),再对匹配到的角色调用 `label list-members`
- `label list-members` 需要先通过 `label list` 或 `label get` 获取 labelId,再用 --id 查询角色下的成员
- 角色/职责类查询(主管、管理员、财务、HR 等任意角色,或"谁负责 XX")优先走 [`aisearch person`](./aisearch.md) 的职责维度(`--dimension duty`);`contact label` 角色查询命令已下线,不要再构造
## 自动化脚本
-27
View File
@@ -26,36 +26,10 @@ Flags:
--robot-code string 机器人 ID (必填, 或设 DINGTALK_DING_ROBOT_CODE)
```
### 查询 DING 消息历史
```
Usage:
dws ding message list [flags]
Example:
dws ding message list
dws ding message list --type UNREAD
dws ding message list --type SEND --cursor 10
Flags:
--cursor int 分页游标 (首次传 0, 翻页传返回的 nextCursor)
--type string 消息类型: ALL / UNREAD / SEND / NEW_COMMENT / DELETED (可选, 不传返回全部)
```
### 查看 DING 接收状态
```
Usage:
dws ding message receiver-status [flags]
Example:
dws ding message receiver-status --ding-id <OPEN_DING_ID>
# 查询 dingId: dws ding message list
Flags:
--ding-id string DING 消息 openDingId (必填)
```
## 意图判断
用户说"DING 一下/紧急通知/电话提醒" → `message send`
用户说"撤回 DING" → `message recall`
用户说"DING 消息/查 DING/DING 历史/我的 DING" → `message list`
用户说"DING 接收状态/谁收到了 DING/DING 已读" → `message receiver-status`
关键区分:
- ding(紧急提醒, 支持电话/短信) vs bot(常规群/单聊消息)
@@ -77,7 +51,6 @@ dws ding message recall --robot-code <ROBOT_CODE> --id <OPEN_DING_ID> --format j
| 操作 | 提取 | 用于 |
|------|------|------|
| `message send` | `openDingId` | message recall 的 --id |
| `message list` | `openDingId` | message receiver-status 的 --ding-id |
## 注意事项
- `--robot-code` 从钉钉开放平台 **应用管理 → 机器人** 中获取,也可设环境变量 `DINGTALK_DING_ROBOT_CODE`
- sms/call 类型有通信费用,使用前需和用户确认
+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) |
### 文件操作
### 文件操作(doc 与 drive 均提供,按场景选用)
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `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) |
> **说明**:以下命令在 `doc` 下均为可用命令;`drive` 下也有等价命令(聚合钉盘 + 文档空间)。需要纯文件/钉盘语义时用 `drive`,需要在文档空间内操作时用 `doc`,两者都不会报 deprecated。
| doc 命令 | drive 等价命令 | 详见 |
|----------|----------------|------|
| `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 mkdir` | [`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`;`doc permission *` 亦可用) | `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`;同名 `doc` 命令亦可用) | `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,doc permission 亦可用)──
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,同名 doc 命令亦可用)──
# 第一步:获取 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 颜色 / 元素边界规范)
@@ -15,11 +15,11 @@ Usage:
Example:
dws doc comment list --node <DOC_ID>
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
dws doc comment list --node <DOC_ID> --limit 20 --cursor <TOKEN>
dws doc comment list --node <DOC_ID> --page-size 20 --next-token <TOKEN>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--limit int 每页返回的评论数量,默认 50,最大 50
--cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
--page-size int 每页返回的评论数量
--next-token string 下一页 token,从上一次请求的返回结果中获取 (首次请求不传)
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
```
@@ -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 流程)
@@ -13,19 +13,19 @@ Usage:
Example:
dws doc list
dws doc list --folder <DOC_FOLDER_NODE_ID>
dws doc list --workspace <WS_ID> --limit 20
dws doc list --workspace <WS_ID> --page-size 20
Flags:
--folder string 文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 知识库 ID
--limit int 每页数量 (默认 50,最大 50)
--cursor string 分页游标 (从上次结果的 nextPageToken 获取)
--page-size int 每页数量
--page-token string 分页 token (从上次结果的 nextPageToken 获取)
```
## 关键说明
- 不传任何 flag 时遍历"我的文档"根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 单页最多 50 条,需翻页时使用 `nextPageToken` → `--cursor`。
- 需翻页时使用 `nextPageToken` → `--page-token`。
## 上下文传递
@@ -45,10 +45,10 @@ dws doc list --folder <DOC_FOLDER_NODE_ID> --format json
dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# 浏览指定知识库根目录(取出 workspaceId 后)
dws doc list --workspace <WS_ID> --limit 20 --format json
dws doc list --workspace <WS_ID> --page-size 20 --format json
# 翻页
dws doc list --folder <DOC_FOLDER_NODE_ID> --cursor <nextPageToken> --format json
dws doc list --folder <DOC_FOLDER_NODE_ID> --page-token <nextPageToken> --format json
```
## 参考

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