Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be80790172 | ||
|
|
2dbbca1ec9 | ||
|
|
b214c0a06c | ||
|
|
d3087d170b | ||
|
|
49637d982e | ||
|
|
4c5f1faeb1 | ||
|
|
5833e71751 | ||
|
|
0e690fbe4e | ||
|
|
f7e8106a72 | ||
|
|
87a9b5b9be | ||
|
|
97678e6441 | ||
|
|
67090ae09f | ||
|
|
6f042f9167 | ||
|
|
78dd4aaa4b | ||
|
|
088a4d67ae | ||
|
|
81f5245c8a | ||
|
|
c4946c3eaf |
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` 切换或重装:**
|
||||
|
||||
|
||||
@@ -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
@@ -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`。
|
||||
|
||||
### 登录钉钉
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 指针)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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。
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+124
@@ -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
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` 的场景**:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 图片/文件消息
|
||||
|
||||
@@ -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` 角色查询命令已下线,不要再构造
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
|
||||
@@ -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 类型有通信费用,使用前需和用户确认
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user