Compare commits

...
20 Commits
Author SHA1 Message Date
Ariand修雨 3ee5f13c62 docs: condense Key Services table and document multi-org profiles (#527)
* docs: condense Key Services table and document multi-org profiles

The Key Services section listed a per-service command count and an exhaustive
subcommand token dump plus a long description, which had drifted out of date
and was hard to scan. Condense it (EN + zh) to a lark-cli-style
Service / Command / Capabilities table with a one-line capability per service,
pointing to docs/command-index.md for the full listing.

Also document the multi-organization (profile) capability, which had no README
coverage: a collapsible section placed right after "Custom App mode (CI/CD,
ISV integration)" in Getting Started, covering auth login adding a profile,
profile list / switch, the global --profile one-shot flag, and the agent-
orchestrated cross-org read pattern (writes stay on the current org). Mirrored
in README_zh.md.

CHANGELOG: add a [1.0.45] entry describing the full multi-profile feature
(login / profile management / --profile / backward-forward compatibility /
skill docs) plus the persistence hardening (locking, atomic writes, corruption
recovery, safe legacy mirror, no cross-org token fallback).

* docs(changelog): note --ai-tag default-on (#524) in [1.0.45]

---------

Co-authored-by: 修雨 <huyizhou.hyz@alibaba-inc.com>
2026-06-29 19:20:46 +08:00
e32fa1535c feat(auth): support multi-profile login (#500)
* feat(auth): support multi-profile login

* fix(auth): complete multi-org profile acceptance

* feat(auth): 完成多组织 profile 验收

* docs(auth): 补充多组织 Ralph 验收材料

* feat(auth): 支持 auth switch TUI 切换 profile

* feat(auth): logout 默认清理所有组织

* feat(auth): login 默认新增组织授权

* feat(profile): 使用 profile switch 切换组织

* docs(ralph): 更新 profile switch 验收材料

* fix(profile): 展示全部可切换组织

* feat(profile): support multi-org switch tui

* chore(install): add branch source installer

* fix(profile): keep global profile out of tool params

* feat(profile): support csv multi-profile runtime

* ci: add multi-profile e2e workflow

* ci: run multi-profile e2e on all branches

* docs: document multi-profile e2e ci gate

* docs: remove multi-profile test cases from pr

* ci: harden multi-profile e2e gates

* fix(auth): serialize profiles.json RMW and harden multi-profile persistence

Wrap all profiles.json read-modify-write paths (profile switch/use/remove,
status marking, token save, logout) in the existing dual-layer lock via a new
withProfilesLock helper. Split each writer into a public (locking) entry point
plus a lock-free *Locked variant so the non-reentrant lock is never re-acquired;
the refresh path (oauth_helpers) and the load-path legacy migration now call the
lock-free saver to avoid self-deadlock.

Also: write profiles.json and the token marker via per-write random temp names
(uuid) to stop concurrent writers from corrupting a fixed .tmp file; quarantine
an unparseable profiles.json and rebuild an empty config so the CLI can
self-heal instead of locking out auth reset/logout; make DeleteAllTokenData
proceed even if profiles.json cannot be read; and stop SyncLegacyTokenMirror
from deleting the legacy mirror on a transient keychain read error.

* fix(auth): do not fall back to a different org's legacy token slot

When no explicit --profile is given, LoadTokenDataForProfile resolves the
current/primary profile and reads its per-corp keychain slot. If that slot
read failed, the code silently fell through to the legacy single token slot,
which after any drift between the legacy mirror and the current profile could
belong to a different organization. The command would then run as the wrong
org with no indication to the user.

Reproduction (conceptual):
  - profiles.json currentProfile = corpA
  - corpA's keychain slot is unreadable, legacy single slot still holds corpB
  - any read command (no --profile) silently used corpB's token

Fix: when a profile is resolved but its slot read fails and no --profile was
given, only fall back to the legacy single slot when its CorpID matches the
resolved profile (same org); otherwise return the original error instead of
acting as a different organization. The no-profile legacy path (pre-migration
installs with no resolved profile) is unchanged.

Tests:
  - Covered by the existing internal/auth suite under go test -race; the
    same-org fallback preserves the legacy-mirror case while the cross-org
    case now surfaces the read error.

* feat(skill): document multi-org profile usage and always ship dws-shared

The skills had no guidance on the multi-profile capability, so an agent would
treat the CLI as single-org: when a lookup missed in the current org it would
give up or ask the user instead of searching other logged-in orgs. The multi
skill set also referenced a `dws-shared` prerequisite that was never actually
installed, and the only multi-org hints lived inline in three product skills.

This adds, in source only:
- A "multi-org / profile" section in the mono SKILL.md (concept, commands,
  cross-org rule, aggregation, safety guardrails) plus a decision-tree entry,
  trigger conditions, and a corrected logout danger-table row (logout removes
  all orgs by default; removing the primary silently re-elects a new primary,
  confirm before removing the primary).
- A standalone skills/multi/dingtalk-profile skill mirroring the same content.
- A new skills/multi/dws-shared skill that carries auth, global flags and the
  multi-org rule, so every product skill's PREREQUISITE resolves and all
  read/search skills inherit the cross-org behavior without per-skill edits.
- Cross-org fallback notes on dingtalk-aisearch / chat / contact.

To guarantee the prerequisite actually ships, multi-mode install now force-
includes dws-shared even when --skill / --exclude narrows the set (no-op when
the source has no dws-shared, preserving older layouts).

Tests:
  - internal/app: TestP1SharedAlwaysIncludedWithSkillFilter installs with
    `-s aitable` and asserts dws-shared still lands in the destination;
    TestP1SharedNoopWhenAbsent guards the older-layout no-op.
  - go test -race ./internal/auth/... ./internal/app/... passes.

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: qinze <audanye@gmail.com>
2026-06-29 18:27:25 +08:00
修雨 79b8eda3b6 feat(chat): default --ai-tag on so dws-sent messages carry the AI badge
Per req 83667761 (奕皓): messages sent through dws should carry the
「通过AI发送」badge by default, transparently flagging AI/CLI-sent messages.

- `--ai-tag` default flipped false → true on `chat message send` / `reply`, so
  no flag / `--ai-tag` / `--ai-tag=true` all attach clawType (open edition
  `openClaw`); only `--ai-tag=false` omits it (send as the user). The switch name
  is unchanged. reply honors the same default (no longer leaks the wukong
  clawType).
- skill chat.md: concise rule — default-on, pass `--ai-tag=false` to disable.
- tests: default now asserts clawType present; added an `--ai-tag=false` opt-out
  case.
2026-06-29 18:16:20 +08:00
修雨 be80790172 docs(changelog): add [1.0.44] — phantom guard, report contents-file, @file, sheet parity 2026-06-29 10:53:03 +08:00
修雨 2dbbca1ec9 docs(skill): align dws skill references with the real CLI
Every documented command / flag / example now matches `dws <svc> --help`:
- drop phantom commands (attendance class/group/vacation/..., contact label,
  ding message list/receiver-status) that map to undeployed tools.
- fix runtime-failure flags (mail --body→--content, doc/calendar/minutes
  pagination, chat send-by-bot @-flags, wiki member --users).
- route role/duty "who is responsible" queries to `aisearch person --dimension
  duty` instead of the removed `contact label`.
- realign the multi/dingtalk-report skill to entry submit / inbox list / outbox
  list.
2026-06-29 10:53:03 +08:00
修雨 b214c0a06c fix(sheet): wukong parity for range read/update
- accept scalar cells in range update
- flat values projection on read; null clears a cell
- add --hyperlinks flag to range update
2026-06-29 10:53:03 +08:00
修雨 d3087d170b feat(compat): native @file / --contents-file input for structured JSON flags
`dws report entry submit --contents-file <f>` (and `--contents -` stdin) silently
submitted `contents:[null]`. Root cause: the `--contents-file` flag had no
transform, so its value mapped to an unused param while `--contents` stayed empty.

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

bash -n + YAML validated.
2026-06-25 16:21:34 +08:00
230 changed files with 26323 additions and 8187 deletions
-103
View File
@@ -1,103 +0,0 @@
name: Auto Dev Release
# 推到 dev 分支 → 跑测试 → 通过才自动出一个 dev 预览 release。
# install-devapp.sh / .ps1 取 fork 最新 release,所以发完安装链接即最新。
#
# 设计要点:
# - 自包含:测试 + 算版本 + 打 tag + 发 release 全在一个 job,不依赖 tag 触发
# release.yml(GITHUB_TOKEN 推的 tag 本来也不会触发别的 workflow)。
# - 质量门:测试这一步失败,后面发布步骤就不会跑。
# - 不含 npm publish,所以自动发布天然是绿的。
on:
push:
branches:
- feat/dws-devapp
# 串行:两次 push 不会同时算版本号撞车
concurrency:
group: auto-dev-release
cancel-in-progress: false
permissions:
contents: write
jobs:
test-and-release:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
# ---- 质量门:测试不过就不发 ----
- name: Build
run: make build
- name: Test
run: go test -race -count=1 -timeout=5m ./cmd/... ./internal/...
# ---- 算下一个 dev 版本号并打 tag ----
- name: Compute next dev version
id: ver
run: |
set -eu
LATEST=$(git tag -l 'v*-dev.*' --sort=-v:refname | head -1 || true)
if [ -z "$LATEST" ]; then
BASE="v1.0.39"; N=0
else
BASE=$(printf '%s' "$LATEST" | sed -E 's/-dev\.[0-9]+$//')
N=$(printf '%s' "$LATEST" | sed -E 's/.*-dev\.([0-9]+)$/\1/')
fi
NEW="${BASE}-dev.$((N + 1))"
echo "version=$NEW" >> "$GITHUB_OUTPUT"
echo "next dev release: $NEW (prev: ${LATEST:-none})"
- name: Create and push tag
run: |
set -eu
V="${{ steps.ver.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$V"
git push origin "$V"
# ---- 自己发 release(不靠 tag 触发 release.yml)----
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
run: |
set -eu
RCS_VERSION="0.27.0"
curl -fsSL -o /tmp/rcodesign.tar.gz \
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
mkdir -p /tmp/rcodesign
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
rcodesign --version
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post-release packaging
run: ./scripts/release/post-goreleaser.sh
env:
DWS_PACKAGE_VERSION: ${{ steps.ver.outputs.version }}
- name: Upload dws-skills.zip to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${{ steps.ver.outputs.version }}" dist/dws-skills.zip --clobber
+35 -4
View File
@@ -31,14 +31,45 @@ jobs:
with:
fetch-depth: 0
- name: Push main + tags to Gitee
- name: Push main + tags to Gitee (with README localization)
if: env.GITEE_TOKEN != ''
run: |
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
# 镜像对齐(force:Gitee 始终跟随 GitHub)
git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
git checkout -B gitee-main origin/main
git config user.email "actions@github.com"
git config user.name "github-actions[bot]"
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
done
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
SVG=".github/badges/coverage.svg"
if [ -f "$SVG" ]; then
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
NUM="${PCT%\%}"; INT="${NUM%.*}"
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
done
fi
git add README.md README_zh.md 2>/dev/null || true
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
git push --force --tags "$REMOTE"
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
+54
View File
@@ -0,0 +1,54 @@
name: Multi Profile E2E
on:
pull_request:
push:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: multi-profile-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
multi-profile-e2e:
name: Multi Profile E2E
runs-on: ubuntu-latest
timeout-minutes: 15
env:
MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run isolated multi-profile chain
shell: bash
run: |
set -o pipefail
mkdir -p .tmp-bin
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
{
echo "### Multi Profile E2E"
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
echo "- Result: passed"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload debug artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: multi-profile-e2e-debug
path: |
.tmp-bin/multi-profile-e2e.*/out
.tmp-bin/multi-profile-e2e.log
if-no-files-found: ignore
retention-days: 3
+7 -1
View File
@@ -12,7 +12,10 @@ permissions:
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
# idempotent (re-runs only upload missing assets).
timeout-minutes: 60
steps:
- name: Check out repository
@@ -28,6 +31,9 @@ jobs:
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Multi Profile E2E
run: bash scripts/dev/test-multi-profile-e2e.sh
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
run: |
set -eu
@@ -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 }}
+65
View File
@@ -6,6 +6,71 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.45] - 2026-06-29
This release adds **multi-organization (profile) support** (#500): `dws` can stay logged in to several DingTalk organizations at once and switch between them, while staying fully backward/forward compatible with the previous single-org token. A profile is one logged-in organization (corp); the current profile decides which org a command runs against. The release also hardens the new credential store for concurrency and corruption recovery, documents the capability in both the mono and multi skill sets, and flips `--ai-tag` on by default so messages sent through `dws` carry the DingTalk 「通过AI发送」 badge (#524).
### Added
- **Multi-organization login & `profile` management** (`internal/auth/profiles.go`, `internal/app/profile_command.go`) — `dws auth login` against a new organization adds a profile (the first login becomes the primary); `dws profile list` shows logged-in orgs with primary / current markers, status and validity; `dws profile switch <name|corpId|->` persistently switches the default org (`-` toggles back to the previous one, no-arg opens a TUI selector on a terminal); `dws profile use` is an alias of `switch`. `dws auth status [--profile <name>]` reports a specific profile. Credentials are stored per organization in keychain slots keyed by corpId (`auth-token:<corpId>`), with a plaintext `profiles.json` registry holding only metadata and the primary/current/previous pointers (no tokens).
- **Global `--profile <name|corpId>` flag** — run a single command against a specific organization without changing the default (one-shot; does not move currentProfile). Cross-org reads are orchestrated by the agent (list profiles → query each with `--profile` → merge); there is intentionally no built-in `--all-orgs`.
- **Backward / forward compatibility with the legacy single token slot** — a pre-existing single-slot token is migrated into `auth-token:<corpId>` and marked primary on first multi-profile use; the current (or primary) profile's token is mirrored back into the legacy slot so older binaries and the embedded host keep working. `profiles.json` is additive and ignored by older versions.
- **`dingtalk-profile` and `dws-shared` skills + multi-org documentation** (`skills/`) — a standalone `dingtalk-profile` skill plus a new `dws-shared` skill that carries auth, global flags and the multi-org rule, so every multi-mode product skill's PREREQUISITE resolves and all read/search skills inherit cross-org behavior. The mono skill gains a "multi-org / profile" section, trigger conditions, a decision-tree entry and a corrected logout danger note. Multi-mode install now always ships `dws-shared` even when `--skill` / `--exclude` narrows the set.
### Changed
- **`--ai-tag` now defaults on — DingTalk 「通过AI发送」 badge for dws-sent messages** (`internal/helpers/chat.go`, #524) — `chat message send` / `reply` flip the `--ai-tag` default from false to true, attaching the AI `clawType` by default so messages sent through `dws` (and by AI agents) transparently carry the 「通过AI发送」 badge; pass `--ai-tag=false` to send as the user with no badge.
- **Concurrency-safe, self-healing `profiles.json`** (`internal/auth/profiles.go`, `internal/auth/token.go`) — every read-modify-write on `profiles.json` and the legacy mirror is serialized under the existing dual-layer (process + cross-process) lock, split into public (locking) entry points and lock-free `*Locked` variants so the non-reentrant lock is never re-acquired (the refresh path and the load-path migration use the lock-free savers). `profiles.json` and the token marker are written via per-write random temp names + atomic rename so concurrent writers can no longer corrupt a fixed `.tmp`. An unparseable `profiles.json` is quarantined (`*.corrupt-*`) and rebuilt empty so the CLI self-heals; `auth reset` / `logout` proceed even when it cannot be read and sweep the quarantined files.
### Fixed
- **No silent fallback to a different org's token** (`internal/auth/token.go`) — when the resolved current/primary profile's keychain slot fails to read and no `--profile` was given, the loader now only falls back to the legacy single slot if it belongs to the same organization; otherwise it surfaces the error instead of acting as a different org.
- **Legacy mirror no longer wiped on a transient keychain read error** (`internal/auth/profiles.go`) — `SyncLegacyTokenMirror` distinguishes "token genuinely absent" from "keychain momentarily unreadable" and keeps the existing mirror in the latter case, so a host app's login state is not dropped by a transient failure.
## [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.
+48 -22
View File
@@ -135,6 +135,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
**3. Skills only (Gitee mirror):**
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
```
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
## Upgrade
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
@@ -225,6 +233,22 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
</details>
<details>
<summary><strong>Multiple organizations (profiles)</strong></summary>
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
```bash
dws auth login # log in to another org → adds a profile (first login becomes the primary)
dws profile list # list logged-in orgs (primary / current marker, status)
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
```
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
</details>
<details>
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
@@ -309,6 +333,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`:**
@@ -514,29 +540,29 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
## Key Services
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| 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 |
| 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 |
| 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 |
| Service | Command | Capabilities |
|---------|---------|--------------|
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
| Todo | `todo` | Create / list / update / complete tasks and comments |
| Approval | `oa` | Approve / reject / revoke / transfer; query pending / initiated / CC instances and forms |
| Attendance | `attendance` | Clock-in records, shifts, summaries, group rules (read-only) |
| Ding | `ding` | Send / recall DING messages |
| Report | `report` | Create / submit logs, inbox & outbox, templates, statistics |
| AI Tables | `aitable` | Bases / tables / records / fields / views, permissions & roles, automation, charts & dashboards, import / export |
| Doc | `doc` | Search / read / write docs, block-level editing, comments, permissions, media, up / download |
| Drive | `drive` | List / search / download, folders, upload, copy / move / rename, permissions |
| Minutes | `minutes` | AI meeting notes: list, summary / keywords / transcription / todos, mind map, speakers, tags |
| Mail | `mail` | Mailboxes, KQL search, read / send, drafts, folders, templates, contacts |
| Sheet | `sheet` | Online spreadsheets: worksheet & range read / write, filters, conditional format, images, CSV |
| Wiki | `wiki` | Knowledge bases: spaces, members, node tree, docs & files |
| DevDoc | `devdoc` | Search the Open Platform docs and diagnose API errors |
| AI Search | `aisearch` | Enterprise people search by name / dept / role / duty / supervisor / phone / job-number |
| Live | `live` | List my live streams |
| Raw API | `api` | Call any DingTalk OpenAPI directly, with managed app-level token |
> **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.
> Full command listing with usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for any service's 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.
+48 -22
View File
@@ -135,6 +135,14 @@ npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
**3. 单独安装 Skills(Gitee 镜像):**
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
```
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
## 升级
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
@@ -225,6 +233,22 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
</details>
<details>
<summary><strong>多组织(profile)</strong></summary>
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
```bash
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
dws profile list # 列出已登录组织(主 / 当前标记、状态)
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
```
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
</details>
<details>
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
@@ -306,6 +330,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` 切换或重装:**
@@ -510,29 +536,29 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
## 核心服务
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 15 | `user` `dept` `label` `relation` | 按姓名 / 手机号 / 工号搜索、批量查询、部门树、角色标签、人员关系、花名册与离职、当前用户信息 |
| 群聊 | `chat`(别名 `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` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
| 日历 | `calendar` | 17 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 16 | `task` `comment` | 创建、列表、修改、完成、详情、删除,以及任务评论 |
| 审批 | `oa` | 15 | `approval` | 同意 / 拒绝 / 撤销 / 转交、待我审批 / 我发起 / 已提交 / 已办 / 抄送、流程表单、评论、操作记录 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
| 日志 | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | 创建 / 提交日志、收发(收件箱 / 发件箱)列表、模版(获取 / 列表)、详情、统计、单条获取 |
| AI 表格 | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件(仅获取凭证的 `upload` + 一键上传 `upload-file`);数据表表单;模板 |
| 文档 | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
| 钉盘 | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | 钉盘文件操作:列出空间、文件列表 / 详情 / 下载、创建文件夹、一键 `upload`(三步合成)或两阶段 `upload-info` + `commit`、删除 |
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`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`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档并排查 API 调用错误 |
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
| 服务 | 命令 | 能力 |
|------|------|------|
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
| 审批 | `oa` | 同意 / 拒绝 / 撤销 / 转交,查待办 / 已发起 / 抄送及表单 |
| 考勤 | `attendance` | 打卡记录、排班、考勤摘要、考勤组规则(只读) |
| DING | `ding` | 发送 / 撤回 DING 消息 |
| 日志 | `report` | 创建 / 提交日志,收发件箱,模版,统计 |
| AI 表格 | `aitable` | Base / 数据表 / 记录 / 字段 / 视图,权限与角色,自动化,图表与仪表盘,导入导出 |
| 文档 | `doc` | 搜索 / 读写文档,块级编辑,评论,权限,媒体,上传 / 下载 |
| 钉盘 | `drive` | 列表 / 搜索 / 下载,文件夹,上传,复制 / 移动 / 重命名,权限 |
| AI 听记 | `minutes` | 听记列表、摘要 / 关键词 / 转写 / 待办、思维导图、发言人、标签 |
| 邮箱 | `mail` | 邮箱、KQL 搜索、读 / 发、草稿、文件夹、模版、联系人 |
| 在线电子表格 | `sheet` | 在线表格:工作表与区域读写、筛选、条件格式、图片、CSV |
| 知识库 | `wiki` | 知识库:空间、成员、节点树、文档与文件 |
| 开发者文档 | `devdoc` | 搜索开放平台文档并排查 API 错误 |
| AI 搜问 | `aisearch` | 企业人员搜索:按姓名 / 部门 / 角色 / 职责 / 上下级 / 手机号 / 工号 |
| 直播 | `live` | 查看我的直播列表 |
| Raw API | `api` | 直接调用任意钉钉 OpenAPI,自动管理应用级 Token |
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看任一服务的子命令。
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
+113 -97
View File
@@ -1,4 +1,4 @@
# DevApp 一键安装与 Agent 接入指南
# dws dev 一键安装与 Agent 接入指南
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
@@ -6,45 +6,44 @@
## 一键安装
当前 DevApp 能力在 `feat/dws-devapp` 预览分支上。要安装这个分支里的最新能力,请使用 DevApp 专用安装脚本:
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
```bash
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
### Windows(PowerShell)
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
这个脚本会:
1. 拉取 `wxianfeng/dingtalk-workspace-cli` 的 `feat/dws-devapp` 分支。
2. 使用本地源码构建 `dws`。
3. 安装 `dws` 到默认目录 `~/.local/bin`。
4. 安装 Agent Skill 到本机已检测到的 Agent 目录,只安装通用 `dws` 和 DevApp 专用 `dws-devapp` 两个 skill。
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
> 预览分支安装需要本机已有 `git`、`go` 和 `make`。Go 版本要求以仓库 `go.mod` 为准。
如果 DevApp 能力已经发布到正式 Release,可以改用正式安装命令:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
```
Windows PowerShell 正式安装命令:
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
安装脚本支持这些环境变量:
支持这些环境变量(全部可选):
| 变量 | 说明 |
|---|---|
| `DEVAPP_REPO_URL` | 覆盖源码仓库地址,默认 `https://github.com/wxianfeng/dingtalk-workspace-cli.git` |
| `DEVAPP_BRANCH` | 覆盖安装分支,默认 `feat/dws-devapp` |
| `DEVAPP_SOURCE_DIR` | 使用已有源码目录安装,跳过 clone |
| `DEVAPP_KEEP_SOURCE=1` | 保留临时源码目录,便于调试 |
| `DEVAPP_SKIP_SKILL_SETUP=1` | 跳过自动安装 `dws` 与 `dws-devapp` skill |
| `DEVAPP_SKILL_NAME` | 覆盖 DevApp skill 安装名称,默认 `dws-devapp` |
| `DWS_INSTALL_DIR` | 传给底层 `scripts/install.sh`,覆盖 `dws` 安装目录 |
| `DWS_SKILL_MODE` | 传给底层 `scripts/install.sh`,选择 `mono` 或 `multi` |
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
### 国内加速
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
## 安装后验证
@@ -54,13 +53,13 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
dws version
```
确认 DevApp 命令存在:
确认 `dws dev app` 命令存在:
```bash
dws devapp --help --format json
dws dev app --help --format json
```
如果能看到 `list`、`get`、`create`、`permission`、`robot`、`security`、`version` 等能力,说明 DevApp 已安装成功。
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
确认登录状态:
@@ -77,14 +76,18 @@ dws auth login
登录完成后读取应用列表:
```bash
dws devapp list --format json
dws dev app list --format json
```
## DevApp 是什么
## dws dev 是什么
DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
它让 Agent 可以完成这些工作:
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
- `dws dev doc` — 开放平台开发文档搜索。
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
- 查询、创建、更新、启用、停用、删除开放平台应用。
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
@@ -92,12 +95,12 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
- 查询、申请、移除权限点。
- 管理应用成员。
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
- 创建、查询、更新、启用、停用机器人。
- 异步创建机器人、配置/启停现有机器人。
- 创建版本、发起发布、查询审批和发布状态。
## 给 Agent 使用
安装完成后,可以直接让 Agent 操作 DevApp。
安装完成后,可以直接让 Agent 操作 `dws dev`。
示例:
@@ -114,7 +117,7 @@ DevApp 是开放平台应用管理能力的 CLI 和 Agent Skill 入口。安装
```
```text
帮我发布这个应用版本,先检查发布前置条件。
帮我发布这个应用版本,先预检是否需要审批。
```
Agent 写操作必须遵循:
@@ -127,34 +130,30 @@ Agent 写操作必须遵循:
## 第一个写操作
推荐用机器人配置作为 smoke test。先 dry-run:
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
提交建号任务(记下返回的 `taskId`):
```bash
dws devapp robot config \
--unified-app-id <unifiedAppId> \
--name "告警机器人" \
--brief "告警通知" \
dws dev app robot submit \
--name "告警助手" \
--robot-name "告警机器人" \
--desc "处理告警通知和事件回调" \
--dry-run \
--format json
```
确认预览无误后执行:
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
```bash
dws devapp robot config \
--unified-app-id <unifiedAppId> \
--name "告警机器人" \
--brief "告警通知" \
--desc "处理告警通知和事件回调" \
--yes \
--format json
dws dev app robot result --task-id <taskId> --format json
```
回读验证:
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
```bash
dws devapp robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
```
## 常用命令
@@ -162,19 +161,21 @@ dws devapp robot get --unified-app-id <unifiedAppId> --format json
### 应用管理
```bash
dws devapp list --format json
dws devapp get --unified-app-id <unifiedAppId> --format json
dws devapp create --name "考勤应用" --dry-run --format json
dws devapp update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
dws devapp inactive --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp active --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp delete --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app list --format json
dws dev app get --unified-app-id <unifiedAppId> --format json
dws dev app create --name "考勤应用" --dry-run --format json
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
```
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
### 凭证查询
```bash
dws devapp credentials get --unified-app-id <unifiedAppId> --format json
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
```
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
@@ -182,68 +183,82 @@ dws devapp credentials get --unified-app-id <unifiedAppId> --format json
### 权限点管理
```bash
dws devapp permission list --unified-app-id <unifiedAppId> --format json
dws devapp permission add --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
dws devapp permission remove --unified-app-id <unifiedAppId> --permissions Contact.User.mobile --dry-run --format json
dws dev app permission list --unified-app-id <unifiedAppId> --format json
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
```
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
### 机器人配置
### 机器人能力
```bash
dws devapp robot get --unified-app-id <unifiedAppId> --format json
dws devapp robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
dws devapp robot enable --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp robot disable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
dws dev app robot result --task-id <taskId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
```
### 成员与安全
```bash
dws devapp member list --unified-app-id <unifiedAppId> --format json
dws devapp member add --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
dws devapp member remove --unified-app-id <unifiedAppId> --users <userId> --dry-run --format json
dws devapp security config --unified-app-id <unifiedAppId> --redirect-url <url> --dry-run --format json
dws devapp security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
dws dev app member list --unified-app-id <unifiedAppId> --format json
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
```
### 网页应用与事件
```bash
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
dws dev app event list --unified-app-id <unifiedAppId> --format json
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
```
### 版本发布
```bash
dws devapp version list --unified-app-id <unifiedAppId> --format json
dws devapp version list --unified-app-id <unifiedAppId> --cursor <nextCursor> --format json
dws devapp version create --unified-app-id <unifiedAppId> --dry-run --format json
dws devapp version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
dws devapp version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version list --unified-app-id <unifiedAppId> --format json
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
```
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
## 安全边界
DevApp 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
默认安全策略:
- 写操作先 dry-run。
- 删除、停用、发布必须由用户确认。
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
- 应用定位优先使用 `agentId`、`unifiedAppId`、`appKey`。
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
## 排障
### `dws devapp` 不存在
### `dws dev app` 不存在
先确认安装的是预览分支源码,而不是正式 Release:
先确认装上的是带 `dws dev` 的版本:
```bash
dws version
dws devapp --help --format json
dws dev app --help --format json
```
如果正式 Release 尚未包含 DevApp,请重新执行本文的一键源码安装命令。
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
### `dws devapp list` 失败
### `dws dev app list` 失败
优先检查登录态:
@@ -254,11 +269,13 @@ dws auth login
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
### 提示"当前用户没有开发者身份"
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
### 页面能操作,但 CLI 或 MCP 提示无权限
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。
先确认当前用户是否满足以下任一条件:
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
- 应用 owner。
- 应用管理员。
@@ -270,18 +287,17 @@ dws auth login
先查当前机器人状态:
```bash
dws devapp robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot get --unified-app-id <unifiedAppId> --format json
```
如果机器人不存在,使用 `robot config` 创建或配置。
如果机器人已存在,继续用 `robot config` 修改配置,或用 `robot enable` 重新启用。
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
## 页面文案建议
用于产品页顶部:
```text
Install DevApp in one command.
Install dws dev in one command.
Let your coding agents manage DingTalk Open Platform apps from the terminal:
create apps, configure robots, apply permissions, manage security settings,
@@ -291,7 +307,7 @@ and publish versions with dry-run safety built in.
中文版本:
```text
一行命令接入 DevApp。
一行命令接入 dws dev。
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -13,9 +13,14 @@
打开终端,整段复制执行:
```bash
curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
> ```bash
> DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
> ```
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
### Windows
@@ -23,12 +28,12 @@ curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/fe
打开 PowerShell,整段复制执行:
```powershell
irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
> 能打印出版本号即安装成功(脚本默认装当前最新的预览版)。脚本走 GitHub API 取最新预览版,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
### 登录钉钉
+3 -3
View File
@@ -4,10 +4,13 @@ go 1.25.8
require (
github.com/RealAlexandreAI/json-repair v0.0.15
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.18
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
@@ -21,9 +24,7 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
@@ -44,7 +45,6 @@ require (
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.20.0 // indirect
+277 -48
View File
@@ -39,11 +39,12 @@ import (
)
type authLoginConfig struct {
Token string
Force bool
Device bool
Recommend bool
Yes bool
Token string
Force bool
Device bool
Recommend bool
Yes bool
TargetCorpID string
}
type authLoginGuideAction string
@@ -109,10 +110,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
示例:
dws auth login # 本机登录后选择推荐/全部权限与授权业务域
dws auth login # 本机登录并新增/刷新一个组织 profile
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
dws auth login --recommend # 无交互批量授权服务端推荐权限
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
dws auth login --force # 强制重新登录 (忽略缓存 token)
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
dws auth login --token xxx # 使用指定 token`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -154,8 +156,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider := authpkg.NewOAuthProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
provider.TargetCorpID = cfg.TargetCorpID
configureOAuthProviderCompatibility(provider, configDir)
tokenData, err = provider.Login(loginCtx, cfg.Force)
tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg))
if err != nil {
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
}
@@ -163,6 +166,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
ResetRuntimeTokenCache()
clearCompatCache()
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" {
_ = enrichAuthLoginProfileFromContact(cmd.Context(), configDir, patCaller, tokenData)
ResetRuntimeTokenCache()
clearCompatCache()
}
w := cmd.OutOrStdout()
runPostLoginAuthorization := func() error {
@@ -217,7 +225,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
if err := runPostLoginAuthorization(); err != nil {
return err
}
return writeAuthLoginJSON(w, tokenData, cfg.Force)
return writeAuthLoginJSON(w, tokenData, authLoginForcesAuthorization(cfg))
}
// Default table output
@@ -225,7 +233,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
return err
}
fmt.Fprintln(w)
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) {
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
} else {
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
@@ -250,7 +258,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
}
cmd.Flags().String("token", "", "Access token")
cmd.Flags().Bool("device", false, "Use device authorization flow")
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
// Hidden compatibility flags
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
@@ -373,58 +381,67 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
}
func newAuthLogoutCommand() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "清除认证信息",
cmd := &cobra.Command{
Use: "logout",
Short: "清除认证信息(默认退出所有组织)",
Long: `清除本机钉钉登录态。
默认退出所有已登录组织 profile;指定 --profile 时只退出该组织,不影响其他组织。`,
Example: ` dws auth logout
dws auth logout --profile <corpId>
dws auth logout --profile "钉钉"`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
profileSelector, err := cmd.Flags().GetString("profile")
if err != nil {
return apperrors.NewInternal("failed to read --profile")
}
revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
defer cancel()
_ = authpkg.RevokeTokenRemote(revokeCtx)
// Load token data to get associated clientId before deletion
var storedClientID string
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
storedClientID = tokenData.ClientID
if strings.TrimSpace(profileSelector) != "" {
if err := logoutOneProfile(cmd, revokeCtx, configDir, profileSelector); err != nil {
return err
}
} else {
if err := logoutAllProfiles(cmd, revokeCtx, configDir); err != nil {
return err
}
}
if err := authpkg.DeleteTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
// Clean up associated client secret and app token from keychain
if storedClientID != "" {
_ = authpkg.DeleteClientSecret(storedClientID)
_ = authpkg.DeleteAppTokenData(storedClientID)
}
// Also try cleaning app token using appKey from app config
if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID {
_ = authpkg.DeleteAppTokenData(appKey)
}
// Clean up app credentials (app.json + keychain secret)
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = os.Remove(filepath.Join(configDir, "token.json"))
ResetRuntimeTokenCache()
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
fmt.Fprintln(w, "[OK] 已清除认证信息")
if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
}
return nil
},
}
cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId")
return cmd
}
func newAuthStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "查看认证状态",
cmd := &cobra.Command{
Use: "status",
Short: "查看认证状态",
Long: `查看当前或指定组织 profile 的认证状态。
指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`,
Example: ` dws auth status
dws auth status --profile <corpId>
dws auth status --profile "钉钉"
dws auth status --profile <corpId> --format json`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
profileSelector, err := cmd.Flags().GetString("profile")
if err != nil {
return apperrors.NewInternal("failed to read --profile")
}
restoreProfile := pushRuntimeProfile(profileSelector)
defer restoreProfile()
authenticated := false
refreshed := false
@@ -444,6 +461,8 @@ func newAuthStatusCommand() *cobra.Command {
}
} else if edition.Get().AutoPurgeToken {
_ = authpkg.DeleteTokenData(configDir)
} else if tokenData != nil {
_ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
}
}
if authStatusAuthenticated(tokenData) {
@@ -467,6 +486,12 @@ func newAuthStatusCommand() *cobra.Command {
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
}
if tokenData != nil {
if tokenData.CorpName != "" {
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
}
if tokenData.CorpID != "" {
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
}
if tokenData.IsRefreshTokenValid() {
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅")
} else {
@@ -485,6 +510,74 @@ func newAuthStatusCommand() *cobra.Command {
return nil
},
}
cmd.Flags().String("profile", "", "指定要查看的 profile 名或 corpId")
return cmd
}
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
return apperrors.NewValidation(err.Error())
}
restoreProfile := pushRuntimeProfile(selector)
defer restoreProfile()
_ = authpkg.RevokeTokenRemote(ctx)
if err := authpkg.DeleteTokenDataForProfile(configDir, selector); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
return nil
}
func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error {
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
if cfg == nil || len(cfg.Profiles) == 0 {
_ = authpkg.RevokeTokenRemote(ctx)
} else {
for _, profile := range cfg.Profiles {
restoreProfile := pushRuntimeProfile(profile.CorpID)
_ = authpkg.RevokeTokenRemote(ctx)
restoreProfile()
}
}
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
return nil
}
func pushRuntimeProfile(selector string) func() {
selector = strings.TrimSpace(selector)
if selector == "" {
return func() {}
}
previous := authpkg.RuntimeProfile()
authpkg.SetRuntimeProfile(selector)
return func() {
authpkg.SetRuntimeProfile(previous)
}
}
func cleanupAuthConfigIfNoProfiles(configDir string) {
cfg, err := authpkg.LoadProfiles(configDir)
if err == nil && len(cfg.Profiles) > 0 {
return
}
if authpkg.TokenDataExistsKeychain() {
return
}
appKey, _ := authpkg.ResolveAppCredentials(configDir)
if appKey != "" {
_ = authpkg.DeleteAppTokenData(appKey)
}
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = authpkg.DeleteTokenMarker(configDir)
}
func newAuthExportCommand() *cobra.Command {
@@ -683,11 +776,12 @@ func newAuthResetCommand() *cobra.Command {
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
if err := authpkg.DeleteTokenData(configDir); err != nil {
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
}
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = authpkg.DeleteAppConfig(configDir)
ResetRuntimeTokenCache()
clearCompatCache()
w := cmd.OutOrStdout()
@@ -958,18 +1052,153 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
}
yes := false
profileSelector := ""
if cmd.Root() != nil {
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
}
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
if err != nil {
return authLoginConfig{}, err
}
return authLoginConfig{
Token: strings.TrimSpace(token),
Force: force,
Device: device,
Recommend: recommend,
Yes: yes,
Token: strings.TrimSpace(token),
Force: force,
Device: device,
Recommend: recommend,
Yes: yes,
TargetCorpID: targetCorpID,
}, nil
}
func authLoginForcesAuthorization(_ authLoginConfig) bool {
return true
}
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
selector = strings.TrimSpace(selector)
if selector == "" {
return "", nil
}
if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil {
return strings.TrimSpace(profile.CorpID), nil
}
if strings.HasPrefix(selector, "ding") {
return selector, nil
}
return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
}
type contactProfileIdentity struct {
CorpID string
CorpName string
UserID string
UserName string
}
func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, caller edition.ToolCaller, data *authpkg.TokenData) error {
if caller == nil || data == nil {
return nil
}
corpID := strings.TrimSpace(data.CorpID)
if corpID == "" {
return nil
}
if strings.TrimSpace(data.CorpName) != "" && strings.TrimSpace(data.UserID) != "" && strings.TrimSpace(data.UserName) != "" {
return nil
}
restoreProfile := pushRuntimeProfile(corpID)
defer restoreProfile()
ResetRuntimeTokenCache()
result, err := caller.CallTool(ctx, "contact", "get_current_user_profile", map[string]any{
"profile": corpID,
})
if err != nil {
return err
}
identity, ok := contactProfileIdentityFromToolResult(result)
if !ok {
return nil
}
if identity.CorpID != "" && identity.CorpID != corpID {
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
}
updated := *data
if identity.CorpName != "" {
updated.CorpName = identity.CorpName
}
if identity.UserID != "" {
updated.UserID = identity.UserID
}
if identity.UserName != "" {
updated.UserName = identity.UserName
}
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
return nil
}
if err := authpkg.SaveTokenData(configDir, &updated); err != nil {
return err
}
*data = updated
return nil
}
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
if result == nil {
return contactProfileIdentity{}, false
}
for _, block := range result.Content {
if strings.TrimSpace(block.Text) == "" {
continue
}
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
return identity, true
}
}
return contactProfileIdentity{}, false
}
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
var payload struct {
Result []struct {
OrgEmployeeModel struct {
CorpID string `json:"corpId"`
OrgName string `json:"orgName"`
UserID string `json:"userId"`
UserIDLower string `json:"userid"`
OrgUserName string `json:"orgUserName"`
Name string `json:"name"`
} `json:"orgEmployeeModel"`
} `json:"result"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return contactProfileIdentity{}, false
}
if len(payload.Result) == 0 {
return contactProfileIdentity{}, false
}
org := payload.Result[0].OrgEmployeeModel
identity := contactProfileIdentity{
CorpID: strings.TrimSpace(org.CorpID),
CorpName: strings.TrimSpace(org.OrgName),
UserID: firstNonEmptyString(org.UserID, org.UserIDLower),
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
}
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func authStatusAuthenticated(data *authpkg.TokenData) bool {
if data == nil {
return false
+249
View File
@@ -184,6 +184,161 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
}
}
func TestAuthStatusTableIncludesCorpName(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"))
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --format table error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"企业:", "corp_primary org", "企业 ID:", "corp_primary"} {
if !bytes.Contains(out.Bytes(), []byte(want)) {
t.Fatalf("auth status table missing %q in output:\n%s", want, out.String())
}
}
}
func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "auth", "status", "--profile", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --profile error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"corp_primary org", "corp_primary"} {
if !bytes.Contains(out.Bytes(), []byte(want)) {
t.Fatalf("auth status --profile output missing %q:\n%s", want, out.String())
}
}
if bytes.Contains(out.Bytes(), []byte("corp_secondary org")) {
t.Fatalf("auth status --profile should render selected profile, got:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile)
}
}
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
ClientID: "client-app",
ClientSecret: authpkg.PlainSecret("secret-app"),
}); err != nil {
t.Fatalf("SaveAppConfig() error = %v", err)
}
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("remote revoke disabled in unit test")
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "logout"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth logout error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"[OK] 已清除认证信息", "重新登录"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("auth logout output missing %q:\n%s", want, out.String())
}
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
t.Fatalf("profiles after logout = %#v, want empty", cfg)
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
t.Fatal("primary profile token should be deleted")
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
t.Fatal("secondary profile token should be deleted")
}
if authpkg.TokenDataExistsKeychain() {
t.Fatal("legacy auth-token mirror should be deleted")
}
appConfig, err := authpkg.LoadAppConfig(configDir)
if err != nil {
t.Fatalf("LoadAppConfig() error = %v", err)
}
if appConfig == nil || appConfig.ClientID != "client-app" {
t.Fatalf("app config after logout = %#v, want preserved client-app", appConfig)
}
}
func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("remote revoke disabled in unit test")
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth logout --profile corp_primary error = %v\noutput:\n%s", err, out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile)
}
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" {
t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles)
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
t.Fatal("selected primary profile token should be deleted")
}
if !authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
t.Fatal("unselected secondary profile token should be retained")
}
loaded, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.CorpID != "corp_secondary" || loaded.AccessToken != "access-corp_secondary" {
t.Fatalf("default token = (%q, %q), want retained secondary token", loaded.CorpID, loaded.AccessToken)
}
}
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
newRoot := func(t *testing.T) *cobra.Command {
t.Helper()
@@ -297,6 +452,15 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
}
}
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
if !authLoginForcesAuthorization(authLoginConfig{}) {
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
}
if !authLoginForcesAuthorization(authLoginConfig{Force: false}) {
t.Fatal("Force=false should still force authorization")
}
}
func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
@@ -578,6 +742,53 @@ func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
}
}
func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
token := &authpkg.TokenData{
AccessToken: "access-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "ding32fff839a3e0105d",
ClientID: "client-id",
Source: "mcp",
}
if err := authpkg.SaveTokenData(configDir, token); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
}}
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil {
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
}
if token.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("token corpName = %q, want 钉钉(中国)信息技术有限公司", token.CorpName)
}
if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" {
t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName)
}
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d")
if err != nil {
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
}
if loaded.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("persisted corpName = %q, want 钉钉(中国)信息技术有限公司", loaded.CorpName)
}
if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" {
t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools)
}
if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -642,3 +853,41 @@ func stringSliceArgEqual(got any, want []string) bool {
return false
}
}
func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string {
t.Helper()
root := t.TempDir()
configDir := filepath.Join(root, "config")
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
t.Setenv("DWS_CONFIG_DIR", configDir)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
clearCompatCache()
t.Cleanup(func() {
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
clearCompatCache()
})
for _, token := range tokens {
if err := authpkg.SaveTokenData(configDir, token); err != nil {
t.Fatalf("SaveTokenData(%s) error = %v", token.CorpID, err)
}
}
return configDir
}
func authLogoutTestToken(corpID string) *authpkg.TokenData {
return &authpkg.TokenData{
AccessToken: "access-" + corpID,
RefreshToken: "refresh-" + corpID,
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: corpID,
CorpName: corpID + " org",
UserID: "user-" + corpID,
UserName: "User " + corpID,
ClientID: "client-" + corpID,
}
}
+203
View File
@@ -0,0 +1,203 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"encoding/json"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/spf13/cobra"
)
// toolMappingParam 描述一个 MCP 参数到 CLI flag + 中文友好名的映射。
type toolMappingParam struct {
Flag string `json:"flag"`
Label string `json:"label"`
Type string `json:"type,omitempty"`
}
// toolMappingEntry 是单个 MCP 工具的映射条目。key 用 RPCName,对齐 SLS 日志的 tool 字段。
type toolMappingEntry struct {
Product string `json:"product"`
CLICommand string `json:"cliCommand"`
DisplayName string `json:"displayName"`
Params map[string]toolMappingParam `json:"params,omitempty"`
}
// toolMapping 是给开放平台日志页渲染用的全量映射契约。
type toolMapping struct {
Version string `json:"version"`
Count int `json:"count"`
Tools map[string]toolMappingEntry `json:"tools"`
}
// newCatalogCommand 提供 `dws catalog export`:把已发现的工具目录投影成
// tool→指令 映射 JSON,供开放平台 MCP/DWS 日志页把 tool/args 渲染成中文友好名。
// 复用 root 注入的带 auth 的 loader(缓存优先;建议先 `dws cache refresh`)。
func newCatalogCommand(loader cli.CatalogLoader) *cobra.Command {
catalogCmd := &cobra.Command{
Use: "catalog",
Short: "导出已发现的工具目录(内部用)",
Hidden: true,
}
var out string
var version string
exportCmd := &cobra.Command{
Use: "export",
Short: "导出 tool→指令 映射 JSON(供开放平台日志页渲染)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
catalog, err := loader.Load(cmd.Context())
if err != nil {
return err
}
mapping := projectToolMapping(catalog, version)
data, err := json.MarshalIndent(mapping, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if strings.TrimSpace(out) == "" {
_, werr := os.Stdout.Write(data)
return werr
}
return os.WriteFile(out, data, 0o644)
},
}
exportCmd.Flags().StringVar(&out, "out", "", "输出文件路径(默认 stdout)")
exportCmd.Flags().StringVar(&version, "version", "dev", "版本号标记")
catalogCmd.AddCommand(exportCmd)
return catalogCmd
}
// projectToolMapping 把 ir.Catalog 投影成 toolMapping 契约。
func projectToolMapping(catalog ir.Catalog, version string) toolMapping {
mapping := toolMapping{Version: version, Tools: make(map[string]toolMappingEntry)}
for _, product := range catalog.Products {
command := ""
if product.CLI != nil {
command = strings.TrimSpace(product.CLI.Command)
}
if command == "" {
command = product.ID
}
for _, tool := range product.Tools {
if tool.Hidden {
continue
}
entry := toolMappingEntry{
Product: command,
CLICommand: tmBuildCLICommand(command, tool),
DisplayName: tmFirstNonEmpty(tool.Title, tmFirstNonEmpty(tmFirstLine(tool.Description), tool.RPCName)),
Params: make(map[string]toolMappingParam),
}
for name, raw := range tmSchemaProperties(tool.InputSchema) {
prop, _ := raw.(map[string]any)
overlay, hasOverlay := tool.FlagOverlay[name]
if hasOverlay && overlay.Hidden {
continue
}
flag := tmKebab(name)
if hasOverlay && strings.TrimSpace(overlay.Alias) != "" {
flag = strings.TrimSpace(overlay.Alias)
}
label := tmMapStr(prop, "title")
if label == "" {
label = tmFirstLine(tmMapStr(prop, "description"))
}
entry.Params[name] = toolMappingParam{
Flag: flag,
Label: label,
Type: tmMapStr(prop, "type"),
}
}
if len(entry.Params) == 0 {
entry.Params = nil
}
mapping.Tools[tool.RPCName] = entry
}
}
mapping.Count = len(mapping.Tools)
return mapping
}
// tmBuildCLICommand 拼出 CLI 命令路径,如 chat + message + list -> "chat message list"。
func tmBuildCLICommand(command string, tool ir.ToolDescriptor) string {
parts := make([]string, 0, 3)
if command != "" {
parts = append(parts, command)
}
if g := strings.TrimSpace(tool.Group); g != "" {
parts = append(parts, g)
}
name := strings.TrimSpace(tool.CLIName)
if name == "" {
name = tool.RPCName
}
parts = append(parts, name)
return strings.Join(parts, " ")
}
func tmSchemaProperties(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
props, _ := schema["properties"].(map[string]any)
return props
}
func tmMapStr(m map[string]any, key string) string {
if m == nil {
return ""
}
s, _ := m[key].(string)
return strings.TrimSpace(s)
}
// tmFirstLine 取第一句中文/换行前的片段,作为长描述的短标签兜底。
func tmFirstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexAny(s, "\n。"); i >= 0 {
return strings.TrimSpace(s[:i])
}
return s
}
func tmFirstNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return strings.TrimSpace(a)
}
return strings.TrimSpace(b)
}
// tmKebab 把 camelCase 参数名转 kebab-case 作为默认 flag。
func tmKebab(s string) string {
var b strings.Builder
for i, r := range s {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('-')
}
b.WriteRune(r - 'A' + 'a')
continue
}
b.WriteRune(r)
}
return b.String()
}
+2
View File
@@ -29,6 +29,7 @@ type GlobalFlags struct {
JQ string
Mock bool
Output string
Profile string
Timeout int
Token string
Verbose bool
@@ -46,6 +47,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
_ = cmd.PersistentFlags().MarkHidden("output")
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB")
cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)")
cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token")
_ = cmd.PersistentFlags().MarkHidden("token")
+79 -2
View File
@@ -55,9 +55,11 @@ func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
// `minutes list all` is intentionally provided as a hardcoded helper
// (see internal/helpers/minutes_commands.go) to align with the wukong
// baseline, so it is expected to resolve and is no longer asserted here.
for _, path := range []string{
"chat message list-topic-replies",
"minutes list all",
} {
if cmd := lookupCommand(root, path); cmd != nil {
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
@@ -161,11 +163,16 @@ func TestRootHelpUsesMCPOnlySummary(t *testing.T) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理", "Flags:"} {
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理"} {
if strings.Contains(got, unwanted) {
t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got)
}
}
for _, want := range []string{"Global Flags:", "--profile"} {
if !strings.Contains(got, want) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
}
func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
@@ -215,6 +222,60 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
}
}
func TestProfileHelpDocumentsMultiProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "profile", "switch", "--help")
for _, want := range []string{
"切换默认组织 profile",
"需要只影响单次业务命令时,请使用全局 --profile",
"dws profile switch --corpId <corpId>",
"dws --profile <corpId> contact user get-self",
"--corpId string",
"--name string",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile switch help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "profile", "list", "--help")
for _, want := range []string{
"列出本机已登录的所有组织 profile",
"dws profile list --format json",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile list help missing %q:\n%s", want, got)
}
}
}
func TestAuthHelpDocumentsProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "auth", "login", "--help")
if !strings.Contains(got, "dws auth login --profile <corpId>") {
t.Fatalf("auth login help missing --profile example:\n%s", got)
}
got = executeHelpForTest(t, "auth", "status", "--help")
for _, want := range []string{
"查看当前或指定组织 profile 的认证状态",
"只读取并刷新被选中的 token slot",
"dws auth status --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth status help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "auth", "logout", "--help")
for _, want := range []string{
"默认退出所有已登录组织 profile",
"dws auth logout --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth logout help missing %q:\n%s", want, got)
}
}
}
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
root := NewRootCommand()
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
@@ -222,6 +283,22 @@ func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
}
}
func executeHelpForTest(t *testing.T, args ...string) string {
t.Helper()
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
return out.String()
}
func discoveryServerEntry(command, description string, groups, toolOverrides map[string]any) map[string]any {
cliMeta := map[string]any{
"id": command,
+45 -1
View File
@@ -351,15 +351,59 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
existingTools := loadCachedToolNames(store, servers)
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID, existingTools)
RecordTiming(ctx, "build_commands", time.Since(buildStart))
return cmds
}
// loadCachedToolNames reads the live tools/list snapshot from disk cache for
// each server and returns a map from CLI server ID (slug) → set of tool names
// the server actually exposes. This is the existence oracle BuildDynamicCommands
// uses to hide phantom override leaves (commands whose backing MCP tool is not
// deployed) from `--help`.
//
// Source note: this reads the `tools/` partition (populated by `dws cache
// refresh` / discovery, keyed by server.Key), NOT the `detail/` partition used
// by loadCachedDetailsFast — the latter is frequently empty even after a
// refresh, so it is unusable as an existence signal.
//
// Keyed by cli.ID so serverOverride routing (e.g. contact → hrmregister)
// resolves against the target server's tool set. A server with no cached tools
// is simply absent from the map; the build guard treats "absent / empty" as
// "unknown" and keeps the command, so a cold cache never blanks the tree.
func loadCachedToolNames(store *cache.Store, servers []market.ServerDescriptor) map[string]map[string]struct{} {
result := make(map[string]map[string]struct{})
if store == nil {
return result
}
partition := editionPartition()
for _, server := range servers {
slug := strings.TrimSpace(server.CLI.ID)
if slug == "" || strings.TrimSpace(server.Key) == "" {
continue
}
snap, _, err := store.LoadTools(partition, server.Key)
if err != nil || len(snap.Tools) == 0 {
continue
}
names := make(map[string]struct{}, len(snap.Tools))
for _, t := range snap.Tools {
if n := strings.TrimSpace(t.Name); n != "" {
names[n] = struct{}{}
}
}
if len(names) > 0 {
result[slug] = names
}
}
return result
}
// loadCachedDetailsFast reads Detail API tool metadata from disk cache only —
// no network calls. Returns whatever is available (fresh or stale).
func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor) map[string][]market.DetailTool {
+2 -2
View File
@@ -359,7 +359,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis/bamboo":
case r.URL.Path == "/cli/discovery/apis/cedar":
payload := map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
@@ -433,7 +433,7 @@ func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryT
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis/bamboo":
case r.URL.Path == "/cli/discovery/apis/cedar":
_ = json.NewEncoder(w).Encode(map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
+148
View File
@@ -0,0 +1,148 @@
package app
import (
"context"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_a"),
authLogoutTestToken("corp_b"),
)
authpkg.SetRuntimeProfile("corp_a, corp_b")
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
result, err := runner.Run(context.Background(), executor.Invocation{
Kind: "helper_invocation",
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
Params: map[string]any{"limit": 10},
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if got := authpkg.RuntimeProfile(); got != "corp_a, corp_b" {
t.Fatalf("runtime profile after Run = %q, want restored raw selector", got)
}
content := result.Response["content"].(map[string]any)
if content["multiProfile"] != true {
t.Fatalf("multiProfile = %#v, want true", content["multiProfile"])
}
if content["success"] != true {
t.Fatalf("success = %#v, want true", content["success"])
}
profiles := content["profiles"].([]any)
if len(profiles) != 2 {
t.Fatalf("profiles len = %d, want 2", len(profiles))
}
for i, wantCorpID := range []string{"corp_a", "corp_b"} {
entry := profiles[i].(map[string]any)
if entry["corpId"] != wantCorpID {
t.Fatalf("profiles[%d].corpId = %#v, want %q", i, entry["corpId"], wantCorpID)
}
if entry["ok"] != true {
t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"])
}
resultPayload := entry["result"].(map[string]any)
if resultPayload["runtimeProfile"] != wantCorpID {
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID)
}
}
}
func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
authpkg.SetRuntimeProfile("corp_a, corp_a org,corp_b")
selections, multi, err := resolveMultiProfileSelections(configDir, authpkg.RuntimeProfile())
if err != nil {
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
}
if !multi {
t.Fatal("multi = false, want true")
}
if len(selections) != 2 {
t.Fatalf("selections len = %d, want 2", len(selections))
}
if selections[0].Profile.CorpID != "corp_a" || selections[1].Profile.CorpID != "corp_b" {
t.Fatalf("resolved corp IDs = %q, %q; want corp_a, corp_b", selections[0].Profile.CorpID, selections[1].Profile.CorpID)
}
}
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
authpkg.SetRuntimeProfile("corp_a")
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
result, err := runner.Run(context.Background(), executor.Invocation{
Kind: "helper_invocation",
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok {
t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response)
}
if got := authpkg.RuntimeProfile(); got != "corp_a" {
t.Fatalf("runtime profile after Run = %q, want corp_a", got)
}
}
func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other"))
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == "corp_comma" {
cfg.Profiles[i].Name = "alpha,beta"
}
}
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
selections, multi, err := resolveMultiProfileSelections(configDir, "alpha,beta")
if err != nil {
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
}
if multi {
t.Fatalf("multi = true, want false; selections=%#v", selections)
}
}
func TestCommaSeparatedProfileRejectsEmptySelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
_, _, err := resolveMultiProfileSelections(configDir, "corp_a,,corp_b")
if err == nil {
t.Fatal("resolveMultiProfileSelections() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "empty profile selector") {
t.Fatalf("error = %q, want empty profile selector", err.Error())
}
}
type multiProfileFallbackRunner struct{}
func (multiProfileFallbackRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": map[string]any{
"runtimeProfile": authpkg.RuntimeProfile(),
"tool": invocation.Tool,
},
},
}, nil
}
+83
View File
@@ -0,0 +1,83 @@
package app
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// writeMultiSkillSrc creates a fake multi skill source tree with the given
// subdir names, each containing a minimal SKILL.md.
func writeMultiSkillSrc(t *testing.T, names ...string) string {
t.Helper()
src := t.TempDir()
for _, n := range names {
dir := filepath.Join(src, n)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+n+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return src
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
// dws-shared must ship even when --skill narrows the set to a single product.
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
if !contains(all, "dws-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
}
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
if err != nil {
t.Fatal(err)
}
if contains(filtered, "dws-shared") {
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
}
final := ensureMandatorySharedSkill(filtered, all)
if !contains(final, "dws-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
}
// Actually install with the filtered+mandatory set and assert dws-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-aitable not installed: %v", err)
}
}
// When the source has no dws-shared (older layout), nothing is forced.
func TestP1SharedNoopWhenAbsent(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-aitable")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
if contains(final, "dws-shared") {
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
}
}
+4 -1
View File
@@ -148,8 +148,11 @@ func registerStdioServerFromOverlay(
}
}
// nil existingTools: this overlay is built from the plugin's own live tool
// list (detailsByID is derived from it), so there are no phantom leaves to
// guard against here.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered from overlay",
"plugin", p.Manifest.Name, "server", sc.Key,
+82
View File
@@ -0,0 +1,82 @@
package app
import (
"os"
"reflect"
"testing"
)
func TestNormalizeProfileFlagArgsAcceptsUnquotedCommaContinuation(t *testing.T) {
cases := []struct {
name string
args []string
want []string
}{
{
name: "root profile before command",
args: []string{"--mock", "--profile", "corpA,", "corpB", "contact", "user", "get-self"},
want: []string{"--mock", "--profile", "corpA,corpB", "contact", "user", "get-self"},
},
{
name: "profile after leaf command",
args: []string{"contact", "user", "get-self", "--profile", "corpA,", "corpB", "--format", "json"},
want: []string{"contact", "user", "get-self", "--profile", "corpA,corpB", "--format", "json"},
},
{
name: "equals form",
args: []string{"--profile=corpA,", "corpB", "contact", "user", "get-self"},
want: []string{"--profile=corpA,corpB", "contact", "user", "get-self"},
},
{
name: "three profiles",
args: []string{"--profile", "corpA,", "corpB,", "corpC", "contact", "user", "get-self"},
want: []string{"--profile", "corpA,corpB,corpC", "contact", "user", "get-self"},
},
{
name: "already quoted by shell remains unchanged",
args: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
want: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
},
{
name: "single profile remains unchanged",
args: []string{"--profile", "corpA", "contact", "user", "get-self"},
want: []string{"--profile", "corpA", "contact", "user", "get-self"},
},
{
name: "trailing comma before next flag remains validation input",
args: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
want: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, _ := normalizeProfileFlagArgs(tc.args)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("normalizeProfileFlagArgs() = %#v, want %#v", got, tc.want)
}
})
}
}
func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
got := preparseProfileFlag([]string{"--profile", "corpA,", "corpB", "contact", "user", "get-self"})
if got != "corpA,corpB" {
t.Fatalf("preparseProfileFlag() = %q, want corpA,corpB", got)
}
}
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
os.Args = []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}
restore := normalizeProcessProfileArgs()
if want := []string{"dws", "--profile", "corpA,corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
t.Fatalf("os.Args after normalize = %#v, want %#v", os.Args, want)
}
restore()
if want := []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
t.Fatalf("os.Args after restore = %#v, want %#v", os.Args, want)
}
}
+747
View File
@@ -0,0 +1,747 @@
// 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"
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
)
func newProfileCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "组织 profile 管理",
Long: `管理本机已登录的钉钉组织 profile。
每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织,
profile switch/use 才会持久修改默认组织上下文。`,
Example: ` dws profile list
dws profile switch
dws profile switch <corpId>
dws profile switch -
dws --profile <corpId> contact user get-self`,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newProfileListCommand(), newProfileSwitchCommand(), newProfileUseCommand())
return cmd
}
func newProfileListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "列出已登录组织 profile",
Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。",
Example: ` dws profile list
dws profile list --format json`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return writeProfileListJSON(cmd.OutOrStdout(), cfg)
}
writeProfileListTable(cmd.OutOrStdout(), cfg)
return nil
},
}
}
func newProfileUseCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "use [name|corpId|-]",
Short: "切换当前组织 profile(兼容 profile switch)",
Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。",
Example: ` dws profile use <corpId>
dws profile use --name "钉钉"
dws profile use -`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runProfileSwitchCommand(cmd, args)
},
}
addProfileSwitchSelectorFlags(cmd)
return cmd
}
func newProfileSwitchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "switch [name|corpId|-]",
Short: "切换当前组织 profile",
Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。
需要只影响单次业务命令时,请使用全局 --profile。`,
Example: ` dws profile switch
dws profile switch <corpId>
dws profile switch --corpId <corpId>
dws profile switch --name "钉钉"
dws profile switch -
dws --profile <corpId> contact user get-self`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runProfileSwitchCommand(cmd, args)
},
}
addProfileSwitchSelectorFlags(cmd)
return cmd
}
func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
cmd.Flags().String("corpId", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corp-id", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corpid", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corp", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("name", "", "按组织名或 profile 名直接切换组织 profile")
_ = cmd.Flags().MarkHidden("corp-id")
_ = cmd.Flags().MarkHidden("corpid")
_ = cmd.Flags().MarkHidden("corp")
}
var (
profileSwitchSelector = selectProfileSwitchProfile
profileSwitchInteractiveTerminal = isInteractiveTerminal
)
const (
profileSwitchVisibleOptions = 5
profileSwitchCellPadding = 1
profileSwitchOrgWidth = 34
profileSwitchStatusWidth = 10
)
var profileSwitchRenderer = newProfileSwitchRenderer()
func newProfileSwitchRenderer() *lipgloss.Renderer {
renderer := lipgloss.NewRenderer(io.Discard)
renderer.SetColorProfile(termenv.TrueColor)
renderer.SetHasDarkBackground(true)
return renderer
}
func runProfileSwitchCommand(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
selector, err := profileSwitchSelectorFromCommand(cmd, args)
if err != nil {
return err
}
usedTUI := false
if selector == "" {
selector, err = profileSwitchSelector(cmd, configDir)
if err != nil {
return err
}
usedTUI = true
}
return switchProfileAndWrite(cmd, configDir, selector, usedTUI)
}
func profileSwitchSelectorFromCommand(cmd *cobra.Command, args []string) (string, error) {
selectors := make([]string, 0, 2)
if len(args) > 0 {
selectors = append(selectors, strings.TrimSpace(args[0]))
}
for _, name := range []string{"corpId", "corp-id", "corpid", "corp", "name"} {
value, changed := changedStringFlag(cmd, name)
if !changed {
continue
}
if value == "" {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", name))
}
selectors = append(selectors, value)
}
if len(selectors) == 0 {
return "", nil
}
selector := selectors[0]
for _, candidate := range selectors[1:] {
if candidate != selector {
return "", apperrors.NewValidation("只能指定一个组织选择器,请使用位置参数或 --corpId/--name 其中一种")
}
}
return selector, nil
}
func changedStringFlag(cmd *cobra.Command, name string) (string, bool) {
if cmd == nil || cmd.Flags() == nil {
return "", false
}
flag := cmd.Flags().Lookup(name)
if flag == nil || !flag.Changed {
return "", false
}
return strings.TrimSpace(flag.Value.String()), true
}
func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedTUI bool) error {
var (
profile *authpkg.Profile
err error
)
if strings.TrimSpace(selector) == "-" {
profile, err = authpkg.UsePreviousProfile(configDir)
} else {
profile, err = authpkg.SetCurrentProfile(configDir, selector)
}
if err != nil {
return apperrors.NewValidation(err.Error())
}
ResetRuntimeTokenCache()
clearCompatCache()
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
cfg, loadErr := authpkg.LoadProfiles(configDir)
if loadErr != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
}
return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg)
}
fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile))
return nil
}
func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) {
if !profileSwitchInteractiveTerminal() {
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
}
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
if cfg == nil || len(cfg.Profiles) == 0 {
return "", apperrors.NewValidation("未找到已登录 profile,请先运行 dws auth login")
}
choice := strings.TrimSpace(cfg.CurrentProfile)
if choice == "" {
choice = strings.TrimSpace(cfg.PrimaryProfile)
}
if choice == "" {
choice = cfg.Profiles[0].CorpID
}
return runProfileSwitchTUI(cmd, cfg, choice)
}
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
model := newProfileSwitchTUIModel(cfg, selectedCorpID)
program := tea.NewProgram(
model,
tea.WithAltScreen(),
tea.WithInput(cmd.InOrStdin()),
tea.WithOutput(cmd.ErrOrStderr()),
tea.WithContext(cmd.Context()),
)
finalModel, err := program.Run()
if err != nil {
if errors.Is(err, tea.ErrInterrupted) {
return "", apperrors.NewValidation("组织选择中止: user aborted")
}
return "", apperrors.NewInternal(fmt.Sprintf("failed to run profile selector: %v", err))
}
final, ok := finalModel.(profileSwitchTUIModel)
if !ok || final.aborted || !final.submitted {
return "", apperrors.NewValidation("组织选择中止: user aborted")
}
return final.selectedCorpID(), nil
}
type profileSwitchTUIModel struct {
cfg *authpkg.ProfilesConfig
profiles []authpkg.Profile
selected int
offset int
submitted bool
aborted bool
}
func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string) profileSwitchTUIModel {
model := profileSwitchTUIModel{cfg: cfg}
if cfg != nil {
model.profiles = profileSwitchSortedProfiles(cfg.Profiles)
}
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID)
if model.selected < 0 {
model.selected = 0
}
model.ensureSelectedVisible()
return model
}
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
sorted := append([]authpkg.Profile(nil), profiles...)
sort.SliceStable(sorted, func(i, j int) bool {
left, leftOK := profileSwitchSortTime(sorted[i])
right, rightOK := profileSwitchSortTime(sorted[j])
if leftOK && rightOK && !left.Equal(right) {
return left.After(right)
}
if leftOK != rightOK {
return leftOK
}
return false
})
return sorted
}
func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) {
for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} {
if t, ok := parseProfileSwitchTime(raw); ok {
return t, true
}
}
return time.Time{}, false
}
func parseProfileSwitchTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false
}
return t, true
}
func (m profileSwitchTUIModel) Init() tea.Cmd {
return nil
}
func (m profileSwitchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc", "q":
m.aborted = true
return m, tea.Quit
case "up", "k":
if m.selected > 0 {
m.selected--
m.ensureSelectedVisible()
}
case "down", "j":
if m.selected < len(m.profiles)-1 {
m.selected++
m.ensureSelectedVisible()
}
case "enter":
m.submitted = true
return m, tea.Quit
}
}
return m, nil
}
func (m profileSwitchTUIModel) View() string {
var b strings.Builder
title := profileSwitchTitleStyle().Render("选择要切换的组织")
hint := profileSwitchMutedStyle().Render("全部已登录 profile,↑↓ 选择,Enter 确认")
b.WriteString(title)
b.WriteString("\n")
b.WriteString(hint)
b.WriteString("\n\n")
b.WriteString(m.tableView())
b.WriteString("\n")
b.WriteString(profileSwitchMutedStyle().Render("↑/k up • ↓/j down • enter submit • esc cancel"))
return b.String()
}
func (m profileSwitchTUIModel) tableView() string {
rows := []string{
profileSwitchBorder("┌", "┬", "┐"),
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
profileSwitchBorder("├", "┼", "┤"),
}
for i := 0; i < profileSwitchVisibleOptions; i++ {
idx := m.offset + i
if idx >= 0 && idx < len(m.profiles) {
rows = append(rows, m.profileRow(idx))
continue
}
rows = append(rows, profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()))
}
rows = append(rows, profileSwitchBorder("└", "┴", "┘"))
return strings.Join(rows, "\n")
}
func (m profileSwitchTUIModel) profileRow(idx int) string {
profile := m.profiles[idx]
org, status := profileSwitchProfileCells(profile, m.cfg)
style := profileSwitchNormalRowStyle()
if idx == m.selected {
org = "› " + org
style = profileSwitchSelectedRowStyle()
} else {
org = " " + org
}
return profileSwitchStyledTableLine(org, status, style)
}
func (m *profileSwitchTUIModel) ensureSelectedVisible() {
if len(m.profiles) == 0 {
m.selected = 0
m.offset = 0
return
}
if m.selected < 0 {
m.selected = 0
}
if m.selected >= len(m.profiles) {
m.selected = len(m.profiles) - 1
}
if m.selected < m.offset {
m.offset = m.selected
}
if m.selected >= m.offset+profileSwitchVisibleOptions {
m.offset = m.selected - profileSwitchVisibleOptions + 1
}
maxOffset := len(m.profiles) - profileSwitchVisibleOptions
if maxOffset < 0 {
maxOffset = 0
}
if m.offset > maxOffset {
m.offset = maxOffset
}
if m.offset < 0 {
m.offset = 0
}
}
func (m profileSwitchTUIModel) selectedCorpID() string {
if m.selected < 0 || m.selected >= len(m.profiles) {
return ""
}
return strings.TrimSpace(m.profiles[m.selected].CorpID)
}
func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int {
corpID = strings.TrimSpace(corpID)
for i, p := range profiles {
if strings.TrimSpace(p.CorpID) == corpID {
return i
}
}
return -1
}
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
org, status := profileSwitchProfileCells(p, cfg)
if status == "" {
return org
}
return strings.Join([]string{org, status}, " | ")
}
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
return profileOrgName(p), profileSwitchProfileStatus(p, cfg)
}
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
if cfg != nil && p.CorpID == cfg.CurrentProfile {
return "当前组织"
}
return ""
}
func profileSwitchBorder(left, sep, right string) string {
segments := []string{
strings.Repeat("─", profileSwitchCellWidth(profileSwitchOrgWidth)),
strings.Repeat("─", profileSwitchCellWidth(profileSwitchStatusWidth)),
}
return profileSwitchBorderStyle().Render(left + strings.Join(segments, sep) + right)
}
func profileSwitchTableLine(org, status string) string {
cells := []string{
profileSwitchTableCell(org, profileSwitchOrgWidth),
profileSwitchTableCell(status, profileSwitchStatusWidth),
}
return "│" + strings.Join(cells, "│") + "│"
}
func profileSwitchStyledTableLine(org, status string, style lipgloss.Style) string {
cells := []string{
style.Render(profileSwitchTableCell(org, profileSwitchOrgWidth)),
style.Render(profileSwitchTableCell(status, profileSwitchStatusWidth)),
}
return profileSwitchTableSeparator() + strings.Join(cells, profileSwitchTableSeparator()) + profileSwitchTableSeparator()
}
func profileSwitchTableSeparator() string {
return profileSwitchBorderStyle().Render("│")
}
func profileSwitchTableCell(value string, width int) string {
clipped := clipProfileDisplayCell(strings.TrimSpace(value), width)
padding := strings.Repeat(" ", profileSwitchCellPadding)
return padding + padProfileDisplayCell(clipped, width) + padding
}
func padProfileDisplayCell(value string, width int) string {
padding := width - lipgloss.Width(value)
if padding < 0 {
padding = 0
}
return value + strings.Repeat(" ", padding)
}
func profileSwitchCellWidth(contentWidth int) int {
return contentWidth + profileSwitchCellPadding*2
}
func profileSwitchSelectedRowStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
}
func profileSwitchNormalRowStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#FFFFFF"))
}
func profileSwitchHeaderStyle() lipgloss.Style {
return profileSwitchMutedStyle().Bold(true)
}
func profileSwitchBorderStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#2F3B52"))
}
func profileSwitchTitleStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
}
func profileSwitchMutedStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#8A96A8"))
}
type profileListResponse struct {
Success bool `json:"success"`
PrimaryProfile string `json:"primaryProfile,omitempty"`
CurrentProfile string `json:"currentProfile,omitempty"`
PreviousProfile string `json:"previousProfile,omitempty"`
Profiles []profileView `json:"profiles"`
}
type profileUseResponse struct {
Success bool `json:"success"`
Profile profileView `json:"profile"`
}
type profileView struct {
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
ClientID string `json:"clientId,omitempty"`
Status string `json:"status,omitempty"`
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
RefreshExpAt string `json:"refreshExpAt,omitempty"`
LastLoginAt string `json:"lastLoginAt,omitempty"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
IsPrimary bool `json:"isPrimary"`
IsCurrent bool `json:"isCurrent"`
}
func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error {
resp := profileListResponse{
Success: true,
PrimaryProfile: cfg.PrimaryProfile,
CurrentProfile: cfg.CurrentProfile,
PreviousProfile: cfg.PreviousProfile,
Profiles: profileViews(cfg),
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.ProfilesConfig) error {
resp := profileUseResponse{Success: true}
if profile != nil {
primaryProfile := ""
currentProfile := ""
if cfg != nil {
primaryProfile = cfg.PrimaryProfile
currentProfile = cfg.CurrentProfile
}
resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile)
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) {
if cfg == nil || len(cfg.Profiles) == 0 {
fmt.Fprintln(w, "未找到已登录 profile")
return
}
fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER")
for _, p := range cfg.Profiles {
current := ""
if p.CorpID == cfg.CurrentProfile {
current = "*"
}
primary := ""
if p.CorpID == cfg.PrimaryProfile {
primary = "*"
}
user := p.UserName
if user == "" {
user = p.UserID
}
status := p.Status
if status == "" {
status = authpkg.ProfileStatusActive
}
fmt.Fprintf(
w,
"%-3s %-3s %-28s %-34s %-10s %s\n",
current,
primary,
clipProfileCell(profileOrgName(p), 28),
clipProfileCell(p.CorpID, 34),
status,
user,
)
}
}
func profileUseMessage(profile *authpkg.Profile) string {
if profile == nil {
return "[OK] 当前 profile 已切换"
}
corpID := strings.TrimSpace(profile.CorpID)
orgName := strings.TrimSpace(profile.CorpName)
if orgName == "" {
orgName = profileOrgName(*profile)
}
return fmt.Sprintf("[OK] 当前组织: %s (%s)", orgName, corpID)
}
func profileOrgName(p authpkg.Profile) string {
if v := strings.TrimSpace(p.CorpName); v != "" {
return v
}
if v := strings.TrimSpace(p.Name); v != "" {
return v
}
return strings.TrimSpace(p.CorpID)
}
func profileViews(cfg *authpkg.ProfilesConfig) []profileView {
if cfg == nil {
return nil
}
views := make([]profileView, 0, len(cfg.Profiles))
for _, p := range cfg.Profiles {
views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile))
}
return views
}
func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView {
return profileView{
CorpID: p.CorpID,
CorpName: profileOrgName(p),
UserID: p.UserID,
UserName: p.UserName,
ClientID: p.ClientID,
Status: p.Status,
AuthorizedDomains: p.AuthorizedDomains,
ExpiresAt: p.ExpiresAt,
RefreshExpAt: p.RefreshExpAt,
LastLoginAt: p.LastLoginAt,
LastUsedAt: p.LastUsedAt,
IsPrimary: p.CorpID == primaryProfile,
IsCurrent: p.CorpID == currentProfile,
}
}
func clipProfileCell(value string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
if limit <= 3 {
return string(runes[:limit])
}
return string(runes[:limit-3]) + "..."
}
func clipProfileDisplayCell(value string, limit int) string {
if limit <= 0 {
return ""
}
if lipgloss.Width(value) <= limit {
return value
}
if limit <= 3 {
var b strings.Builder
for _, r := range value {
rw := lipgloss.Width(string(r))
if lipgloss.Width(b.String())+rw > limit {
break
}
b.WriteRune(r)
}
return b.String()
}
target := limit - 3
var b strings.Builder
width := 0
for _, r := range value {
rw := lipgloss.Width(string(r))
if width+rw > target {
break
}
b.WriteRune(r)
width += rw
}
return b.String() + "..."
}
+582
View File
@@ -0,0 +1,582 @@
// 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 (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) {
profile := &authpkg.Profile{
Name: "B Org",
CorpID: "corp_b",
CorpName: "B Org",
Status: authpkg.ProfileStatusActive,
}
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_a",
CurrentProfile: "corp_b",
}
var buf bytes.Buffer
if err := writeProfileUseJSON(&buf, profile, cfg); err != nil {
t.Fatalf("writeProfileUseJSON() error = %v", err)
}
var resp profileUseResponse
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if bytes.Contains(buf.Bytes(), []byte(`"name"`)) {
t.Fatalf("profile use JSON should not contain name when corpName is present:\n%s", buf.String())
}
if resp.Profile.CorpName != "B Org" {
t.Fatalf("corpName = %q, want B Org", resp.Profile.CorpName)
}
if !resp.Profile.IsCurrent {
t.Fatalf("isCurrent = false, want true")
}
if resp.Profile.IsPrimary {
t.Fatalf("isPrimary = true, want false")
}
}
func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
}
var resp profileListResponse
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
if !resp.Success {
t.Fatal("success = false, want true")
}
if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
}
if len(resp.Profiles) != 2 {
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
}
if bytes.Contains(out.Bytes(), []byte(`"name"`)) {
t.Fatalf("profile list JSON should not contain name when corpName is present:\n%s", out.String())
}
for _, p := range resp.Profiles {
if p.CorpName == "" {
t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p)
}
}
}
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile use output should include organization name:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_primary" {
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
}
cmd = NewRootCommand()
out.Reset()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
t.Fatalf("profile use - output should include organization name:\n%s", out.String())
}
cfg, err = authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err = authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_secondary" {
t.Fatalf("legacy token corp = %q, want corp_secondary", legacyToken.CorpID)
}
}
func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch corp_primary error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile switch output should include organization name:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_primary" {
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
}
}
func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "--corpId", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
cmd = NewRootCommand()
out.Reset()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "--corp", "corp_secondary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
}
cfg, err = authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile)
}
}
func TestProfileSwitchRootCommandRejectsConflictingSelectors(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "switch", "corp_primary", "--corpId", "corp_secondary"})
err := cmd.Execute()
if err == nil {
t.Fatalf("profile switch with conflicting selectors succeeded\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), "只能指定一个组织选择器") {
t.Fatalf("error = %v, want conflicting selector validation", err)
}
}
func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
oldSelector := profileSwitchSelector
t.Cleanup(func() {
profileSwitchSelector = oldSelector
})
called := false
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
called = true
if gotConfigDir != configDir {
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
}
return "corp_primary", nil
}
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "switch"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch error = %v\noutput:\n%s", err, out.String())
}
if !called {
t.Fatal("profile switch without args did not invoke TUI selector")
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile switch TUI path should use human output by default:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
}
func TestProfileSwitchOptionLabelUsesOnlyOrganizationAndCurrentState(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_primary",
CurrentProfile: "corp_secondary",
Profiles: []authpkg.Profile{
{
CorpID: "corp_primary",
CorpName: "第一组织",
UserName: "alice",
Status: authpkg.ProfileStatusActive,
},
{
CorpID: "corp_secondary",
CorpName: "第二组织",
UserName: "bob",
Status: authpkg.ProfileStatusActive,
},
},
}
primary := profileSwitchOptionLabel(cfg.Profiles[0], cfg)
current := profileSwitchOptionLabel(cfg.Profiles[1], cfg)
for _, label := range []string{primary, current} {
if strings.Contains(label, "\n") {
t.Fatalf("profile switch label contains newline: %q", label)
}
}
if !strings.Contains(primary, "第一组织") {
t.Fatalf("primary option missing organization name: %q", primary)
}
if !strings.Contains(current, "当前组织") {
t.Fatalf("current option missing current marker: %q", current)
}
for _, unwanted := range []string{"alice", "bob", "已登录", "主组织", "corp_primary", "corp_secondary"} {
if strings.Contains(primary, unwanted) || strings.Contains(current, unwanted) {
t.Fatalf("profile switch option should not contain %q: %q / %q", unwanted, primary, current)
}
}
}
func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
cfg := profileSwitchTestConfig(2)
model := newProfileSwitchTUIModel(cfg, "corp_00")
view := model.tableView()
if lines := strings.Split(view, "\n"); len(lines) != profileSwitchVisibleOptions+4 {
t.Fatalf("table line count = %d, want %d:\n%s", len(lines), profileSwitchVisibleOptions+4, view)
}
for _, want := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "组织名", "本地状态"} {
if !strings.Contains(view, want) {
t.Fatalf("profile switch table missing %q in:\n%s", want, view)
}
}
for _, unwanted := range []string{"CORP_ID", "ORGANIZATION", "STATUS"} {
if strings.Contains(view, unwanted) {
t.Fatalf("profile switch table should not contain %q:\n%s", unwanted, view)
}
}
if got := strings.Count(view, "│"); got != (profileSwitchVisibleOptions+1)*3 {
t.Fatalf("table vertical separators = %d, want %d\n%s", got, (profileSwitchVisibleOptions+1)*3, view)
}
for _, profile := range cfg.Profiles {
if got := strings.Count(view, profile.CorpID); got != 0 {
t.Fatalf("profile corpId %s appears %d times, want hidden:\n%s", profile.CorpID, got, view)
}
}
}
func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "old",
CurrentProfile: "old",
Profiles: []authpkg.Profile{
{CorpID: "old", CorpName: "旧组织", LastLoginAt: "2026-06-26T10:00:00+08:00"},
{CorpID: "new", CorpName: "新组织", LastLoginAt: "2026-06-26T12:00:00+08:00"},
{CorpID: "fallback", CorpName: "兜底组织", UpdatedAt: "2026-06-26T11:00:00+08:00"},
},
}
model := newProfileSwitchTUIModel(cfg, "old")
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
wantOrder := []string{"new", "fallback", "old"}
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
}
if got := model.selectedCorpID(); got != "old" {
t.Fatalf("selectedCorpID = %q, want old", got)
}
}
func TestProfileSwitchTUIArrowKeysMoveSelectionWithoutDuplicatingRows(t *testing.T) {
cfg := profileSwitchTestConfig(7)
model := newProfileSwitchTUIModel(cfg, "corp_00")
for step := 0; step < 6; step++ {
view := model.tableView()
if got := strings.Count(view, "›"); got != 1 {
t.Fatalf("step %d selected cursor count = %d, want 1:\n%s", step, got, view)
}
for _, profile := range cfg.Profiles {
name := profileOrgName(profile)
if got := strings.Count(view, name); got > 1 {
t.Fatalf("step %d profile %s appears %d times, want at most once:\n%s", step, name, got, view)
}
}
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(profileSwitchTUIModel)
}
if model.selected != 6 || model.offset != 2 {
t.Fatalf("selection after down keys = selected %d offset %d, want 6/2", model.selected, model.offset)
}
}
func TestProfileSwitchTableRowsKeepFixedDisplayWidth(t *testing.T) {
rows := []string{
profileSwitchTableLine("组织名", "本地状态"),
profileSwitchTableLine("› 钉钉(中国)信息技术有限公司", "当前组织"),
profileSwitchTableLine(" ACME", ""),
profileSwitchTableLine("", ""),
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
profileSwitchStyledTableLine("› 钉钉(中国)信息技术有限公司", "当前组织", profileSwitchSelectedRowStyle()),
profileSwitchStyledTableLine(" ACME", "", profileSwitchNormalRowStyle()),
profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()),
}
wantWidth := lipgloss.Width(rows[0])
for i, row := range rows {
if got := lipgloss.Width(row); got != wantWidth {
t.Fatalf("row[%d] width = %d, want %d: %q", i, got, wantWidth, row)
}
if got := strings.Count(row, "│"); got != 3 {
t.Fatalf("row[%d] separator count = %d, want 3: %q", i, got, row)
}
}
}
func TestProfileSwitchOptionLabelHidesCorpID(t *testing.T) {
const corpID = "ding8196cd9a2b2405da24f2f5cc6abecb85"
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: corpID,
CurrentProfile: corpID,
}
label := profileSwitchOptionLabel(authpkg.Profile{
CorpID: corpID,
CorpName: "钉钉",
}, cfg)
for _, want := range []string{"钉钉", "当前组织"} {
if !strings.Contains(label, want) {
t.Fatalf("profile switch label missing %q in %q", want, label)
}
}
for _, unwanted := range []string{"ding8196", "cb85", "主组织"} {
if strings.Contains(label, unwanted) {
t.Fatalf("profile switch label should not contain %q in %q", unwanted, label)
}
}
}
func profileSwitchTestConfig(count int) *authpkg.ProfilesConfig {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_00",
CurrentProfile: "corp_00",
}
for i := 0; i < count; i++ {
corpID := fmt.Sprintf("corp_%02d", i)
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
CorpID: corpID,
CorpName: fmt.Sprintf("组织%02d", i),
Status: authpkg.ProfileStatusActive,
})
}
return cfg
}
func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "switch"})
err := cmd.Execute()
if err == nil {
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
t.Fatalf("error = %v, want auth switch unknown command", err)
}
}
func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
oldSelector := profileSwitchSelector
t.Cleanup(func() {
profileSwitchSelector = oldSelector
})
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
if gotConfigDir != configDir {
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
}
return "corp_primary", nil
}
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "use"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile use TUI path should use human output by default:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
}
func TestProfileSwitchSelectorRequiresInteractiveTerminal(t *testing.T) {
oldInteractive := profileSwitchInteractiveTerminal
t.Cleanup(func() {
profileSwitchInteractiveTerminal = oldInteractive
})
profileSwitchInteractiveTerminal = func() bool { return false }
_, err := selectProfileSwitchProfile(nil, t.TempDir())
if err == nil {
t.Fatal("selectProfileSwitchProfile() succeeded, want validation error")
}
if !bytes.Contains([]byte(err.Error()), []byte("profile selector required")) {
t.Fatalf("error = %v, want profile selector hint", err)
}
}
func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_a",
CurrentProfile: "corp_b",
Profiles: []authpkg.Profile{
{
Name: "DingTalk China",
CorpID: "corp_a",
CorpName: "钉钉(中国)信息技术有限公司",
UserName: "alice",
Status: authpkg.ProfileStatusActive,
},
{
Name: "B Org",
CorpID: "corp_b",
CorpName: "B 组织",
UserID: "bob-id",
},
},
}
var buf bytes.Buffer
writeProfileListTable(&buf, cfg)
out := buf.String()
for _, want := range []string{
"ORG_NAME",
"钉钉(中国)信息技术有限公司",
"B 组织",
"corp_a",
"corp_b",
} {
if !bytes.Contains(buf.Bytes(), []byte(want)) {
t.Fatalf("profile list table missing %q in output:\n%s", want, out)
}
}
for _, unwanted := range []string{"PROFILE", "DingTalk China"} {
if bytes.Contains(buf.Bytes(), []byte(unwanted)) {
t.Fatalf("profile list table should not contain %q in output:\n%s", unwanted, out)
}
}
}
func TestProfileUseMessageIncludesCorpName(t *testing.T) {
got := profileUseMessage(&authpkg.Profile{
Name: "DingTalk China",
CorpID: "ding8196",
CorpName: "钉钉(中国)信息技术有限公司",
})
for _, want := range []string{"当前组织: 钉钉(中国)信息技术有限公司", "ding8196"} {
if !bytes.Contains([]byte(got), []byte(want)) {
t.Fatalf("profileUseMessage() missing %q in %q", want, got)
}
}
if bytes.Contains([]byte(got), []byte("DingTalk China")) {
t.Fatalf("profileUseMessage() should not include profile name when corpName is present: %q", got)
}
}
@@ -0,0 +1,158 @@
package app
import (
"bytes"
"context"
"sync"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
func TestProductCommandsAcceptGlobalProfileFlag(t *testing.T) {
const selectedProfile = "corp_profile_matrix"
products := []struct {
name string
path []string
tool string
}{
{name: "aitable", path: []string{"aitable", "profile-test", "probe"}, tool: "aitable_profile_probe"},
{name: "attendance", path: []string{"attendance", "profile-test", "probe"}, tool: "attendance_profile_probe"},
{name: "calendar", path: []string{"calendar", "profile-test", "probe"}, tool: "calendar_profile_probe"},
{name: "contact", path: []string{"contact", "profile-test", "probe"}, tool: "contact_profile_probe"},
{name: "devdoc", path: []string{"devdoc", "profile-test", "probe"}, tool: "devdoc_profile_probe"},
{name: "ding", path: []string{"ding", "profile-test", "probe"}, tool: "ding_profile_probe"},
{name: "report", path: []string{"report", "profile-test", "probe"}, tool: "report_profile_probe"},
{name: "todo", path: []string{"todo", "profile-test", "probe"}, tool: "todo_profile_probe"},
}
descriptors := make([]market.ServerDescriptor, 0, len(products))
for _, product := range products {
descriptors = append(descriptors, profileFlagProductDescriptor(product.name, product.tool))
}
capture := &profileFlagRunner{}
oldLoadDynamicCommands := loadDynamicCommandsFn
loadDynamicCommandsFn = func(_ context.Context, _ executor.Runner) []*cobra.Command {
SetDynamicServers(descriptors)
return compat.BuildDynamicCommands(descriptors, capture, nil, nil)
}
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
t.Cleanup(func() {
loadDynamicCommandsFn = oldLoadDynamicCommands
SetDynamicServers(nil)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
})
for _, product := range products {
t.Run(product.name, func(t *testing.T) {
capture.reset()
authpkg.SetRuntimeProfile("")
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
args := append([]string{"-f", "json"}, product.path...)
args = append(args, "--profile", selectedProfile)
cmd.SetArgs(args)
// Arrange / Act: execute a product command with root --profile after the leaf.
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
// Assert: the product tool runs under the selected profile without leaking it as a business arg.
call := capture.last()
if call == nil {
t.Fatal("expected product command to invoke runner")
}
if call.product != product.name {
t.Fatalf("canonical product = %q, want %q", call.product, product.name)
}
if call.tool != product.tool {
t.Fatalf("tool = %q, want %q", call.tool, product.tool)
}
if call.profile != selectedProfile {
t.Fatalf("runtime profile at execution = %q, want %q", call.profile, selectedProfile)
}
if _, ok := call.params["profile"]; ok {
t.Fatalf("--profile leaked into business params: %#v", call.params)
}
})
}
}
func profileFlagProductDescriptor(product, tool string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: product,
DisplayName: product,
Endpoint: "https://example.invalid/" + product,
CLI: market.CLIOverlay{
ID: product,
Command: product,
Groups: map[string]market.CLIGroupDef{
"profile-test": {Description: "profile-test"},
},
ToolOverrides: map[string]market.CLIToolOverride{
tool: {
CLIName: "probe",
Group: "profile-test",
Description: tool,
RejectPositional: true,
},
},
},
}
}
type profileFlagCall struct {
product string
tool string
profile string
params map[string]any
}
type profileFlagRunner struct {
mu sync.Mutex
calls []profileFlagCall
}
func (r *profileFlagRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.mu.Lock()
defer r.mu.Unlock()
params := make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
params[key] = value
}
r.calls = append(r.calls, profileFlagCall{
product: invocation.CanonicalProduct,
tool: invocation.Tool,
profile: authpkg.RuntimeProfile(),
params: params,
})
return executor.Result{Invocation: invocation}, nil
}
func (r *profileFlagRunner) reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = nil
}
func (r *profileFlagRunner) last() *profileFlagCall {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.calls) == 0 {
return nil
}
call := r.calls[len(r.calls)-1]
return &call
}
+124 -3
View File
@@ -67,6 +67,9 @@ func Execute() (exitCode int) {
}
}()
restoreArgs := normalizeProcessProfileArgs()
defer restoreArgs()
timing := NewTimingCollector()
defer func() {
StopAllStdioClients() // Ensure child processes are terminated on exit
@@ -96,6 +99,7 @@ func Execute() (exitCode int) {
if executed == nil {
executed = root
}
err = rewordRequiredFlagError(err)
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
@@ -114,6 +118,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.",
@@ -267,6 +301,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
rootCtx = context.Background()
}
flags := &GlobalFlags{}
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
CatalogBaseURLOverride: DiscoveryBaseURL(),
@@ -290,6 +325,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
if flags.ClientID != "" {
authpkg.SetClientID(flags.ClientID)
@@ -327,9 +363,11 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
utilityCommands := []*cobra.Command{
newAuthCommand(patCaller),
newProfileCommand(),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
newCatalogCommand(loader),
newConfigCommand(),
newDoctorCommand(),
newCompletionCommand(root),
@@ -372,6 +410,85 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
return root
}
func preparseProfileFlag(args []string) string {
args, _ = normalizeProfileFlagArgs(args)
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--profile" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case strings.HasPrefix(arg, "--profile="):
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
}
}
return ""
}
func normalizeProcessProfileArgs() func() {
original := append([]string(nil), os.Args...)
if len(os.Args) > 1 {
if normalized, changed := normalizeProfileFlagArgs(os.Args[1:]); changed {
os.Args = append([]string{os.Args[0]}, normalized...)
}
}
return func() {
os.Args = original
}
}
func normalizeProfileFlagArgs(args []string) ([]string, bool) {
if len(args) == 0 {
return args, false
}
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
trimmed := strings.TrimSpace(arg)
switch {
case trimmed == "--profile":
out = append(out, arg)
if i+1 >= len(args) {
continue
}
value, next := collectProfileFlagValue(args[i+1], args, i+2)
out = append(out, value)
i = next - 1
case strings.HasPrefix(trimmed, "--profile="):
value, next := collectProfileFlagValue(strings.TrimPrefix(trimmed, "--profile="), args, i+1)
out = append(out, "--profile="+value)
i = next - 1
default:
out = append(out, arg)
}
}
return out, argsChanged(args, out)
}
func collectProfileFlagValue(first string, args []string, next int) (string, int) {
parts := []string{strings.TrimSpace(first)}
for len(parts) > 0 && strings.HasSuffix(strings.TrimSpace(parts[len(parts)-1]), ",") && next < len(args) {
candidate := strings.TrimSpace(args[next])
if candidate == "" || strings.HasPrefix(candidate, "-") {
break
}
parts = append(parts, candidate)
next++
}
return strings.Join(parts, ""), next
}
func argsChanged(before, after []string) bool {
if len(before) != len(after) {
return true
}
for i := range before {
if before[i] != after[i] {
return true
}
}
return false
}
func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
return buildAuthCommand(patCaller)
}
@@ -770,6 +887,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
"completion": true,
"skill": true,
"plugin": true,
"profile": true,
"version": true,
"help": true,
"recovery": true,
@@ -796,7 +914,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
// by a malicious or misconfigured plugin.
var reservedCommands = map[string]bool{
"auth": true, "api": true, "login": true, "logout": true,
"plugin": true, "skill": true, "cache": true,
"plugin": true, "profile": true, "skill": true, "cache": true,
"config": true, "doctor": true, "completion": true,
"recovery": true, "upgrade": true, "version": true,
"schema": true, "mcp": true, "help": true,
@@ -1471,8 +1589,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 +1779,9 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
detailsByID := toolsToDetails(tools, overlay.ID)
// nil existingTools: overlay built from this plugin's live tool list.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered",
"plugin", p.Manifest.Name, "server", sc.Key,
+2 -2
View File
@@ -24,7 +24,7 @@ func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T)
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/cli/discovery/apis/bamboo":
case "/cli/discovery/apis/cedar":
_ = json.NewEncoder(w).Encode(market.ListResponse{
Metadata: market.ListMetadata{Count: 2},
Servers: []market.ServerEnvelope{
@@ -146,7 +146,7 @@ func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
},
},
})
case "/cli/discovery/apis/bamboo":
case "/cli/discovery/apis/cedar":
marketHits.Add(1)
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
default:
+1 -1
View File
@@ -263,7 +263,7 @@ func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
if !strings.Contains(out.String(), "Discovered MCP Services:") {
t.Fatalf("root help output missing MCP summary:\n%s", out.String())
}
for _, want := range []string{"Utility Commands:", "skill", "auth", "version"} {
for _, want := range []string{"Utility Commands:", "skill", "auth", "profile", "version", "Global Flags:", "--profile"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("root help output missing %q:\n%s", want, out.String())
}
+49
View File
@@ -9,6 +9,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func configureRootHelp(root *cobra.Command) {
@@ -86,6 +87,7 @@ func renderRootHelp(root *cobra.Command) {
_ = tw.Flush()
_, _ = fmt.Fprintln(w)
}
renderRootGlobalFlags(root)
_, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
// Render root.Long after the command list so agents see the upgrade
@@ -99,6 +101,53 @@ func renderRootHelp(root *cobra.Command) {
}
}
func renderRootGlobalFlags(root *cobra.Command) {
if root == nil {
return
}
flags := visiblePersistentFlags(root)
if len(flags) == 0 {
return
}
w := root.OutOrStdout()
_, _ = fmt.Fprintln(w, tui.Section("Global Flags:"))
_, _ = fmt.Fprintln(w)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
for _, flag := range flags {
_, _ = fmt.Fprintf(tw, " %s\t%s\n", formatRootFlag(flag), tui.Dim(strings.TrimSpace(flag.Usage)))
}
_ = tw.Flush()
_, _ = fmt.Fprintln(w)
}
func visiblePersistentFlags(root *cobra.Command) []*pflag.Flag {
if root == nil {
return nil
}
flags := make([]*pflag.Flag, 0)
root.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
if flag == nil || flag.Hidden {
return
}
flags = append(flags, flag)
})
return flags
}
func formatRootFlag(flag *pflag.Flag) string {
if flag == nil {
return ""
}
name := "--" + flag.Name
if flag.Value != nil && flag.Value.Type() != "bool" {
name += " " + flag.Value.Type()
}
if flag.Shorthand == "" {
return " " + name
}
return "-" + flag.Shorthand + ", " + name
}
func commandShort(cmd *cobra.Command) string {
if cmd == nil {
return ""
+202 -19
View File
@@ -17,6 +17,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -161,6 +162,18 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
// invocations within the same process free.
logHostOwnedPATDecisionOnce()
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
if err != nil {
return executor.Result{}, apperrors.NewValidation(err.Error())
}
if multi {
return r.runMultiProfile(ctx, invocation, selections)
}
return r.runSingle(ctx, invocation, true)
}
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
@@ -178,7 +191,9 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
// Prefetch the Keychain token in the background. Keychain access costs
// ~70ms on macOS; starting it here lets the load overlap with endpoint
// resolution and catalog loading below.
go getCachedRuntimeToken(ctx)
if prefetchToken {
go getCachedRuntimeToken(ctx)
}
if shouldUseDirectRuntime(invocation) {
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
@@ -238,6 +253,144 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
return r.executeInvocation(ctx, endpoint, invocation)
}
type multiProfileSelection struct {
Selector string
Profile authpkg.Profile
}
func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfileSelection, bool, error) {
rawSelector = strings.TrimSpace(rawSelector)
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
return nil, false, nil
}
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
return nil, false, nil
}
parts := strings.Split(rawSelector, ",")
selections := make([]multiProfileSelection, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
selector := strings.TrimSpace(part)
if selector == "" {
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
}
profile, err := authpkg.ResolveProfile(configDir, selector)
if err != nil {
return nil, false, err
}
if profile == nil {
return nil, false, fmt.Errorf("profile %q not found", selector)
}
if seen[profile.CorpID] {
continue
}
seen[profile.CorpID] = true
selections = append(selections, multiProfileSelection{
Selector: selector,
Profile: *profile,
})
}
if len(selections) == 0 {
return nil, false, nil
}
return selections, true, nil
}
func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor.Invocation, selections []multiProfileSelection) (executor.Result, error) {
previousProfile := authpkg.RuntimeProfile()
defer authpkg.SetRuntimeProfile(previousProfile)
entries := make([]any, 0, len(selections))
succeeded := 0
failed := 0
for _, selection := range selections {
authpkg.SetRuntimeProfile(selection.Profile.CorpID)
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
entry := map[string]any{
"selector": selection.Selector,
"corpId": selection.Profile.CorpID,
"corpName": selection.Profile.CorpName,
"ok": err == nil,
}
if err != nil {
failed++
entry["error"] = multiProfileErrorPayload(err)
} else {
succeeded++
if payload := multiProfileResultPayload(result); payload != nil {
entry["result"] = payload
}
if result.Response != nil {
if endpoint, ok := result.Response["endpoint"]; ok {
entry["endpoint"] = endpoint
}
}
}
entries = append(entries, entry)
}
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": map[string]any{
"success": failed == 0,
"multiProfile": true,
"summary": map[string]any{
"total": len(selections),
"succeeded": succeeded,
"failed": failed,
},
"profiles": entries,
},
},
}, nil
}
func cloneInvocation(invocation executor.Invocation) executor.Invocation {
cloned := invocation
if invocation.Params != nil {
cloned.Params = make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
cloned.Params[key] = value
}
}
return cloned
}
func multiProfileResultPayload(result executor.Result) any {
if result.Response == nil {
return nil
}
if content, ok := result.Response["content"]; ok {
return content
}
return result.Response
}
func multiProfileErrorPayload(err error) map[string]any {
payload := map[string]any{
"message": err.Error(),
}
var typed *apperrors.Error
if errors.As(err, &typed) {
payload["category"] = string(typed.Category)
if typed.Reason != "" {
payload["reason"] = typed.Reason
}
if typed.Operation != "" {
payload["operation"] = typed.Operation
}
if code := typed.ExitCode(); code != 0 {
payload["exitCode"] = code
}
}
return payload
}
// handleCatalogMiss decides what to do when discovery catalog does not cover the
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
//
@@ -333,6 +486,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 +653,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,
@@ -586,28 +756,40 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
// Cached token state for process lifetime
var (
cachedRuntimeToken string
cachedRuntimeTokenOnce sync.Once
cachedRuntimeTokenMu sync.Mutex
cachedRuntimeTokens = map[string]string{}
)
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
// This avoids repeated Keychain access which takes ~70ms each time.
func getCachedRuntimeToken(ctx context.Context) string {
cachedRuntimeTokenOnce.Do(func() {
loadStart := time.Now()
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
if cacheKey == "" {
cacheKey = "__default__"
}
cachedRuntimeTokenMu.Lock()
if token := cachedRuntimeTokens[cacheKey]; token != "" {
cachedRuntimeTokenMu.Unlock()
return token
}
cachedRuntimeTokenMu.Unlock()
configDir := defaultConfigDir()
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return
}
if token != "" {
cachedRuntimeToken = token
}
})
return cachedRuntimeToken
loadStart := time.Now()
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
configDir := defaultConfigDir()
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return ""
}
if token == "" {
return ""
}
cachedRuntimeTokenMu.Lock()
cachedRuntimeTokens[cacheKey] = token
cachedRuntimeTokenMu.Unlock()
return token
}
// generateExecutionID returns a random 16-char hex string used to correlate
@@ -622,8 +804,9 @@ func generateExecutionID() string {
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
// This should be called after login/logout operations.
func ResetRuntimeTokenCache() {
cachedRuntimeTokenOnce = sync.Once{}
cachedRuntimeToken = ""
cachedRuntimeTokenMu.Lock()
defer cachedRuntimeTokenMu.Unlock()
cachedRuntimeTokens = map[string]string{}
}
func newRuntimeContentScanner() safety.Scanner {
+30 -1
View File
@@ -123,7 +123,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if filterErr != nil {
return filterErr
}
multiSkillNames = filtered
// dws-shared carries the global rules every product skill declares as a
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
}
if !autoYes {
@@ -160,6 +162,33 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar).
const multiSkillPrefix = "dingtalk-"
// multiSharedSkill is the shared, non-product skill that every per-product
// skill declares as a PREREQUISITE. It must always be installed in multi mode
// regardless of --skill / --exclude, otherwise the product skills reference a
// dws-shared that was never installed.
const multiSharedSkill = "dws-shared"
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
func ensureMandatorySharedSkill(selected, all []string) []string {
hasShared := false
for _, n := range all {
if n == multiSharedSkill {
hasShared = true
break
}
}
if !hasShared {
return selected
}
for _, n := range selected {
if n == multiSharedSkill {
return selected
}
}
return append([]string{multiSharedSkill}, selected...)
}
// normalizeMultiSkillName accepts either the short form (aitable) or the
// full form (dingtalk-aitable) and returns the canonical full form.
// Empty input returns "". Comparison is case-insensitive.
+57
View File
@@ -330,6 +330,63 @@ func TestBuildTokenData_DefaultExpiry(t *testing.T) {
}
}
func TestParseMCPTokenResponseIncludesCorpName(t *testing.T) {
provider := &OAuthProvider{}
data, err := provider.parseMCPTokenResponse([]byte(`{
"accessToken": "access-123",
"refreshToken": "refresh-456",
"expiresIn": 7200,
"corpId": "ding123",
"corpName": "钉钉(中国)信息技术有限公司"
}`))
if err != nil {
t.Fatalf("parseMCPTokenResponse() error = %v", err)
}
if data.CorpID != "ding123" {
t.Fatalf("corp id = %q, want ding123", data.CorpID)
}
if data.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("corp name = %q, want 钉钉(中国)信息技术有限公司", data.CorpName)
}
}
func TestParseMCPTokenResponseCorpNameFallbacks(t *testing.T) {
provider := &OAuthProvider{}
for _, tc := range []struct {
name string
body string
want string
}{
{
name: "snake",
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","corp_name":"Snake Corp"}`,
want: "Snake Corp",
},
{
name: "orgName",
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","orgName":"Org Corp"}`,
want: "Org Corp",
},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := provider.parseMCPTokenResponse([]byte(tc.body))
if err != nil {
t.Fatalf("parseMCPTokenResponse() error = %v", err)
}
if data.CorpName != tc.want {
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.want)
}
})
}
}
func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
authURL := buildAuthURL("client-id", "http://127.0.0.1:1234/callback", "ding-target")
if !strings.Contains(authURL, "corpId=ding-target") {
t.Fatalf("auth URL missing target corpId: %s", authURL)
}
}
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
if resp.AccessToken == "" {
return nil
+53 -3
View File
@@ -17,6 +17,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
@@ -30,6 +31,24 @@ var (
// SaveTokenDataKeychain saves TokenData to the platform keychain.
// This is the new secure storage method using random master key.
func SaveTokenDataKeychain(data *TokenData) error {
return saveTokenDataKeychainAccount(keychain.AccountToken, data)
}
// TokenAccountForCorpID returns the keychain account used for a corp-bound token.
func TokenAccountForCorpID(corpID string) string {
return keychain.AccountToken + ":" + strings.TrimSpace(corpID)
}
// SaveTokenDataKeychainForCorpID saves TokenData to a corp-scoped keychain slot.
func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return fmt.Errorf("corpId is required for profile token storage")
}
return saveTokenDataKeychainAccount(TokenAccountForCorpID(corpID), data)
}
func saveTokenDataKeychainAccount(account string, data *TokenData) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal token data: %w", err)
@@ -41,7 +60,7 @@ func SaveTokenDataKeychain(data *TokenData) error {
}
}()
if err := keychain.Set(keychain.Service, keychain.AccountToken, string(jsonData)); err != nil {
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
return fmt.Errorf("save to keychain: %w", err)
}
return nil
@@ -49,12 +68,25 @@ func SaveTokenDataKeychain(data *TokenData) error {
// LoadTokenDataKeychain loads TokenData from the platform keychain.
func LoadTokenDataKeychain() (*TokenData, error) {
jsonStr, err := keychain.Get(keychain.Service, keychain.AccountToken)
return loadTokenDataKeychainAccount(keychain.AccountToken)
}
// LoadTokenDataKeychainForCorpID loads TokenData from a corp-scoped keychain slot.
func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return nil, fmt.Errorf("corpId is required for profile token storage")
}
return loadTokenDataKeychainAccount(TokenAccountForCorpID(corpID))
}
func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
jsonStr, err := keychain.Get(keychain.Service, account)
if err != nil {
return nil, fmt.Errorf("load from keychain: %w", err)
}
if jsonStr == "" {
return nil, fmt.Errorf("no token data in keychain")
return nil, fmt.Errorf("no token data in keychain account %q", account)
}
var data TokenData
@@ -69,11 +101,29 @@ func DeleteTokenDataKeychain() error {
return keychain.Remove(keychain.Service, keychain.AccountToken)
}
// DeleteTokenDataKeychainForCorpID removes TokenData from a corp-scoped keychain slot.
func DeleteTokenDataKeychainForCorpID(corpID string) error {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return fmt.Errorf("corpId is required for profile token storage")
}
return keychain.Remove(keychain.Service, TokenAccountForCorpID(corpID))
}
// TokenDataExistsKeychain checks if token data exists in keychain.
func TokenDataExistsKeychain() bool {
return keychain.Exists(keychain.Service, keychain.AccountToken)
}
// TokenDataExistsKeychainForCorpID checks if a corp-scoped token exists.
func TokenDataExistsKeychainForCorpID(corpID string) bool {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return false
}
return keychain.Exists(keychain.Service, TokenAccountForCorpID(corpID))
}
// EnsureMigration performs one-time migration from legacy .data to keychain.
// This should be called early in the auth flow (e.g., during GetAccessToken).
// The migration is idempotent and thread-safe.
+31 -6
View File
@@ -23,6 +23,7 @@ import (
"net/url"
"os"
"slices"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -143,9 +144,13 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.CorpName = data.CorpName
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
if err := SaveTokenData(p.configDir, updated); err != nil {
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
@@ -185,9 +190,13 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.CorpName = data.CorpName
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
if err := SaveTokenData(p.configDir, updated); err != nil {
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
@@ -259,7 +268,7 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
}
// parseMCPTokenResponse parses token response from MCP proxy.
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."}
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
var resp struct {
AccessToken string `json:"accessToken"`
@@ -267,6 +276,9 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
PersistentCode string `json:"persistentCode"`
ExpiresIn int64 `json:"expiresIn"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
CorpNameSnake string `json:"corp_name"`
OrgName string `json:"orgName"`
// Error fields (when request fails)
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
@@ -293,6 +305,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
CorpID: resp.CorpID,
CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName),
}
if resp.PersistentCode != "" {
data.PersistentCode = resp.PersistentCode
@@ -300,7 +313,16 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
return data, nil
}
func buildAuthURL(clientID, redirectURI string) string {
func firstNonEmpty(values ...string) string {
for _, v := range values {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
return ""
}
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
params := url.Values{
"client_id": {clientID},
"redirect_uri": {redirectURI},
@@ -308,6 +330,9 @@ func buildAuthURL(clientID, redirectURI string) string {
"scope": {DefaultScopes},
"prompt": {"consent"},
}
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
params.Set("corpId", targetCorpID)
}
return AuthorizeURL + "?" + params.Encode()
}
+11 -7
View File
@@ -37,12 +37,13 @@ var oauthHTTPClient = &http.Client{
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
type OAuthProvider struct {
configDir string
clientID string
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
configDir string
clientID string
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
TargetCorpID string
}
// NewOAuthProvider creates a new OAuth provider.
@@ -397,7 +398,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_ = server.Shutdown(shutCtx)
}()
authURL := buildAuthURL(p.clientID, redirectURI)
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
if p.logger != nil {
p.logger.Debug("authorization URL", "url", authURL)
}
@@ -547,9 +548,12 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
if rErr == nil {
return refreshed.AccessToken, nil
}
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
if p.logger != nil {
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
}
} else {
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
}
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
+4 -1
View File
@@ -52,6 +52,9 @@ func PortableAuthTargetPopulated(configDir string) bool {
if TokenDataExistsKeychain() {
return true
}
if _, err := os.Stat(ProfilesPath(configDir)); err == nil {
return true
}
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err == nil {
return true
}
@@ -199,7 +202,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
func portableConfigFiles(configDir string) ([]string, error) {
var files []string
patterns := []string{"app*.json", "mcp_url", "terminal_url"}
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
for _, pattern := range patterns {
matches, err := filepath.Glob(filepath.Join(configDir, pattern))
if err != nil {
+73
View File
@@ -138,3 +138,76 @@ func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
t.Fatalf("imported app config = %#v, want client ID preserved", cfg)
}
}
func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
SetRuntimeProfile("")
t.Cleanup(func() { SetRuntimeProfile("") })
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
sourceConfig := filepath.Join(t.TempDir(), ".dws")
tokenA := &TokenData{
AccessToken: "access-a",
RefreshToken: "refresh-a",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
CorpID: "corp_a",
CorpName: "A Org",
ClientID: "client-a",
}
tokenB := &TokenData{
AccessToken: "access-b",
RefreshToken: "refresh-b",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
CorpID: "corp_b",
CorpName: "B Org",
ClientID: "client-b",
}
if err := SaveTokenData(sourceConfig, tokenA); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(sourceConfig, tokenB); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
var bundle bytes.Buffer
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
}
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
t.Setenv(keychain.StorageDirEnv, targetKeychain)
targetConfig := filepath.Join(t.TempDir(), ".dws")
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
}
cfg, err := LoadProfiles(targetConfig)
if err != nil {
t.Fatalf("LoadProfiles() after import error = %v", err)
}
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
t.Fatalf("profiles after import = %#v", cfg)
}
if len(cfg.Profiles) != 2 {
t.Fatalf("profiles len = %d, want 2: %#v", len(cfg.Profiles), cfg.Profiles)
}
loadedA, err := LoadTokenDataForProfile(targetConfig, "corp_a")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(A) after import error = %v", err)
}
if loadedA.AccessToken != "access-a" {
t.Fatalf("profile A token = %q, want access-a", loadedA.AccessToken)
}
loadedB, err := LoadTokenDataForProfile(targetConfig, "corp_b")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(B) after import error = %v", err)
}
if loadedB.AccessToken != "access-b" {
t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken)
}
}
+678
View File
@@ -0,0 +1,678 @@
// 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 auth
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// withProfilesLock runs fn while holding the auth dual-layer lock (process +
// cross-process file lock) so that all read-modify-write cycles on
// profiles.json and the legacy token mirror are serialized.
//
// The lock is NOT reentrant. fn must only call the lock-free *Locked variants;
// calling a public (locking) function from within fn would deadlock. Paths that
// already hold the lock (e.g. OAuthProvider.lockedRefresh and the read path
// reached from it) must likewise call the lock-free variants directly.
func withProfilesLock(configDir string, fn func() error) error {
lock, err := AcquireDualLock(context.Background(), configDir)
if err != nil {
return err
}
defer lock.Release()
return fn()
}
const profilesJSONFile = "profiles.json"
const (
ProfileStatusActive = "active"
ProfileStatusExpired = "expired"
ProfileStatusRevoked = "revoked"
)
// ProfilesConfig stores non-sensitive profile metadata. Token material stays in keychain.
type ProfilesConfig struct {
Version int `json:"version"`
PrimaryProfile string `json:"primaryProfile,omitempty"`
CurrentProfile string `json:"currentProfile,omitempty"`
PreviousProfile string `json:"previousProfile,omitempty"`
Profiles []Profile `json:"profiles,omitempty"`
}
// Profile is a logged-in DingTalk organization identity.
type Profile struct {
Name string `json:"name"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName,omitempty"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
ClientID string `json:"clientId,omitempty"`
Status string `json:"status,omitempty"`
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
RefreshExpAt string `json:"refreshExpAt,omitempty"`
LastLoginAt string `json:"lastLoginAt,omitempty"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// SetRuntimeProfile sets a process-local one-shot profile override.
func SetRuntimeProfile(profile string) {
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
}
// RuntimeProfile returns the process-local one-shot profile override.
func RuntimeProfile() string {
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
}
// ProfilesPath returns the profile metadata path for a config dir.
func ProfilesPath(configDir string) string {
return filepath.Join(configDir, profilesJSONFile)
}
// LoadProfiles reads profiles.json. A missing file returns an empty config.
func LoadProfiles(configDir string) (*ProfilesConfig, error) {
path := ProfilesPath(configDir)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &ProfilesConfig{Version: 1}, nil
}
return nil, fmt.Errorf("read profiles: %w", err)
}
var cfg ProfilesConfig
if err := json.Unmarshal(data, &cfg); err != nil {
// Corrupt file (e.g. an interrupted concurrent write): quarantine it and
// rebuild an empty config so the CLI can self-heal (auth reset / re-login)
// instead of being permanently locked out by an unreadable profiles.json.
quarantine := path + ".corrupt-" + time.Now().Format("20060102-150405.000")
_ = os.Rename(path, quarantine)
return &ProfilesConfig{Version: 1}, nil
}
normalizeProfilesConfig(&cfg)
return &cfg, nil
}
// SaveProfiles writes profiles.json atomically.
func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
if cfg == nil {
cfg = &ProfilesConfig{}
}
normalizeProfilesConfig(cfg)
if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal profiles: %w", err)
}
data = append(data, '\n')
path := ProfilesPath(configDir)
// Per-write random temp name: a fixed "profiles.json.tmp" lets two
// concurrent writers interleave into the same temp file and rename a
// corrupted result into place.
tmp := path + "." + uuid.New().String() + ".tmp"
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
return fmt.Errorf("write profiles tmp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename profiles: %w", err)
}
return nil
}
// EnsureProfilesMigration initializes profiles.json from the legacy auth-token slot when needed.
// EnsureProfilesMigration migrates a legacy single-slot token into the
// profiles registry. It acquires the lock; call ensureProfilesMigrationLocked
// from contexts that already hold it (refresh / read paths).
func EnsureProfilesMigration(configDir string) error {
return withProfilesLock(configDir, func() error {
return ensureProfilesMigrationLocked(configDir)
})
}
func ensureProfilesMigrationLocked(configDir string) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
if len(cfg.Profiles) > 0 {
return nil
}
if !TokenDataExistsKeychain() {
return nil
}
data, err := LoadTokenDataKeychain()
if err != nil || data == nil || strings.TrimSpace(data.CorpID) == "" {
return nil
}
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
return err
}
return upsertProfileFromToken(configDir, cfg, data, false)
}
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
func UpsertProfileFromToken(configDir string, data *TokenData) error {
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
}
// UpsertProfileFromTokenWithCurrent updates profiles.json and optionally makes
// the token's corp the persistent current profile.
func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCurrent bool) error {
return withProfilesLock(configDir, func() error {
return upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent)
})
}
func upsertProfileFromTokenWithCurrentLocked(configDir string, data *TokenData, makeCurrent bool) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
return upsertProfileFromToken(configDir, cfg, data, makeCurrent)
}
func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenData, makeCurrent bool) error {
if data == nil {
return nil
}
corpID := strings.TrimSpace(data.CorpID)
if corpID == "" {
return nil
}
normalizeProfilesConfig(cfg)
now := time.Now().Format(time.RFC3339)
idx := profileIndexByCorpID(cfg, corpID)
if idx < 0 {
profile := Profile{
Name: chooseProfileName(cfg, data),
CorpID: corpID,
CorpName: strings.TrimSpace(data.CorpName),
UserID: strings.TrimSpace(data.UserID),
UserName: strings.TrimSpace(data.UserName),
ClientID: strings.TrimSpace(data.ClientID),
Status: ProfileStatusActive,
ExpiresAt: timeOrRFC3339(data.ExpiresAt),
RefreshExpAt: timeOrRFC3339(data.RefreshExpAt),
LastLoginAt: now,
LastUsedAt: now,
UpdatedAt: now,
}
cfg.Profiles = append(cfg.Profiles, profile)
} else {
p := &cfg.Profiles[idx]
if shouldRefreshProfileName(p, data) {
p.Name = chooseProfileName(cfg, data)
}
if v := strings.TrimSpace(data.CorpName); v != "" {
p.CorpName = v
}
if v := strings.TrimSpace(data.UserID); v != "" {
p.UserID = v
}
if v := strings.TrimSpace(data.UserName); v != "" {
p.UserName = v
}
if v := strings.TrimSpace(data.ClientID); v != "" {
p.ClientID = v
}
p.Status = ProfileStatusActive
p.ExpiresAt = timeOrRFC3339(data.ExpiresAt)
p.RefreshExpAt = timeOrRFC3339(data.RefreshExpAt)
p.LastLoginAt = now
p.LastUsedAt = now
p.UpdatedAt = now
}
if cfg.PrimaryProfile == "" {
cfg.PrimaryProfile = corpID
}
if makeCurrent && cfg.CurrentProfile != corpID {
if cfg.CurrentProfile != "" {
cfg.PreviousProfile = cfg.CurrentProfile
}
cfg.CurrentProfile = corpID
}
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = corpID
}
return SaveProfiles(configDir, cfg)
}
// ResolveProfile returns a profile selected by name/corpId or by current/primary fallback.
func ResolveProfile(configDir, selector string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
selector = strings.TrimSpace(selector)
if selector != "" {
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", selector)
}
return p, nil
}
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
return p, nil
}
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
return p, nil
}
return nil, nil
}
func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
selector = strings.TrimSpace(selector)
if selector != "" {
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", selector)
}
return p, nil
}
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
if p := findProfile(cfg, candidate); p != nil && TokenDataExistsKeychainForCorpID(p.CorpID) {
return p, nil
}
}
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
return p, nil
}
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
return p, nil
}
return nil, nil
}
// SetCurrentProfile persists the selected current profile.
func SetCurrentProfile(configDir, selector string) (*Profile, error) {
var result *Profile
err := withProfilesLock(configDir, func() error {
p, e := setCurrentProfileLocked(configDir, selector)
result = p
return e
})
return result, err
}
func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
}
if cfg.CurrentProfile != p.CorpID {
if cfg.CurrentProfile != "" {
cfg.PreviousProfile = cfg.CurrentProfile
}
cfg.CurrentProfile = p.CorpID
}
touchProfile(cfg, p.CorpID)
if err := SaveProfiles(configDir, cfg); err != nil {
return nil, err
}
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
return nil, err
}
return findProfile(cfg, p.CorpID), nil
}
// UsePreviousProfile toggles currentProfile and previousProfile.
func UsePreviousProfile(configDir string) (*Profile, error) {
var result *Profile
err := withProfilesLock(configDir, func() error {
p, e := usePreviousProfileLocked(configDir)
result = p
return e
})
return result, err
}
func usePreviousProfileLocked(configDir string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
prev := strings.TrimSpace(cfg.PreviousProfile)
if prev == "" {
return nil, fmt.Errorf("previous profile is empty")
}
p := findProfile(cfg, prev)
if p == nil {
return nil, fmt.Errorf("previous profile %q not found", prev)
}
cfg.PreviousProfile, cfg.CurrentProfile = cfg.CurrentProfile, p.CorpID
touchProfile(cfg, p.CorpID)
if err := SaveProfiles(configDir, cfg); err != nil {
return nil, err
}
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
return nil, err
}
return findProfile(cfg, p.CorpID), nil
}
// RemoveProfile removes a profile from metadata and returns the removed profile.
func RemoveProfile(configDir, selector string) (*Profile, error) {
var result *Profile
err := withProfilesLock(configDir, func() error {
p, e := removeProfileLocked(configDir, selector)
result = p
return e
})
return result, err
}
func removeProfileLocked(configDir, selector string) (*Profile, error) {
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
}
removed := *p
kept := cfg.Profiles[:0]
for _, profile := range cfg.Profiles {
if profile.CorpID != removed.CorpID {
kept = append(kept, profile)
}
}
cfg.Profiles = kept
if cfg.PrimaryProfile == removed.CorpID {
cfg.PrimaryProfile = firstProfileCorpID(cfg)
}
if cfg.CurrentProfile == removed.CorpID {
cfg.CurrentProfile = cfg.PrimaryProfile
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = firstProfileCorpID(cfg)
}
}
if cfg.PreviousProfile == removed.CorpID {
cfg.PreviousProfile = ""
}
if len(cfg.Profiles) == 0 {
cfg.PrimaryProfile = ""
cfg.CurrentProfile = ""
cfg.PreviousProfile = ""
}
if err := SaveProfiles(configDir, cfg); err != nil {
return nil, err
}
return &removed, nil
}
// MarkProfileStatus updates a profile status if it exists.
func MarkProfileStatus(configDir, corpID, status string) error {
if strings.TrimSpace(corpID) == "" {
return nil
}
return withProfilesLock(configDir, func() error {
return markProfileStatusLocked(configDir, corpID, status)
})
}
func markProfileStatusLocked(configDir, corpID, status string) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
p := findProfile(cfg, corpID)
if p == nil {
return nil
}
p.Status = strings.TrimSpace(status)
p.UpdatedAt = time.Now().Format(time.RFC3339)
return SaveProfiles(configDir, cfg)
}
// SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token.
func SyncLegacyTokenMirror(configDir string) error {
return withProfilesLock(configDir, func() error {
return syncLegacyTokenMirrorLocked(configDir)
})
}
func syncLegacyTokenMirrorLocked(configDir string) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
hadReadError := false
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
p := findProfile(cfg, candidate)
if p == nil {
continue
}
data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID)
if loadErr != nil {
// Transient keychain read failure: do NOT touch the existing mirror.
hadReadError = true
continue
}
if data != nil {
if err := SaveTokenDataKeychain(data); err != nil {
return err
}
return WriteTokenMarker(configDir)
}
}
if hadReadError {
// Keep the existing legacy mirror untouched rather than wiping a host
// app's login state just because keychain was momentarily unavailable.
return nil
}
// All candidate profiles confirmed absent (no token): clear the mirror.
_ = DeleteTokenDataKeychain()
_ = DeleteTokenMarker(configDir)
return nil
}
func normalizeProfilesConfig(cfg *ProfilesConfig) {
if cfg == nil {
return
}
cfg.Version = 1
seen := make(map[string]bool, len(cfg.Profiles))
profiles := cfg.Profiles[:0]
for _, p := range cfg.Profiles {
p.CorpID = strings.TrimSpace(p.CorpID)
if p.CorpID == "" || seen[p.CorpID] {
continue
}
seen[p.CorpID] = true
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
p.Name = p.CorpID
}
if corpName := strings.TrimSpace(p.CorpName); p.Name == p.CorpID && corpName != "" && !profileNameTakenByOtherCorp(cfg, corpName, p.CorpID) {
p.Name = corpName
}
if p.Status == "" {
p.Status = ProfileStatusActive
}
profiles = append(profiles, p)
}
cfg.Profiles = profiles
if cfg.PrimaryProfile != "" && findProfile(cfg, cfg.PrimaryProfile) == nil {
cfg.PrimaryProfile = ""
}
if cfg.CurrentProfile != "" && findProfile(cfg, cfg.CurrentProfile) == nil {
cfg.CurrentProfile = ""
}
if cfg.PreviousProfile != "" && findProfile(cfg, cfg.PreviousProfile) == nil {
cfg.PreviousProfile = ""
}
if cfg.PrimaryProfile == "" {
cfg.PrimaryProfile = firstProfileCorpID(cfg)
}
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = cfg.PrimaryProfile
}
}
func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string {
base := strings.TrimSpace(data.CorpName)
if base == "" {
base = strings.TrimSpace(data.CorpID)
}
if base == "" {
base = "profile"
}
if !profileNameTakenByOtherCorp(cfg, base, data.CorpID) {
return base
}
suffix := shortCorpID(data.CorpID)
name := base + "-" + suffix
if !profileNameTakenByOtherCorp(cfg, name, data.CorpID) {
return name
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%s-%d", base, suffix, i)
if !profileNameTakenByOtherCorp(cfg, candidate, data.CorpID) {
return candidate
}
}
}
func shouldRefreshProfileName(p *Profile, data *TokenData) bool {
if p == nil || data == nil {
return false
}
name := strings.TrimSpace(p.Name)
if name == "" {
return true
}
return strings.TrimSpace(data.CorpName) != "" && name == strings.TrimSpace(p.CorpID)
}
func profileNameTakenByOtherCorp(cfg *ProfilesConfig, name, corpID string) bool {
name = strings.TrimSpace(name)
corpID = strings.TrimSpace(corpID)
for _, p := range cfg.Profiles {
if p.CorpID != corpID && p.Name == name {
return true
}
}
return false
}
func findProfile(cfg *ProfilesConfig, selector string) *Profile {
if cfg == nil {
return nil
}
selector = strings.TrimSpace(selector)
if selector == "" {
return nil
}
var corpNameMatch *Profile
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == selector || cfg.Profiles[i].Name == selector {
return &cfg.Profiles[i]
}
if strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
if corpNameMatch != nil {
return nil
}
corpNameMatch = &cfg.Profiles[i]
}
}
return corpNameMatch
}
func profileIndexByCorpID(cfg *ProfilesConfig, corpID string) int {
if cfg == nil {
return -1
}
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == corpID {
return i
}
}
return -1
}
func firstProfileCorpID(cfg *ProfilesConfig) string {
if cfg == nil || len(cfg.Profiles) == 0 {
return ""
}
return cfg.Profiles[0].CorpID
}
func touchProfile(cfg *ProfilesConfig, corpID string) {
if p := findProfile(cfg, corpID); p != nil {
now := time.Now().Format(time.RFC3339)
p.LastUsedAt = now
p.UpdatedAt = now
}
}
func timeOrRFC3339(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func shortCorpID(corpID string) string {
corpID = strings.TrimSpace(corpID)
if len(corpID) <= 8 {
return corpID
}
return corpID[len(corpID)-8:]
}
+167 -10
View File
@@ -22,8 +22,11 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -82,7 +85,7 @@ func WriteTokenMarker(configDir string) error {
if err := os.MkdirAll(configDir, 0o700); err != nil {
return err
}
tmp := filepath.Join(configDir, tokenJSONFile+".tmp")
tmp := filepath.Join(configDir, tokenJSONFile+"."+uuid.New().String()+".tmp")
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
@@ -91,7 +94,10 @@ func WriteTokenMarker(configDir string) error {
// DeleteTokenMarker removes the token.json marker file.
func DeleteTokenMarker(configDir string) error {
return os.Remove(filepath.Join(configDir, tokenJSONFile))
if err := os.Remove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
@@ -99,20 +105,67 @@ func DeleteTokenMarker(configDir string) error {
// to the default keychain-based storage.
func SaveTokenData(configDir string, data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshaling token data for hook: %w", err)
}
return h.SaveToken(configDir, jsonData)
return saveTokenViaHook(h, configDir, data)
}
return SaveTokenDataKeychain(data)
return withProfilesLock(configDir, func() error {
return saveTokenDataLocked(configDir, data)
})
}
// saveTokenDataLocked performs the keychain + profiles.json + legacy mirror
// writes assuming the auth dual-layer lock is already held. Callers that
// already hold the lock (OAuthProvider refresh path, the legacy secure->keychain
// migration in LoadTokenDataForProfile) must use this instead of SaveTokenData
// to avoid deadlocking on the non-reentrant lock.
func saveTokenDataLocked(configDir string, data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
return saveTokenViaHook(h, configDir, data)
}
if data != nil && strings.TrimSpace(data.CorpID) != "" {
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
return err
}
makeCurrent := strings.TrimSpace(RuntimeProfile()) == ""
if err := upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent); err != nil {
return err
}
if makeCurrent {
if err := SaveTokenDataKeychain(data); err != nil {
return err
}
} else if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
return err
}
return WriteTokenMarker(configDir)
}
if err := SaveTokenDataKeychain(data); err != nil {
return err
}
return WriteTokenMarker(configDir)
}
func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshaling token data for hook: %w", err)
}
return h.SaveToken(configDir, jsonData)
}
// LoadTokenData reads TokenData. When an edition hook (LoadToken) is
// registered, it delegates entirely to the hook; otherwise it falls back
// to keychain with legacy .data migration.
func LoadTokenData(configDir string) (*TokenData, error) {
return LoadTokenDataForProfile(configDir, RuntimeProfile())
}
// LoadTokenDataForProfile reads TokenData for a profile selector without mutating
// currentProfile. Empty selector follows the default resolution chain.
func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
if h := edition.Get(); h.LoadToken != nil {
if strings.TrimSpace(profile) != "" {
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
}
jsonData, err := h.LoadToken(configDir)
if err != nil {
return nil, err
@@ -125,6 +178,28 @@ func LoadTokenData(configDir string) (*TokenData, error) {
}
// Default: keychain with legacy .data migration
selected, err := resolveProfileForLoad(configDir, profile)
if err != nil {
return nil, err
}
if selected != nil {
data, err := LoadTokenDataKeychainForCorpID(selected.CorpID)
if err == nil {
return data, nil
}
if strings.TrimSpace(profile) != "" {
return nil, err
}
// No explicit --profile: `selected` is the resolved current/primary
// profile. Only fall back to the legacy single slot when it belongs to
// the SAME org; otherwise surface the error instead of silently acting
// as a different organization (the legacy mirror may have drifted).
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
return legacy, nil
}
return nil, err
}
if TokenDataExistsKeychain() {
return LoadTokenDataKeychain()
}
@@ -132,7 +207,9 @@ func LoadTokenData(configDir string) (*TokenData, error) {
if err != nil {
return nil, err
}
if err := SaveTokenDataKeychain(data); err == nil {
// One-time legacy secure-store -> keychain migration. This read path may run
// while the refresh lock is already held, so use the lock-free saver.
if err := saveTokenDataLocked(configDir, data); err == nil {
_ = DeleteSecureData(configDir)
}
return data, nil
@@ -142,15 +219,95 @@ func LoadTokenData(configDir string) (*TokenData, error) {
// registered, it delegates entirely to the hook; otherwise it falls back
// to keychain + legacy cleanup.
func DeleteTokenData(configDir string) error {
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
}
// DeleteTokenDataForProfile removes one profile's token data. Empty selector
// removes the current/default profile, falling back to legacy single-slot auth.
func DeleteTokenDataForProfile(configDir, profile string) error {
if h := edition.Get(); h.DeleteToken != nil {
if strings.TrimSpace(profile) != "" {
return fmt.Errorf("profile selection is not supported by the current auth backend")
}
return h.DeleteToken(configDir)
}
return withProfilesLock(configDir, func() error {
return deleteTokenDataForProfileLocked(configDir, profile)
})
}
func deleteTokenDataForProfileLocked(configDir, profile string) error {
selected, err := resolveProfileForLoad(configDir, profile)
if err != nil {
return err
}
if selected != nil {
keychainErr := DeleteTokenDataKeychainForCorpID(selected.CorpID)
_, removeErr := removeProfileLocked(configDir, selected.CorpID)
legacyErr := syncLegacyTokenMirrorLocked(configDir)
secureErr := DeleteSecureData(configDir)
if keychainErr != nil {
return keychainErr
}
if removeErr != nil {
return removeErr
}
if legacyErr != nil {
return legacyErr
}
return secureErr
}
keychainErr := DeleteTokenDataKeychain()
legacyErr := DeleteSecureData(configDir)
markerErr := DeleteTokenMarker(configDir)
if keychainErr != nil {
return keychainErr
}
return legacyErr
if legacyErr != nil {
return legacyErr
}
return markerErr
}
// DeleteAllTokenData removes all profile-scoped and legacy token data.
func DeleteAllTokenData(configDir string) error {
if h := edition.Get(); h.DeleteToken != nil {
return h.DeleteToken(configDir)
}
return withProfilesLock(configDir, func() error {
var firstErr error
// Best-effort: even if profiles.json is unreadable, still clear every
// other slot so the user can always self-heal via auth reset / logout.
if cfg, err := LoadProfiles(configDir); err == nil {
for _, profile := range cfg.Profiles {
if e := DeleteTokenDataKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
firstErr = e
}
}
}
if e := os.Remove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
firstErr = e
}
// Sweep any quarantined corrupt-profiles files so they don't accumulate.
if matches, _ := filepath.Glob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 {
for _, m := range matches {
if e := os.Remove(m); e != nil && !os.IsNotExist(e) && firstErr == nil {
firstErr = e
}
}
}
if e := DeleteTokenDataKeychain(); e != nil && firstErr == nil {
firstErr = e
}
if e := DeleteSecureData(configDir); e != nil && firstErr == nil {
firstErr = e
}
if e := DeleteTokenMarker(configDir); e != nil && firstErr == nil {
firstErr = e
}
return firstErr
})
}
// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token.
+283
View File
@@ -14,6 +14,7 @@
package auth
import (
"os"
"testing"
"time"
@@ -25,8 +26,10 @@ import (
// written by these tests, and removes test data on completion.
func cleanupKeychain(t *testing.T) {
t.Helper()
SetRuntimeProfile("")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Cleanup(func() {
SetRuntimeProfile("")
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
})
}
@@ -127,6 +130,271 @@ func TestTokenOverwrite(t *testing.T) {
}
}
func TestMultiProfileSaveLoadAndSwitch(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
dataA := testToken("at_a", "corp_a", "A Org")
dataB := testToken("at_b", "corp_b", "B Org")
if err := SaveTokenData(configDir, dataA); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, dataB); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
t.Fatalf("profile pointers = primary %q current %q previous %q", cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile)
}
loadedB, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loadedB.AccessToken != "at_b" {
t.Fatalf("default token = %q, want at_b", loadedB.AccessToken)
}
loadedA, err := LoadTokenDataForProfile(configDir, "A Org")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(A Org) error = %v", err)
}
if loadedA.AccessToken != "at_a" {
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
}
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
t.Fatalf("SetCurrentProfile(A) error = %v", err)
}
loadedA, err = LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() after switch error = %v", err)
}
if loadedA.AccessToken != "at_a" {
t.Fatalf("default token after switch = %q, want at_a", loadedA.AccessToken)
}
if _, err := UsePreviousProfile(configDir); err != nil {
t.Fatalf("UsePreviousProfile() error = %v", err)
}
loadedB, err = LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() after previous error = %v", err)
}
if loadedB.AccessToken != "at_b" {
t.Fatalf("default token after previous = %q, want at_b", loadedB.AccessToken)
}
}
func TestRuntimeProfileOverrideDoesNotMutateCurrent(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
t.Fatalf("SetCurrentProfile(A) error = %v", err)
}
SetRuntimeProfile("corp_b")
if err := SaveTokenData(configDir, testToken("at_b_refreshed", "corp_b", "B Org")); err != nil {
t.Fatalf("SaveTokenData(B refresh) error = %v", err)
}
SetRuntimeProfile("")
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_a" {
t.Fatalf("current profile = %q, want corp_a", cfg.CurrentProfile)
}
loadedB, err := LoadTokenDataForProfile(configDir, "corp_b")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(B) error = %v", err)
}
if loadedB.AccessToken != "at_b_refreshed" {
t.Fatalf("profile B token = %q, want at_b_refreshed", loadedB.AccessToken)
}
loadedDefault, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loadedDefault.AccessToken != "at_a" {
t.Fatalf("default token = %q, want at_a", loadedDefault.AccessToken)
}
}
func TestDeleteProfilePreservesOtherProfiles(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
if err := DeleteTokenDataForProfile(configDir, "corp_b"); err != nil {
t.Fatalf("DeleteTokenDataForProfile(B) error = %v", err)
}
if _, err := LoadTokenDataForProfile(configDir, "corp_b"); err == nil {
t.Fatal("LoadTokenDataForProfile(B) error = nil after delete, want failure")
}
loadedA, err := LoadTokenDataForProfile(configDir, "corp_a")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(A) error = %v", err)
}
if loadedA.AccessToken != "at_a" {
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 || cfg.CurrentProfile != "corp_a" {
t.Fatalf("profiles after delete = %#v", cfg)
}
}
func TestUpsertProfileFromTokenOverwritesSameCorp(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
first := testToken("at_first", "corp_same", "旧组织名")
if err := SaveTokenData(configDir, first); err != nil {
t.Fatalf("SaveTokenData(first) error = %v", err)
}
second := testToken("at_second", "corp_same", "新组织名")
second.UserID = "user_updated"
second.UserName = "Updated User"
second.ClientID = "client_updated"
if err := SaveTokenData(configDir, second); err != nil {
t.Fatalf("SaveTokenData(second) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
}
profile := cfg.Profiles[0]
if profile.CorpName != "新组织名" {
t.Fatalf("corpName = %q, want 新组织名", profile.CorpName)
}
if profile.UserID != "user_updated" || profile.UserName != "Updated User" || profile.ClientID != "client_updated" {
t.Fatalf("profile metadata was not overwritten: %#v", profile)
}
loaded, err := LoadTokenDataForProfile(configDir, "corp_same")
if err != nil {
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
}
if loaded.AccessToken != "at_second" {
t.Fatalf("access token = %q, want at_second", loaded.AccessToken)
}
}
func TestUpsertProfileFromTokenPromotesCorpIDNameToCorpName(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
first := testToken("at_first", "corp_same", "")
if err := SaveTokenData(configDir, first); err != nil {
t.Fatalf("SaveTokenData(first) error = %v", err)
}
second := testToken("at_second", "corp_same", "新组织名")
if err := SaveTokenData(configDir, second); err != nil {
t.Fatalf("SaveTokenData(second) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
}
if cfg.Profiles[0].Name != "新组织名" {
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
}
resolved, err := ResolveProfile(configDir, "新组织名")
if err != nil {
t.Fatalf("ResolveProfile(corpName) error = %v", err)
}
if resolved.CorpID != "corp_same" {
t.Fatalf("resolved corpId = %q, want corp_same", resolved.CorpID)
}
}
func TestLoadProfilesPromotesLegacyCorpIDNameToCorpName(t *testing.T) {
configDir := t.TempDir()
raw := `{
"version": 1,
"primaryProfile": "corp_same",
"currentProfile": "corp_same",
"profiles": [
{
"name": "corp_same",
"corpId": "corp_same",
"corpName": "新组织名"
}
]
}`
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(ProfilesPath(configDir), []byte(raw), 0o600); err != nil {
t.Fatalf("WriteFile(profiles.json) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("profiles len = %d, want 1", len(cfg.Profiles))
}
if cfg.Profiles[0].Name != "新组织名" {
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
}
}
func TestLegacyKeychainMigrationInitializesProfile(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
legacy := testToken("at_legacy", "corp_legacy", "Legacy Org")
if err := SaveTokenDataKeychain(legacy); err != nil {
t.Fatalf("SaveTokenDataKeychain() error = %v", err)
}
loaded, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.AccessToken != "at_legacy" {
t.Fatalf("loaded token = %q, want at_legacy", loaded.AccessToken)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_legacy" || cfg.CurrentProfile != "corp_legacy" {
t.Fatalf("profile pointers after migration = %#v", cfg)
}
if !TokenDataExistsKeychainForCorpID("corp_legacy") {
t.Fatal("corp-scoped token should exist after migration")
}
}
func TestTokenDataExistsKeychain(t *testing.T) {
cleanupKeychain(t)
@@ -152,6 +420,21 @@ func TestTokenDataExistsKeychain(t *testing.T) {
}
}
func testToken(accessToken, corpID, corpName string) *TokenData {
now := time.Now().UTC()
return &TokenData{
AccessToken: accessToken,
RefreshToken: "rt_" + accessToken,
ExpiresAt: now.Add(2 * time.Hour),
RefreshExpAt: now.Add(30 * 24 * time.Hour),
CorpID: corpID,
CorpName: corpName,
UserID: "user_" + corpID,
UserName: "User " + corpID,
ClientID: "client_" + corpID,
}
}
func TestTokenValidityChecks(t *testing.T) {
t.Parallel()
+7 -1
View File
@@ -18,6 +18,7 @@ package cobracmd
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -43,7 +44,7 @@ func FlagChanged(cmd *cobra.Command, name string) bool {
// NewGroupCommand creates a non-leaf parent command that shows help when invoked.
func NewGroupCommand(use, short string) *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
@@ -53,6 +54,11 @@ func NewGroupCommand(use, short string) *cobra.Command {
return cmd.Help()
},
}
// Tag as a group container: its RunE only prints help, so cobra's
// Runnable() can't distinguish it from a real leaf — callers that need to
// collapse empty groups rely on this annotation.
cmdutil.MarkGroup(cmd)
return cmd
}
// NewHiddenGroupCommand creates a hidden non-leaf parent command.
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"encoding/json"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// attendanceScheduleInnerRequired are the fields every scheduleVOS item must
// carry; the backend rejects partial items with an opaque error, so the CLI
// validates them up front (mirrors wukong's attendance.go).
var attendanceScheduleInnerRequired = []string{"userId", "workDate", "classId", "isRest"}
var attendanceGroupTypes = map[string]bool{"FIXED": true, "TURN": true, "NONE": true}
var attendanceApproveTypes = map[string]bool{
"overtime": true, "trip": true, "travel": true, "business_trip": true,
"business-trip": true, "out": true, "leave": true, "patch": true,
"repair_check": true, "repair-check": true,
}
// installAttendanceHook wires attendance-specific PreRunE validators that
// mirror wukong's client-side checks (inner-JSON required fields, group type,
// FIXED conditional requirements, group-update no-op). No-op for other
// products / tools. Preserves any PreRunE NewDirectCommand already installed.
func installAttendanceHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil || strings.TrimSpace(canonicalProduct) != "attendance" {
return
}
var validate func(*cobra.Command) error
switch toolName {
case "generateTurnSchedule":
validate = validateAttendanceScheduleImport
case "create_class_setting":
validate = validateAttendanceClassCreate
case "create_group_setting":
validate = validateAttendanceGroupCreate
case "update_group_setting":
validate = validateAttendanceGroupUpdate
case "update_group_member":
validate = validateAttendanceUpdateMembers
case "save_self_setting":
validate = validateAttendanceSelfSettingSave
case "query_at_approve_template":
validate = validateAttendanceApproveTemplates
default:
return
}
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return validate(c)
}
}
func attFlagString(cmd *cobra.Command, names ...string) string {
for _, n := range names {
if cmd.Flags().Lookup(n) == nil {
continue
}
if v, err := cmd.Flags().GetString(n); err == nil && strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func validateAttendanceScheduleImport(cmd *cobra.Command) error {
raw := attFlagString(cmd, "scheduleVOS", "schedules")
if raw == "" {
return nil // empty is owned by the required-flag check
}
var items []map[string]any
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil // malformed JSON is owned by a separate check
}
if len(items) == 0 {
return apperrors.NewValidation("--scheduleVOS requires at least one schedule entry (empty array not allowed)")
}
for _, item := range items {
for _, f := range attendanceScheduleInnerRequired {
if _, ok := item[f]; !ok {
return apperrors.NewValidation("missing required field: " + f + "(--scheduleVOS 每个排班项必填)")
}
}
}
return nil
}
func validateAttendanceClassCreate(cmd *cobra.Command) error {
raw := attFlagString(cmd, "class-vo", "TopAtClassVO")
if raw == "" {
return nil
}
var vo map[string]any
if err := json.Unmarshal([]byte(raw), &vo); err != nil {
return nil
}
if _, ok := vo["sections"]; !ok {
return apperrors.NewValidation("missing required field: sections(班次时段,--class-vo 内必填)")
}
return nil
}
func validateAttendanceGroupCreate(cmd *cobra.Command) error {
typ := strings.TrimSpace(attFlagString(cmd, "type"))
if typ != "" && !attendanceGroupTypes[typ] {
return apperrors.NewValidation("考勤组类型不合法:--type 应为 FIXED / TURN / NONE 之一")
}
if typ == "FIXED" {
var vo map[string]any
if raw := attFlagString(cmd, "group-vo", "groupVO"); raw != "" {
_ = json.Unmarshal([]byte(raw), &vo)
}
if vo == nil {
vo = map[string]any{}
}
if _, ok := vo["workDayClassList"]; !ok {
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 workDayClassList(工作日班次列表)")
}
if _, ok := vo["defaultClassId"]; !ok {
return apperrors.NewValidation("type=FIXED 时 --group-vo 内必填 defaultClassId(默认班次 ID)")
}
}
return nil
}
func validateAttendanceGroupUpdate(cmd *cobra.Command) error {
if v := strings.TrimSpace(attFlagString(cmd, "enable-outside-check")); v != "" && v != "true" && v != "false" {
return apperrors.NewValidation("--enable-outside-check must be true or false")
}
for _, f := range []string{"name", "type", "owner", "enable-outside-check", "classIds", "group-vo"} {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
return nil
}
}
return apperrors.NewValidation("至少需要指定一个修改项(--name / --type / --owner / --enable-outside-check / --classIds / --group-vo)")
}
func validateAttendanceUpdateMembers(cmd *cobra.Command) error {
for _, f := range []string{"add-users", "remove-users", "add-extra-users", "remove-extra-users", "add-depts", "remove-depts"} {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
return nil
}
}
return apperrors.NewValidation("至少需要指定一个变更项(--add-users / --remove-users / --add-extra-users / --remove-extra-users / --add-depts / --remove-depts)")
}
func validateAttendanceSelfSettingSave(cmd *cobra.Command) error {
hasField := false
cmd.Flags().Visit(func(f *pflag.Flag) {
switch f.Name {
case "setting-scene", "user", "yes", "format", "debug", "verbose", "dry-run",
"client-id", "client-secret", "fields", "jq", "mock", "timeout":
// control / identity flags, not setting fields
default:
hasField = true
}
})
if !hasField {
return apperrors.NewValidation("至少需要指定一个设置项(--setting-scene / --user 之外的任一字段)")
}
return nil
}
func validateAttendanceApproveTemplates(cmd *cobra.Command) error {
typ := strings.TrimSpace(attFlagString(cmd, "type"))
if typ != "" && !attendanceApproveTypes[typ] {
return apperrors.NewValidation("无效的审批类型:--type 应为 overtime / leave / patch / trip(travel) / business_trip 之一")
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// calendarRecurrenceTools are the calendar leaves whose recurrence fields must
// be supplied as a complete set (the MCP backend does not merge partial
// recurrence, so a partial update would silently overwrite the rule). Mirrors
// wukong's calendar.go event create/update validation.
var calendarRecurrenceTools = map[string]bool{
"create_calendar_event": true,
"update_calendar_event": true,
}
// calendarRecurrenceFlags is the full set of --recurrence-* flags; touching any
// of them requires the core structural fields to be present.
var calendarRecurrenceFlags = []string{
"recurrence-type", "recurrence-interval", "recurrence-range-type",
"recurrence-count", "recurrence-end-date", "recurrence-days-of-week",
"recurrence-day-of-month", "recurrence-month", "recurrence-week-index",
"recurrence-first-day-of-week",
}
// installCalendarHook wires calendar-specific PreRunE validators onto leaf
// commands emitted by BuildDynamicCommands. No-op for non-calendar products and
// calendar tools without extra client-side checks. The hook chain preserves the
// PreRunE that NewDirectCommand already installed by invoking it first.
func installCalendarHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil || strings.TrimSpace(canonicalProduct) != "calendar" {
return
}
if !calendarRecurrenceTools[toolName] {
return
}
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return validateCalendarRecurrence(c)
}
}
// validateCalendarRecurrence refuses a partial recurrence structure. If any
// --recurrence-* flag is set, recurrence-type / interval / range-type must be
// present, and weekly / relativeMonthly patterns require days-of-week. Error
// wording carries the kebab flag names so the messages match wukong and the
// auto-test substring assertions (days-of-week / recurrence-type).
func validateCalendarRecurrence(cmd *cobra.Command) error {
if cmd == nil {
return nil
}
used := false
for _, f := range calendarRecurrenceFlags {
if fl := cmd.Flags().Lookup(f); fl != nil && cmd.Flags().Changed(f) {
used = true
break
}
}
if !used {
return nil
}
recType := strings.TrimSpace(calendarFlagString(cmd, "recurrence-type"))
if recType == "" {
return apperrors.NewValidation(
"recurrence 结构不完整:使用任一 --recurrence-* 时必须整体重传完整循环字段" +
"(至少 --recurrence-type / --recurrence-interval / --recurrence-range-type," +
"MCP 不合并部分字段)")
}
if !calendarFlagSet(cmd, "recurrence-interval") {
return apperrors.NewValidation(
"recurrence 结构不完整:缺少 --recurrence-interval(循环间隔,recurrence 整体必填)")
}
if !calendarFlagSet(cmd, "recurrence-range-type") {
return apperrors.NewValidation(
"recurrence 结构不完整:缺少 --recurrence-range-type(循环范围类型,recurrence 整体必填)")
}
if recType == "weekly" || recType == "relativeMonthly" {
if strings.TrimSpace(calendarFlagString(cmd, "recurrence-days-of-week")) == "" {
return apperrors.NewValidation(
"weekly / relativeMonthly 循环必须提供 --recurrence-days-of-week (daysOfWeek)")
}
}
return nil
}
func calendarFlagString(cmd *cobra.Command, name string) string {
if cmd.Flags().Lookup(name) == nil {
return ""
}
v, _ := cmd.Flags().GetString(name)
return v
}
// calendarFlagSet reports whether a flag was explicitly provided by the user,
// tolerating both string and int (--recurrence-interval) flag kinds.
func calendarFlagSet(cmd *cobra.Command, name string) bool {
fl := cmd.Flags().Lookup(name)
if fl == nil {
return false
}
return cmd.Flags().Changed(name)
}
+94 -1
View File
@@ -51,8 +51,16 @@ var runtimeDefaultWhitelist = map[string]bool{
// detailsByID maps CLI server ID → []DetailTool from the MCP Detail API.
// When provided, tool Short/Long descriptions and typed flags are enriched from Detail API data.
//
// existingTools maps CLI server ID (slug) → set of tool names that server
// actually exposes (from the live tools/list cache). When a server's set is
// present and non-empty, override leaves whose backing MCP tool is missing from
// it are hidden from `--help` (phantom-command guard). When the set is absent or
// empty (cold cache, or a server we have no tools snapshot for) the guard does
// nothing for that server, so an unpopulated cache never blanks the command
// tree. Pass nil to disable the guard entirely.
//
// Conversion rules reference: docs/mcp-to-cli-conversion.md
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool) []*cobra.Command {
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool, existingTools map[string]map[string]struct{}) []*cobra.Command {
type builtCmd struct {
cmd *cobra.Command
parent string // cli.Parent: attach as sub-command of this top-level command
@@ -222,6 +230,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
cmd := NewDirectCommand(route, runner)
// §guard.toolexists: keep `--help` honest under envelope/deployment
// drift. When we know the resolved server's live tool set and it does
// NOT contain this leaf's backing tool, the command is a phantom
// (renders in help but fails at invocation with "tool not found"), so
// hide it. Safety rails:
// - only acts when the set is KNOWN and non-empty (absent/empty =
// unknown = keep; a cold tools cache must never blank the tree);
// - skips pipeline leaves, which orchestrate multiple tools and have
// no single backing toolName to check;
// - Hidden (not removed) so the command stays invocable for anyone
// who calls it directly — it just leaves the help surface.
if len(override.Pipeline) == 0 && existingTools != nil {
if known, ok := existingTools[canonicalProduct]; ok && len(known) > 0 {
if _, exists := known[toolName]; !exists {
cmd.Hidden = true
}
}
}
// Enrich flags with typed parameters from Detail API toolRequest JSON Schema.
if dt, ok := detailIndex[toolName]; ok && dt.ToolRequest != "" {
buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags)
@@ -239,6 +266,20 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
// todo_hooks.go for the full rationale). No-op for non-todo.
installTodoHook(cmd, canonicalProduct, toolName)
// §calendar-hook: reject partial recurrence on event create/update
// (see calendar_hooks.go). No-op for non-calendar.
installCalendarHook(cmd, canonicalProduct, toolName)
// §attendance-hook: inner-JSON required fields, group type and
// FIXED conditional checks (see attendance_hooks.go). No-op for
// non-attendance.
installAttendanceHook(cmd, canonicalProduct, toolName)
// §report-hook: native --contents-file / --contents - (stdin)
// resolution + one-of(contents, contents-file) relaxation (see
// report_hooks.go). No-op for non-report.
installReportHook(cmd, canonicalProduct, toolName)
// §1.4: Add to the right parent group
attachToGroup(rootCmd, override.Group, groupCmds, cmd)
}
@@ -305,9 +346,61 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
for _, name := range topOrder {
commands = append(commands, topLevel[name])
}
// §guard.emptygroups: a group whose every leaf is hidden would still show in
// help as an empty heading (e.g. attendance `vacation`/`overtime` once their
// tools are gone). Collapse those. This runs unconditionally because leaves
// get hidden by TWO independent mechanisms — the runtime tool-existence
// guard above AND envelope `hidden:true` overrides — and an envelope-emptied
// group must collapse even when the guard is inert (cold tools cache). It is
// safe regardless of cache state: hideEmptyGroups only ever hides a group all
// of whose children are already hidden; it never hides a leaf, so it cannot
// blank a tree on its own.
for _, c := range commands {
// Collapse empty sub-groups within each product, but never the product
// root itself: a root can legitimately be empty at this point and gain
// visible leaves later from helper/overlay merges, so hiding it here
// could wrongly drop a whole product from `dws --help`.
for _, sub := range c.Commands() {
hideEmptyGroups(sub)
}
}
return commands
}
// hideEmptyGroups recursively hides group commands whose every subcommand is
// hidden — the collateral of the tool-existence guard emptying a group of all
// its leaves. Returns true if cmd is (now) hidden. A command with no
// subcommands is a leaf: its own Hidden flag is returned unchanged. A group is
// only newly hidden when it HAS subcommands and they are ALL hidden, so a group
// retaining at least one visible leaf always stays visible.
func hideEmptyGroups(cmd *cobra.Command) bool {
subs := cmd.Commands()
if len(subs) == 0 {
// No children. A real leaf stands on its own Hidden flag. A group
// container with no children is empty — this happens when every
// override in a group is `hidden:true` (those leaves are never built,
// leaving the group childless) — so hide it. Group containers and leaves
// both have a RunE, so cobra's Runnable() can't tell them apart; the
// group annotation can.
if cmdutil.IsGroup(cmd) {
cmd.Hidden = true
return true
}
return cmd.Hidden
}
allHidden := true
for _, sub := range subs {
if !hideEmptyGroups(sub) {
allHidden = false
}
}
if allHidden {
cmd.Hidden = true
}
return cmd.Hidden
}
// buildDetailIndex creates a map from toolName → DetailTool for fast lookup.
func buildDetailIndex(tools []market.DetailTool) map[string]market.DetailTool {
idx := make(map[string]market.DetailTool, len(tools))
+38 -38
View File
@@ -77,7 +77,7 @@ func TestBuildDynamicCommands_ParentNesting(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
// Should produce only one top-level command: "chat"
if len(cmds) != 1 {
@@ -130,7 +130,7 @@ func TestBuildDynamicCommands_ParentNotFound(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
// Parent not found, should fall back to top-level
if len(cmds) != 1 {
@@ -169,7 +169,7 @@ func TestBuildDynamicCommands_ShorthandFlag(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("expected 1 cmd, got %d", len(cmds))
}
@@ -212,7 +212,7 @@ func TestBuildDynamicCommands_RequiredFlag(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -253,7 +253,7 @@ func TestBuildDynamicCommands_RequiredIgnoredWhenPositional(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -292,7 +292,7 @@ func TestBuildDynamicCommands_PositionalArg(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -347,7 +347,7 @@ func TestBuildDynamicCommands_PositionalArgInjection(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -399,7 +399,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
},
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
article := findChild(cmds[0], "article")
if article == nil {
t.Fatal("article group not found")
@@ -552,7 +552,7 @@ func TestBuildDynamicCommands_PositionalArityMixed(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "do")
if leaf == nil {
t.Fatal("do leaf not found")
@@ -606,7 +606,7 @@ func TestBuildDynamicCommands_ServerOverride(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
leaf := findChild(cmds[0], "bot-list")
if leaf == nil {
t.Fatal("bot-list leaf not found")
@@ -641,7 +641,7 @@ func TestBuildDynamicCommands_ServerOverrideFallback(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
leaf := findChild(cmds[0], "list")
if leaf == nil {
t.Fatal("list leaf not found")
@@ -680,7 +680,7 @@ func TestBuildDynamicCommands_DescriptionOverridesUsage(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -732,7 +732,7 @@ func TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -775,7 +775,7 @@ func TestBuildDynamicCommands_BodyWrapper(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
create := findChild(cmds[0], "create")
if create == nil {
t.Fatal("create leaf not found")
@@ -858,7 +858,7 @@ func TestBuildDynamicCommands_MutuallyExclusive(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
cmds[0].SetArgs([]string{"list", "--group", "g1", "--user", "u1"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -901,7 +901,7 @@ func TestBuildDynamicCommands_RequireOneOf(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
cmds[0].SetArgs([]string{"list"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -940,7 +940,7 @@ func TestBuildDynamicCommands_RequireOneOfSatisfied(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"list", "--group", "g1"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -974,7 +974,7 @@ func TestBuildDynamicCommands_RedirectTo(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
history := findChild(cmds[0], "history")
if history == nil {
t.Fatal("history stub not found")
@@ -1029,7 +1029,7 @@ func TestBuildDynamicCommands_Hints(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
root := cmds[0]
// history hint attached directly under root.
@@ -1094,7 +1094,7 @@ func TestBuildDynamicCommands_UnknownFlagConstraintSkipped(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found (constraint validation must not abort build)")
@@ -1135,7 +1135,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimarySet(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--query", "hello"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1193,7 +1193,7 @@ func TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--keyword", "hi"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1234,7 +1234,7 @@ func TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet(t *testin
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1275,7 +1275,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet(t *testing.
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--query", "primary", "--keyword", "fallback"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1316,7 +1316,7 @@ func TestBuildDynamicCommands_MultipleAliases_MultiAliasChain(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"get", "--user-ids", "u1,u2"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1383,7 +1383,7 @@ func TestBuildDynamicCommands_MultipleAliases_Dedup(t *testing.T) {
// If ApplyBindings panics (duplicate pflag) we fail. Otherwise the cmd
// should build and execute fine.
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--keyword", "ok"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1421,7 +1421,7 @@ func TestBuildDynamicCommands_NoParent(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 2 {
t.Fatalf("expected 2 top-level commands, got %d", len(cmds))
@@ -1457,7 +1457,7 @@ func TestBuildDynamicCommands_ExampleField(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
approval := findChild(cmds[0], "approval")
if approval == nil {
t.Fatal("approval group not found")
@@ -1503,7 +1503,7 @@ func TestApplyBindings_VisibleFlagDefault_String(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "list-forms")
if leaf == nil {
t.Fatal("list-forms leaf not found")
@@ -1548,7 +1548,7 @@ func TestApplyBindings_VisibleFlagDefault_Int(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "list-forms")
if leaf == nil {
t.Fatal("list-forms leaf not found")
@@ -1786,7 +1786,7 @@ func TestBuildDynamicCommands_ParentMergeSameName(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 || cmds[0].Name() != "chat" {
t.Fatalf("expected single top-level 'chat', got %d cmds", len(cmds))
}
@@ -1860,7 +1860,7 @@ func TestBuildDynamicCommands_ParentMergeRecursive(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 || cmds[0].Name() != "chat" {
t.Fatalf("expected single top-level 'chat', got %d", len(cmds))
}
@@ -1934,7 +1934,7 @@ func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("expected 1 top-level, got %d", len(cmds))
}
@@ -1987,7 +1987,7 @@ func TestBuildFlagsFromDetailSchema_FormatEnumAnnotations(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found")
@@ -2052,7 +2052,7 @@ func TestBuildDynamicCommands_MapsTo_WithoutTransform(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "# 标题"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2107,7 +2107,7 @@ func TestBuildDynamicCommands_MapsTo_WithFileReadTransform(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content-file", path})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2157,7 +2157,7 @@ func TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "literal body"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2207,7 +2207,7 @@ func TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "x", "--content-file", "/tmp/y"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
+257
View File
@@ -0,0 +1,257 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
// findLeaf returns the first leaf command with the given Use anywhere under
// root (depth-first), or nil.
func findLeaf(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
if got := findLeaf(c, name); got != nil {
return got
}
}
return nil
}
func toolSet(names ...string) map[string]struct{} {
s := make(map[string]struct{}, len(names))
for _, n := range names {
s[n] = struct{}{}
}
return s
}
// attendanceLike builds one server with a real tool and a phantom tool, the
// exact shape of the production drift (e.g. attendance: only a handful of the
// declared overrides map to deployed tools).
func attendanceLike() []market.ServerDescriptor {
return []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
ToolOverrides: map[string]market.CLIToolOverride{
"get_attendance_summary": {CLIName: "summary"}, // real
"get_overtime_rule": {CLIName: "overtime"}, // phantom
},
},
},
}
}
// TestPhantomGuard_HidesWhenToolSetKnown is the core behaviour: when the live
// tool set is known and non-empty, a leaf whose backing tool is absent is
// hidden from --help while the real leaf stays visible.
func TestPhantomGuard_HidesWhenToolSetKnown(t *testing.T) {
t.Parallel()
existing := map[string]map[string]struct{}{
"attendance": toolSet("get_attendance_summary"), // overtime is NOT deployed
}
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, existing)
summary := findLeaf(cmds[0], "summary")
overtime := findLeaf(cmds[0], "overtime")
if summary == nil || overtime == nil {
t.Fatalf("both leaves should still be registered (invocable); summary=%v overtime=%v", summary, overtime)
}
if summary.Hidden {
t.Error("real command 'summary' must stay visible in --help")
}
if !overtime.Hidden {
t.Error("phantom command 'overtime' must be hidden from --help")
}
}
// TestPhantomGuard_ColdCacheKeepsEverything is the safety rail that the prior
// (source-blind) plan got wrong: with no tool set available (nil map), the
// guard must do nothing — never blank the command tree on a cold cache.
func TestPhantomGuard_ColdCacheKeepsEverything(t *testing.T) {
t.Parallel()
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, nil)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("cold cache (nil existingTools) must not hide %q", name)
}
}
}
// TestPhantomGuard_EmptyOrAbsentSetKeepsEverything: an empty set for a server,
// or a server missing from the map entirely, both mean "unknown" — keep all.
func TestPhantomGuard_EmptyOrAbsentSetKeepsEverything(t *testing.T) {
t.Parallel()
cases := []struct {
name string
existing map[string]map[string]struct{}
}{
{"empty set for server", map[string]map[string]struct{}{"attendance": {}}},
{"server absent from map", map[string]map[string]struct{}{"someother": toolSet("x")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, tc.existing)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("%s: must not hide %q when tool set is unknown", tc.name, name)
}
}
})
}
}
// TestPhantomGuard_ServerOverrideRoutesToTargetSet: a leaf with serverOverride
// must be checked against the TARGET server's tool set, not the host's. This is
// what prevents false-flagging legit cross-server routes (contact→hrmregister,
// doc→doc-comment).
func TestPhantomGuard_ServerOverrideRoutesToTargetSet(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-contact",
CLI: market.CLIOverlay{
ID: "contact",
Command: "contact",
ToolOverrides: map[string]market.CLIToolOverride{
// routed to hrmregister; the tool lives there, not in contact
"get_roster": {CLIName: "roster", ServerOverride: "hrmregister"},
},
},
},
}
// contact's own set is empty of get_roster, but hrmregister has it.
existing := map[string]map[string]struct{}{
"contact": toolSet("search_user"),
"hrmregister": toolSet("get_roster"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
roster := findLeaf(cmds[0], "roster")
if roster == nil {
t.Fatal("roster leaf should be registered")
}
if roster.Hidden {
t.Error("serverOverride leaf must resolve against the target server's set and stay visible")
}
}
// TestPhantomGuard_EmptyGroupCollapses: a group all of whose overrides are
// hidden:true (so none of its leaves are built) must itself be hidden from
// help, while a group keeping at least one visible leaf stays. This runs
// regardless of the tools-cache oracle (envelope hidden:true is cache-
// independent), so existingTools is nil here.
func TestPhantomGuard_EmptyGroupCollapses(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
Groups: map[string]market.CLIGroupDef{
"vacation": {Description: "假期管理"}, // all leaves hidden -> collapse
"record": {Description: "考勤记录"}, // keeps a visible leaf
},
ToolOverrides: map[string]market.CLIToolOverride{
"get_leave_types": {CLIName: "types", Group: "vacation", Hidden: true},
"get_leave_balance_quota": {CLIName: "balance", Group: "vacation", Hidden: true},
"get_user_attendance_record": {CLIName: "get", Group: "record"},
},
},
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
vacation := findGroup(cmds[0], "vacation")
record := findGroup(cmds[0], "record")
if vacation == nil || record == nil {
t.Fatalf("both groups should exist as commands; vacation=%v record=%v", vacation, record)
}
if !vacation.Hidden {
t.Error("group 'vacation' with only hidden leaves must collapse (be hidden)")
}
if record.Hidden {
t.Error("group 'record' with a visible leaf must stay visible")
}
}
// findGroup returns a direct child of root with the given name (groups attach
// directly under the product root).
func findGroup(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
// TestPhantomGuard_PipelineLeafNeverHidden: pipeline leaves orchestrate multiple
// tools and have no single backing toolName, so the guard must skip them even
// when the override key is not a deployed tool.
func TestPhantomGuard_PipelineLeafNeverHidden(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-im",
CLI: market.CLIOverlay{
ID: "im",
Command: "im",
ToolOverrides: map[string]market.CLIToolOverride{
"download_media": {
CLIName: "download-media",
Pipeline: []market.PipelineStep{
{Tool: "get_resource_download_url"},
},
},
},
},
},
}
// download_media itself is not a deployed tool name, but the pipeline is.
existing := map[string]map[string]struct{}{
"im": toolSet("get_resource_download_url"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
dl := findLeaf(cmds[0], "download-media")
if dl == nil {
t.Fatal("download-media leaf should be registered")
}
if dl.Hidden {
t.Error("pipeline leaf must never be hidden by the tool-existence guard")
}
}
+12 -3
View File
@@ -336,7 +336,16 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
Response: resp,
}
if route.OutputTransform != nil && result.Response != nil {
result.Response = route.OutputTransform(result.Response)
// Shape the MCP content payload (the actual data), not the
// {endpoint, content} runtime envelope, so rename/drop/table
// paths resolve against response fields (e.g. result.items)
// rather than the wrapper. Falls back to the whole Response
// when no content map is present (degraded/echo paths).
if content, ok := result.Response["content"].(map[string]any); ok {
result.Response["content"] = route.OutputTransform(content)
} else {
result.Response = route.OutputTransform(result.Response)
}
}
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
}
@@ -742,7 +751,7 @@ func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[s
"json": true, "params": true, "help": true,
"format": true, "fields": true, "jq": true,
"debug": true, "verbose": true, "dry-run": true,
"yes": true, "mock": true, "timeout": true,
"yes": true, "mock": true, "profile": true, "timeout": true,
"client-id": true, "client-secret": true,
}
@@ -862,7 +871,7 @@ func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[st
if _, ok := existing[binding.Property]; ok {
continue
}
return nil, apperrors.NewValidation(fmt.Sprintf("--%s is required", primaryName))
return nil, apperrors.NewValidation(fmt.Sprintf("missing required flag: --%s is required", primaryName))
}
if !anyChanged {
continue
+2 -2
View File
@@ -83,7 +83,7 @@ func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
}
// Must not panic; the command must build and stay executable.
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
}
@@ -120,7 +120,7 @@ func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
boom, _, err := cmds[0].Find([]string{"boom"})
if err != nil {
t.Fatalf("find boom: %v", err)
+3 -1
View File
@@ -288,6 +288,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
cmd.Flags().Bool("verbose", false, "Verbose")
cmd.Flags().Bool("dry-run", false, "Dry run")
cmd.Flags().String("format", "json", "Format")
cmd.Flags().String("profile", "", "Profile")
cmd.Flags().String("json", "", "")
cmd.Flags().String("params", "", "")
@@ -296,6 +297,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
_ = cmd.Flags().Set("verbose", "true")
_ = cmd.Flags().Set("dry-run", "true")
_ = cmd.Flags().Set("format", "table")
_ = cmd.Flags().Set("profile", "corp_profile")
params := make(map[string]any)
collectSchemaFlags(cmd, nil, params)
@@ -304,7 +306,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
t.Errorf("name = %v, want Bob", params["name"])
}
// Global flags should be skipped
for _, skip := range []string{"debug", "verbose", "dry_run", "format"} {
for _, skip := range []string{"debug", "verbose", "dry_run", "format", "profile"} {
if _, exists := params[skip]; exists {
t.Errorf("%s should be skipped (global flag)", skip)
}
+186
View File
@@ -0,0 +1,186 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// report_hooks.go — CLI-side input resolution for the `report` product.
//
// The envelope publishes `report entry submit` (MCP tool create_report) with a
// `--contents` flag (json_parse, required) and a sibling `--contents-file`
// flag (omitWhen empty, no transform/mapsTo). On its own, `--contents-file`
// therefore goes nowhere: its value maps to the unused `contentsFile` param and
// the real `contents` param stays empty, so a `--contents-file`-only (or
// `--contents -` stdin) submit silently sends `contents: [null]` and the report
// fails. The literal-only `--contents` path works, which is why
// `report create` (the helper, inline-only) succeeds while
// `report entry submit --contents-file` does not.
//
// The wukong reference implementation reads the file/stdin natively inside its
// hand-written cobra RunE (dws-wukong/wukong/products/report.go
// resolveReportContentsFromFlags, priority: --contents-file > --contents -
// (stdin) > --contents '<json>'). The open-source CLI is envelope-driven, so we
// attach the equivalent native resolution as a build-time hook here, mirroring
// AttachReportListReadableEnrichment (which layers wukong-equivalent list
// enrichment onto the same envelope leaves). No discovery-config change is
// needed: the hook populates the real `--contents` flag before the envelope's
// json_parse transform runs, and the broken `contentsFile` override is left
// inert.
//
// Two build-time adjustments make `--contents-file`-only valid:
//
// 1. The envelope marks `--contents` individually required (cobra
// MarkFlagRequired, enforced at parse time, before PreRunE). We clear that
// annotation and instead declare a `contents` / `contents-file` one-of
// group (MarkFlagsOneRequired, validated by ValidateFlagGroups — also
// before PreRunE, but satisfied when either flag is set). Supplying
// neither still errors, now naming both flags.
// 2. A chained PreRunE resolves the chosen source into `--contents` so the
// downstream json_parse transform sees inline JSON regardless of origin.
package compat
import (
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// reportContentsMaxBytes caps the contents payload at 10MB, matching the
// wukong upstream limit (dws-wukong/wukong/products/report.go
// reportContentsMaxBytes). Oversized input is rejected rather than truncated.
const reportContentsMaxBytes = 10 * 1024 * 1024
// reportToolsWithContentsFile lists every report toolName whose `--contents` /
// `--contents-file` pair needs native file/stdin resolution. Today only
// create_report (the `report entry submit` leaf) carries the pair.
var reportToolsWithContentsFile = map[string]bool{
"create_report": true,
}
// installReportHook wires report-specific input resolution onto leaf commands
// emitted by BuildDynamicCommands. It is a no-op for non-report products and
// for report tools that do not expose the contents/contents-file pair.
//
// The hook chain preserves the cmd.PreRunE that NewDirectCommand already
// installed (currently validateRequireTogether) by invoking it first.
func installReportHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil {
return
}
if strings.TrimSpace(canonicalProduct) != "report" {
return
}
if !reportToolsWithContentsFile[toolName] {
return
}
contents := cmd.Flags().Lookup("contents")
file := cmd.Flags().Lookup("contents-file")
if contents == nil || file == nil {
// Envelope shape changed (renamed/removed flags) — do not block the
// command; leave whatever the envelope declared untouched.
return
}
// (1) Relax the individually-required `--contents` into a one-of group so
// `--contents-file`-only (or `--contents -`) is accepted. Clearing the
// required annotation must happen before parse-time ValidateRequiredFlags;
// this hook runs at build time, so it does.
if contents.Annotations != nil {
delete(contents.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.MarkFlagsOneRequired("contents", "contents-file")
// (2) Resolve the chosen source into --contents before the RunE transform.
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return resolveReportContents(c)
}
}
// resolveReportContents applies the wukong source priority — `--contents-file`
// (file) > `--contents -` (stdin) > `--contents '<json>'` (literal) — and
// writes the resolved JSON string back into the `--contents` flag so the
// downstream json_parse transform decodes it uniformly. When a file or stdin
// source is used, `--contents-file` is cleared so the envelope's omitWhen:empty
// drops the now-redundant param.
func resolveReportContents(cmd *cobra.Command) error {
filePath, _ := cmd.Flags().GetString("contents-file")
if strings.TrimSpace(filePath) != "" {
data, err := readReportContentsFile(filePath)
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from --contents-file")
}
_ = cmd.Flags().Set("contents-file", "")
return nil
}
raw, _ := cmd.Flags().GetString("contents")
if strings.TrimSpace(raw) == "-" {
data, err := readReportContentsLimited(cmd.InOrStdin(), "--contents -")
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from stdin")
}
}
return nil
}
// readReportContentsFile opens a file path and reads its contents under the
// 10MB cap and UTF-8 check. Error wording mirrors wukong so agents and humans
// see a stable message across both editions.
func readReportContentsFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return "", apperrors.NewValidation(
fmt.Sprintf("--contents-file: file not found: %s", path),
apperrors.WithHint("确认路径存在,且指向一个 JSON 文件"),
)
}
return "", apperrors.NewValidation(fmt.Sprintf("--contents-file: cannot read %s: %v", path, err))
}
defer file.Close()
return readReportContentsLimited(file, fmt.Sprintf("--contents-file %s", path))
}
// readReportContentsLimited reads from r enforcing the 10MB cap and UTF-8
// validity. A LimitReader at cap+1 detects overflow without reading unbounded.
func readReportContentsLimited(r io.Reader, source string) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, int64(reportContentsMaxBytes)+1))
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("%s: read failed: %v", source, err))
}
if len(data) > reportContentsMaxBytes {
return "", apperrors.NewValidation(
fmt.Sprintf("%s: contents exceed maximum size of 10MB", source),
apperrors.WithHint("精简内容或拆分为多份日志提交"),
)
}
if !utf8.Valid(data) {
return "", apperrors.NewValidation(fmt.Sprintf("%s: not valid UTF-8", source))
}
return string(data), nil
}
+198
View File
@@ -0,0 +1,198 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newReportSubmitStub mirrors the leaf command shape emitted by
// BuildDynamicCommands for `report entry submit` (envelope: create_report).
// Only the flags the hook touches are registered. --contents is marked
// required to reproduce the envelope's MarkFlagRequired so the relaxation
// behaviour can be asserted.
func newReportSubmitStub() *cobra.Command {
cmd := &cobra.Command{Use: "submit", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("contents", "", "contents JSON array")
cmd.Flags().String("contents-file", "", "contents JSON file")
cmd.Flags().String("template-id", "", "template id")
_ = cmd.MarkFlagRequired("contents")
return cmd
}
const reportContentsPayload = `[{"key":"今日完成工作","sort":"0","content":"done","contentType":"markdown","type":"1"}]`
func TestResolveReportContents_FromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(file): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from file: %q", got)
}
// contents-file must be cleared so omitWhen:empty drops the dead param.
if cf, _ := cmd.Flags().GetString("contents-file"); cf != "" {
t.Fatalf("--contents-file should be cleared after resolution, got %q", cf)
}
}
func TestResolveReportContents_FromStdin(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", "-"); err != nil {
t.Fatal(err)
}
cmd.SetIn(strings.NewReader(reportContentsPayload))
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(stdin): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from stdin: %q", got)
}
}
func TestResolveReportContents_InlineUntouched(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(inline): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("inline --contents must be left untouched, got %q", got)
}
}
func TestResolveReportContents_FilePriorityOverInline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatal(err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", `[{"stale":"inline"}]`); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents: %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents-file must win over inline --contents, got %q", got)
}
}
func TestResolveReportContents_MissingFileErrors(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", filepath.Join(t.TempDir(), "nope.json")); err != nil {
t.Fatal(err)
}
err := resolveReportContents(cmd)
if err == nil {
t.Fatal("expected error for missing --contents-file path")
}
if !strings.Contains(err.Error(), "file not found") {
t.Fatalf("unexpected error: %v", err)
}
}
// ── installReportHook composition ──────────────────────────────
func TestInstallReportHook_RelaxesRequiredToOneOf(t *testing.T) {
cmd := newReportSubmitStub()
// Before the hook, --contents carries the cobra required annotation.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("precondition: --contents should start out required")
}
installReportHook(cmd, "report", "create_report")
// After the hook, the individual required annotation must be cleared so a
// --contents-file-only invocation is not rejected at parse time.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] != nil {
t.Fatal("installReportHook should clear the individual required on --contents")
}
// And a PreRunE must now be installed to resolve the source.
if cmd.PreRunE == nil {
t.Fatal("installReportHook should install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherProduct(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "chat", "create_report")
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("non-report product must not touch required annotation")
}
if cmd.PreRunE != nil {
t.Fatal("non-report product must not install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherReportTool(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "report", "get_received_report_list")
if cmd.PreRunE != nil {
t.Fatal("non-target report tool must not install a PreRunE")
}
}
func TestInstallReportHook_ChainsExistingPreRunE(t *testing.T) {
cmd := newReportSubmitStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
installReportHook(cmd, "report", "create_report")
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE was dropped")
}
}
func TestInstallReportHook_BailsIfChainedPreRunEFails(t *testing.T) {
cmd := newReportSubmitStub()
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
installReportHook(cmd, "report", "create_report")
err := cmd.PreRunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "original boom") {
t.Fatalf("expected original PreRunE error to bubble, got %v", err)
}
}
func TestInstallReportHook_NilCmdSafe(t *testing.T) {
installReportHook(nil, "report", "create_report")
}
+121 -3
View File
@@ -30,7 +30,7 @@ import (
// ApplyTransform applies a named transform rule to a value.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
// json_parse_strict, enum_map, file_read, invert_bool, string_to_int64.
// json_parse_strict, enum_map, file_read, invert_bool, parse_bool, string_to_int64.
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
switch strings.TrimSpace(transform) {
case "":
@@ -49,6 +49,10 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
return transformFileRead(value)
case "invert_bool":
return transformInvertBool(value)
case "parse_bool":
return transformParseBool(value)
case "attendance_class_check_time":
return transformAttendanceClassCheckTime(value)
case "string_to_int64":
return transformStringToInt64(value)
default:
@@ -79,6 +83,77 @@ func transformInvertBool(value any) (any, error) {
}
}
// transformParseBool coerces a CLI string flag into a real JSON boolean so the
// MCP body carries `false`/`true` (not the string "false"/"true" or a swallowed
// zero value). Used by envelope flags that are semantically boolean but must be
// declared as string flags to accept an explicit `false` on the command line
// (cobra bool flags drop the space-form value). Unknown tokens pass through
// unchanged so upstream validators own the error wording.
func transformParseBool(value any) (any, error) {
switch v := value.(type) {
case bool:
return v, nil
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on":
return true, nil
case "false", "0", "no", "off":
return false, nil
}
return value, nil
default:
return value, nil
}
}
// transformAttendanceClassCheckTime parses a class-VO JSON string and converts
// every "HH:mm" checkTime under sections[*].times[*] and
// setting.topRestTimeList[*] into a Unix-millis number (1970-01-01 HH:mm in
// UTC+8), mirroring wukong's convertClassCheckTime. The MCP backend expects the
// numeric form; the envelope cannot express this nested walk, so it lives here.
func transformAttendanceClassCheckTime(value any) (any, error) {
parsed, err := transformJSONParseStrict(value)
if err != nil {
return nil, err
}
classVO, ok := parsed.(map[string]any)
if !ok {
return parsed, nil
}
cst := time.FixedZone("CST", 8*3600)
convertOne := func(obj map[string]any) {
if ct, ok := obj["checkTime"].(string); ok {
ct = strings.TrimSpace(ct)
if t, err := time.ParseInLocation("2006-01-02 15:04", "1970-01-01 "+ct, cst); err == nil {
obj["checkTime"] = float64(t.UnixMilli())
}
}
}
if sections, ok := classVO["sections"].([]any); ok {
for _, sec := range sections {
if secMap, ok := sec.(map[string]any); ok {
if times, ok := secMap["times"].([]any); ok {
for _, t := range times {
if tMap, ok := t.(map[string]any); ok {
convertOne(tMap)
}
}
}
}
}
}
if setting, ok := classVO["setting"].(map[string]any); ok {
if restList, ok := setting["topRestTimeList"].([]any); ok {
for _, item := range restList {
if itemMap, ok := item.(map[string]any); ok {
convertOne(itemMap)
}
}
}
}
return classVO, nil
}
func transformISO8601ToMillis(value any) (any, error) {
s, ok := toString(value)
if !ok {
@@ -168,6 +243,18 @@ func transformJSONParse(value any) (any, error) {
if s == "" {
return value, nil
}
// @file / @- expansion — read the JSON/YAML payload from a file or stdin
// before parsing. A leading "@" is an unambiguous file sentinel because a
// JSON/YAML value never starts with "@"; this is what the error hint below
// promises and lets long/complex payloads (many records, big cell ranges)
// avoid shell-quoting hell.
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
// Strict JSON first — fast path and unambiguous type promotion (numbers
// stay numbers, etc.).
var parsed any
@@ -182,10 +269,32 @@ func transformJSONParse(value any) (any, error) {
return nil, apperrors.NewValidation(
"json_parse: input is not valid JSON or YAML; " +
"quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " +
"or pass `@path/to/file.json` to read from a file",
"or pass `@path/to/file.json` (or `@-` for stdin) to read from a file",
)
}
// resolveJSONSource expands an @file / @- reference used by the json_parse
// transforms. A leading "@" is the file sentinel: "@-" reads stdin, "@<path>"
// reads the file (UTF-8, via transformFileRead). Any value not starting with
// "@" is returned unchanged. JSON/YAML payloads never start with "@", so this
// is unambiguous for structured flags.
func resolveJSONSource(s string) (string, error) {
if !strings.HasPrefix(s, "@") {
return s, nil
}
ref := strings.TrimSpace(s[1:])
if ref == "" {
return "", apperrors.NewValidation(
"json_parse: `@` must be followed by a file path, or `@-` to read from stdin")
}
out, err := transformFileRead(ref)
if err != nil {
return "", err
}
loaded, _ := out.(string)
return strings.TrimSpace(loaded), nil
}
// transformJSONParseStrict is the strict variant of json_parse: only accepts
// well-formed JSON, rejecting input that the YAML fallback would otherwise
// silently coerce to a scalar string. Use when the upstream tool requires a
@@ -199,12 +308,21 @@ func transformJSONParseStrict(value any) (any, error) {
if s == "" {
return value, nil
}
// @file / @- expansion — same sentinel as json_parse (see resolveJSONSource).
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return nil, apperrors.NewValidation(
"json_parse_strict: input is not valid JSON; " +
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
"as strict JSON (e.g. '[{\"key\":\"value\"}]') or use `json_parse` for YAML-tolerant parsing",
"as strict JSON (e.g. '[{\"key\":\"value\"}]'), pass `@path/to/file.json` " +
"(or `@-` for stdin), or use `json_parse` for YAML-tolerant parsing",
)
}
return parsed, nil
+59
View File
@@ -127,6 +127,65 @@ func TestJSONParse_InvalidInput(t *testing.T) {
}
}
func TestJSONParse_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "payload.json")
if err := os.WriteFile(path, []byte(`[{"k":"长内容\n多行","n":1}]`), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse", nil)
if err != nil {
t.Fatalf("json_parse @file: %v", err)
}
arr, ok := got.([]any)
if !ok || len(arr) != 1 {
t.Fatalf("expected 1-element array from @file, got %T %v", got, got)
}
item := arr[0].(map[string]any)
if item["k"] != "长内容\n多行" {
t.Fatalf("@file content mismatch: %v", item)
}
}
func TestJSONParse_AtFileMissing(t *testing.T) {
if _, err := ApplyTransform("@"+filepath.Join(t.TempDir(), "nope.json"), "json_parse", nil); err == nil {
t.Fatal("json_parse @missing-file should error")
}
}
func TestJSONParse_BareAtErrors(t *testing.T) {
_, err := ApplyTransform("@", "json_parse", nil)
if err == nil {
t.Fatal("bare @ should error (needs a path or -)")
}
}
func TestJSONParseStrict_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "p.json")
if err := os.WriteFile(path, []byte(`{"a":[1,2,3]}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse_strict", nil)
if err != nil {
t.Fatalf("json_parse_strict @file: %v", err)
}
if _, ok := got.(map[string]any); !ok {
t.Fatalf("expected object, got %T", got)
}
}
func TestJSONParse_AtPassthroughNonAt(t *testing.T) {
// A value not starting with "@" must be parsed inline, untouched.
got, err := ApplyTransform(`[{"x":1}]`, "json_parse", nil)
if err != nil {
t.Fatalf("inline json_parse: %v", err)
}
if arr, ok := got.([]any); !ok || len(arr) != 1 {
t.Fatalf("inline parse regressed: %T %v", got, got)
}
}
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
// read in full and surfaced as a string value. This is the contract the
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
+2 -2
View File
@@ -13,13 +13,13 @@ import (
)
// newTestMCPServer returns an httptest.Server that handles both market registry
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/bamboo
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/cedar
// succeeds, and mcpOK controls whether initialize+tools/list succeed.
func newTestMCPServer(t *testing.T, marketOK, mcpOK bool) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
if !marketOK {
http.Error(w, "market unavailable", http.StatusInternalServerError)
return
+1 -1
View File
@@ -259,7 +259,7 @@ func newDocsMCPGateway(expectations []docsServerExpectation) *httptest.Server {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
mux.HandleFunc("/cli/discovery/apis/bamboo", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
+49 -3
View File
@@ -110,6 +110,7 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
newAitableFieldCreateCommand(runner),
newAitableFieldUpdateCommand(runner),
newAitableFieldDeleteCommand(runner),
newAitableFieldSearchOptionsCommand(runner),
newAitableFieldListAlias(runner),
)
@@ -129,7 +130,13 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
newAitableRecordCreateCommand(runner),
newAitableRecordUpdateCommand(runner),
newAitableRecordBatchUpdateCommand(runner),
newAitableRecordQueryEmptyCommand(runner),
newAitableRecordDeleteCommand(runner),
newAitableRecordHistoryListCommand(runner),
newAitableRecordShareURLCommand(runner),
newAitableRecordUpsertCommand(runner),
newAitableRecordPrimaryDocGetCommand(runner),
newAitableRecordPrimaryDocCreateCommand(runner),
newAitableRecordListAlias(runner),
)
@@ -251,6 +258,7 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
newAitableDashboardUpdateCommand(runner),
newAitableDashboardDeleteCommand(runner),
newAitableDashboardConfigExampleCommand(runner),
newAitableDashboardArrangeCommand(runner),
dashboardShare,
)
@@ -264,15 +272,53 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
viewGet := newAitableViewGetCommand(runner)
viewGet.AddCommand(
newAitableViewGetCardCommand(runner),
newAitableViewGetTimebarCommand(runner),
newAitableViewGetAggregateCommand(runner),
newAitableViewGetFilterCommand(runner),
newAitableViewGetSortCommand(runner),
newAitableViewGetGroupCommand(runner),
newAitableViewGetVisibleFieldsCommand(runner),
newAitableViewGetFieldWidthsCommand(runner),
newAitableViewGetLockCommand(runner),
newAitableViewGetFrozenColsCommand(runner),
newAitableViewGetRowHeightCommand(runner),
newAitableViewGetFillColorRuleCommand(runner),
)
viewUpdate := newAitableViewUpdateCommand(runner)
viewUpdate.AddCommand(
newAitableViewUpdateCardCommand(runner),
newAitableViewUpdateTimebarCommand(runner),
newAitableViewUpdateAggregateCommand(runner),
newAitableViewUpdateFieldWidthsCommand(runner),
newAitableViewUpdateVisibleFieldsCommand(runner),
newAitableViewUpdateFilterCommand(runner),
newAitableViewUpdateSortCommand(runner),
newAitableViewUpdateGroupCommand(runner),
newAitableViewUpdateNameCommand(runner),
newAitableViewUpdateFrozenColsCommand(runner),
newAitableViewUpdateRowHeightCommand(runner),
newAitableViewUpdateFillColorRuleCommand(runner),
)
view.AddCommand(
newAitableViewGetCommand(runner),
viewGet,
newAitableViewListCommand(runner),
newAitableViewCreateCommand(runner),
newAitableViewUpdateCommand(runner),
viewUpdate,
newAitableViewDeleteCommand(runner),
newAitableViewLockCommand(runner),
newAitableViewDuplicateCommand(runner),
)
root.AddCommand(base, table, field, record, newAitableFormCommand(runner), template, attachment, export, importCmd, dashboard, chart, view)
root.AddCommand(
base, table, field, record, newAitableFormCommand(runner),
newAitableWorkflowCommand(runner),
template, attachment, export, importCmd, dashboard, chart, view,
newAitableAdvpermCommand(runner),
newAitableSectionCommand(runner),
)
// 顶层别名:dws aitable search/list/create/info → base search/list/create/get
// 每个 alias 复用现有 constructor,独立 cobra.Command 实例(避免与 base.* 共享 flag 指针)
+51 -10
View File
@@ -130,7 +130,33 @@ func newAitableBaseCreateCommand(runner executor.Runner) *cobra.Command {
if folderID := aitableStringFlag(cmd, "folder-id"); folderID != "" {
params["folderId"] = folderID
}
return runAitableTool(cmd, runner, "create_base", params)
// dry-run 或带模板:保持单步 create_base 语义。
if commandDryRun(cmd) {
return runAitableTool(cmd, runner, "create_base", params)
}
result, err := runAitableProductToolResult(cmd, runner, "aitable", "create_base", params)
if err != nil {
return err
}
// 默认表由服务端按 scenario 决定:wukong 场景建、openClaw 不建。
// 为与 wukong 行为对齐,无模板时在 CLI 侧兜底补建一张默认表,
// 使新 base 立即可用(含 tableId)。输出仍保持原 create_base 结果,
// 不把内部补建步骤暴露给调用方。
if _, hasTemplate := params["templateId"]; !hasTemplate {
if baseID := findStringDeep(result.Response, "baseId", "baseID"); baseID != "" {
if _, terr := runAitableProductToolResult(cmd, runner, "aitable", "create_table", map[string]any{
"baseId": baseID,
"tableName": "表格1",
"fields": []any{map[string]any{
"fieldName": "标题",
"type": "text",
}},
}); terr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: created base %s but default table creation failed: %v\n", baseID, terr)
}
}
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
@@ -1440,6 +1466,9 @@ func newAitableViewUpdateCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return err
}
if err := normalizeAitableViewConfigBlock(cmd, config); err != nil {
return err
}
params["config"] = config
}
if _, hasName := params["newViewName"]; !hasName {
@@ -1499,6 +1528,14 @@ func runAitableFormTool(cmd *cobra.Command, runner executor.Runner, tool string,
}
func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
result, err := runAitableProductToolResult(cmd, runner, product, tool, params)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func runAitableProductToolResult(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) (executor.Result, error) {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
product,
@@ -1509,9 +1546,9 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
if invocation.DryRun {
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
return executor.Result{}, err
}
return writeCommandPayload(cmd, result)
return result, nil
}
var lastErr error
@@ -1523,7 +1560,7 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
select {
case <-cmd.Context().Done():
timer.Stop()
return cmd.Context().Err()
return executor.Result{}, cmd.Context().Err()
case <-timer.C:
}
}
@@ -1531,16 +1568,16 @@ func runAitableProductTool(cmd *cobra.Command, runner executor.Runner, product,
result, err := runner.Run(cmd.Context(), invocation)
lastErr = err
if err == nil {
return writeCommandPayload(cmd, result)
return result, nil
}
if !aitableErrorRetryable(err) {
return err
return executor.Result{}, err
}
}
if lastErr != nil {
return lastErr
return executor.Result{}, lastErr
}
return nil
return executor.Result{}, nil
}
const aitableHelperMaxRetries = 3
@@ -1906,11 +1943,15 @@ func normalizeAitableSort(items []any) []any {
}
func parseAitableJSONArray(raw, flagName string) ([]any, error) {
var value []any
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--%s JSON parse failed: %v", flagName, err))
}
return value, nil
arr, ok := value.([]any)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("--%s must be a JSON array / 数组, got %T", flagName, value))
}
return arr, nil
}
func parseAitableJSONObject(raw, flagName string) (map[string]any, error) {
+52
View File
@@ -1393,3 +1393,55 @@ func singleAitableRecord(t *testing.T, value any) map[string]any {
}
return record
}
// base create 无模板时,CLI 兜底补建一张默认表(与 wukong 场景行为对齐:
// 服务端对 openClaw 不建默认表,CLI 侧补齐使新 base 立即含 tableId)。
func TestAitableBaseCreateAddsDefaultTableWithoutTemplate(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"data": map[string]any{"baseId": "BASE_001"}},
{"data": map[string]any{"tableId": "TABLE_001"}},
}}
cmd := newAitableBaseCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--name", "项目跟踪"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 2 {
t.Fatalf("calls = %d, want create_base + create_table", len(runner.calls))
}
if got := runner.calls[0].Tool; got != "create_base" {
t.Fatalf("first tool = %q, want create_base", got)
}
if got := runner.calls[1].Tool; got != "create_table" {
t.Fatalf("second tool = %q, want create_table", got)
}
if got := runner.calls[1].Params["baseId"]; got != "BASE_001" {
t.Fatalf("create_table baseId = %#v, want BASE_001", got)
}
}
// base create 带 --template-id 时不补建默认表(模板自带结构)。
func TestAitableBaseCreateWithTemplateSkipsDefaultTable(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"data": map[string]any{"baseId": "BASE_001"}},
}}
cmd := newAitableBaseCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--name", "项目跟踪", "--template-id", "TPL_001"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 1 {
t.Fatalf("calls = %d, want only create_base", len(runner.calls))
}
if got := runner.calls[0].Tool; got != "create_base" {
t.Fatalf("tool = %q, want create_base", got)
}
}
File diff suppressed because it is too large Load Diff
+420
View File
@@ -0,0 +1,420 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func executeAitableExtraCommand(t *testing.T, cmd *cobra.Command, args ...string) {
t.Helper()
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
}
type aitableSequencedRunner struct {
calls []executor.Invocation
responses []map[string]any
}
func (r *aitableSequencedRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls = append(r.calls, invocation)
var response map[string]any
if idx := len(r.calls) - 1; idx >= 0 && idx < len(r.responses) {
response = r.responses[idx]
}
return executor.Result{Invocation: invocation, Response: response}, nil
}
func TestAitableFieldSearchOptionsRoutesToAitable(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableFieldSearchOptionsCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--field-id", "FIELD_001",
"--keyword", "已",
"--limit", "10",
)
if got := runner.last.CanonicalProduct; got != "aitable" {
t.Fatalf("CanonicalProduct = %q, want aitable", got)
}
if got := runner.last.Tool; got != "search_field_options" {
t.Fatalf("Tool = %q, want search_field_options", got)
}
if got := runner.last.Params["keyword"]; got != "已" {
t.Fatalf("keyword = %#v, want 已", got)
}
if got := runner.last.Params["limit"]; got != 10 {
t.Fatalf("limit = %#v, want 10", got)
}
}
func TestAitableViewGetFieldWidthsProjectsCustomWidthMap(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{{
"content": map[string]any{
"status": "success",
"data": map[string]any{"views": []any{map[string]any{
"viewId": "VIEW_001",
"viewType": "Grid",
"custom": map[string]any{
"widthMap": map[string]any{"FIELD_001": 240},
},
}}},
},
}}}
cmd := newAitableViewGetFieldWidthsCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("output JSON parse error = %v\nstdout:\n%s", err, out.String())
}
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("data = %#v, want object", payload["data"])
}
if got := data["FIELD_001"]; got != float64(240) {
t.Fatalf("FIELD_001 width = %#v, want 240", got)
}
}
func TestAitableViewUpdateCardDispatchesGalleryConfig(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"content": map[string]any{
"status": "success",
"data": map[string]any{"views": []any{map[string]any{
"viewId": "VIEW_001",
"viewType": "Gallery",
}}},
}},
{"content": map[string]any{"status": "success"}},
}}
cmd := newAitableViewUpdateCardCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--cover-mode", "custom",
"--cover-field-id", "FIELD_001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 2 {
t.Fatalf("calls = %d, want 2", len(runner.calls))
}
update := runner.calls[1]
if update.Tool != "update_view" {
t.Fatalf("second tool = %q, want update_view", update.Tool)
}
config, ok := update.Params["config"].(map[string]any)
if !ok {
t.Fatalf("config = %#v, want object", update.Params["config"])
}
card, ok := config["galleryCard"].(map[string]any)
if !ok {
t.Fatalf("galleryCard = %#v, want object", config["galleryCard"])
}
if got := card["coverMode"]; got != "custom" {
t.Fatalf("coverMode = %#v, want custom", got)
}
}
func TestAitableViewUpdateConfigRoutedKeyHintsSubcommand(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--config", `{"frozenColCount":2}`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if !strings.Contains(errOut.String(), "frozen-cols") {
t.Fatalf("stderr missing frozen-cols hint:\n%s", errOut.String())
}
}
func TestAitableRecordHistoryListRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordHistoryListCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--record-id", "REC_001",
"--offset", "10",
"--limit", "30",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "query_record_history" {
t.Fatalf("Tool = %q, want query_record_history", got)
}
if got := runner.last.Params["recordId"]; got != "REC_001" {
t.Fatalf("recordId = %#v, want REC_001", got)
}
if got := runner.last.Params["offset"]; got != 10 {
t.Fatalf("offset = %#v, want 10", got)
}
if got := runner.last.Params["limit"]; got != 30 {
t.Fatalf("limit = %#v, want 30", got)
}
}
func TestAitableRecordUpsertAcceptsFieldsAlias(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordUpsertCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--fields", `[{"recordId":"REC_001","cells":{"fld":"updated"}},{"cells":{"fld":"new"}}]`,
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "record_upsert" {
t.Fatalf("Tool = %q, want record_upsert", got)
}
records, ok := runner.last.Params["records"].([]any)
if !ok {
t.Fatalf("records type = %T, want []any", runner.last.Params["records"])
}
if len(records) != 2 {
t.Fatalf("records len = %d, want 2", len(records))
}
}
func TestAitableViewExtraCommandsRouteToExpectedTools(t *testing.T) {
t.Parallel()
t.Run("lock unlock", func(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewLockCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--off",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "lock_or_unlock_view" {
t.Fatalf("Tool = %q, want lock_or_unlock_view", got)
}
if got := runner.last.Params["action"]; got != "unlock" {
t.Fatalf("action = %#v, want unlock", got)
}
})
t.Run("fill color rule", func(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewUpdateFillColorRuleCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--json", `[]`,
)
if got := runner.last.CanonicalProduct; got != "aitable" {
t.Fatalf("CanonicalProduct = %q, want aitable", got)
}
if got := runner.last.Tool; got != "set_view_fill_color_rule" {
t.Fatalf("Tool = %q, want set_view_fill_color_rule", got)
}
if formats, ok := runner.last.Params["conditionalFormats"].([]any); !ok || len(formats) != 0 {
t.Fatalf("conditionalFormats = %#v, want empty []any", runner.last.Params["conditionalFormats"])
}
})
}
func TestAitableWorkflowListRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableWorkflowListCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--limit", "50",
"--offset", "100",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "list_workflows" {
t.Fatalf("Tool = %q, want list_workflows", got)
}
if got := runner.last.Params["limit"]; got != 50 {
t.Fatalf("limit = %#v, want 50", got)
}
if got := runner.last.Params["offset"]; got != 100 {
t.Fatalf("offset = %#v, want 100", got)
}
}
func TestAitableRecordQueryEmptyRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordQueryEmptyCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--limit", "50",
"--cursor", "CUR_001",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "query_empty_records" {
t.Fatalf("Tool = %q, want query_empty_records", got)
}
if got := runner.last.Params["limit"]; got != 50 {
t.Fatalf("limit = %#v, want 50", got)
}
if got := runner.last.Params["cursor"]; got != "CUR_001" {
t.Fatalf("cursor = %#v, want CUR_001", got)
}
}
func TestAitableRecordQueryEmptyRejectsOutOfRangeLimit(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordQueryEmptyCommand(runner)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--limit", "200",
})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error for --limit 200, got nil")
}
if runner.last.Tool != "" {
t.Fatalf("runner should not be called on invalid limit, got tool %q", runner.last.Tool)
}
}
func TestAitableDashboardArrangeRoutesToHelper(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableDashboardArrangeCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--dashboard-id", "DASH_001",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "align_dashboard" {
t.Fatalf("Tool = %q, want align_dashboard", got)
}
if got := runner.last.Params["dashboardId"]; got != "DASH_001" {
t.Fatalf("dashboardId = %#v, want DASH_001", got)
}
}
func TestAitableAdvpermRoleCreateParsesSubRoles(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableAdvpermRoleCreateCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--name", "市场可读",
"--sub-roles", `[{"targetId":"TABLE_001","targetType":"sheet","authLevel":"read"}]`,
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "create_role" {
t.Fatalf("Tool = %q, want create_role", got)
}
subRoles, ok := runner.last.Params["subRoles"].([]any)
if !ok || len(subRoles) != 1 {
t.Fatalf("subRoles = %#v, want single-item []any", runner.last.Params["subRoles"])
}
}
func TestAitableSectionMoveNodeAllowsRootParent(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableSectionMoveNodeCommand(runner)
executeAitableExtraCommand(t, cmd,
"--base-id", "BASE_001",
"--node-id", "NODE_001",
"--new-parent-section-id", "",
"--target-index", "0",
)
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
}
if got := runner.last.Tool; got != "move_nsheet_node" {
t.Fatalf("Tool = %q, want move_nsheet_node", got)
}
if got := runner.last.Params["newParentSectionId"]; got != "" {
t.Fatalf("newParentSectionId = %#v, want empty string", got)
}
if got := runner.last.Params["targetIndex"]; got != 0 {
t.Fatalf("targetIndex = %#v, want 0", got)
}
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler { return calendarHandler{} })
}
// calendarHandler contributes the `calendar attendee list|add|delete` group.
// wukong renamed the calendar participant commands to "attendee" (former name:
// participant); the envelope still exposes them under "participant". These
// leaves call the same MCP tools (get/add/remove_calendar_participant) so the
// wukong command surface is aligned without dropping the legacy participant
// path. MergeCommandTree folds the attendee group into the calendar tree.
type calendarHandler struct{}
func (calendarHandler) Name() string { return "calendar" }
func (calendarHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "calendar",
Short: i18n.T("日历"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
attendee := &cobra.Command{
Use: "attendee",
Short: i18n.T("参会人管理(与 participant 等价,对齐 wukong 命名)"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
attendee.AddCommand(
newCalendarAttendeeListCommand(runner),
newCalendarAttendeeAddCommand(runner),
newCalendarAttendeeDeleteCommand(runner),
)
root.AddCommand(attendee)
return root
}
func newCalendarAttendeeListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list", Short: i18n.T("查询日程参会人"),
Example: " dws calendar attendee list --event <eventId>", Args: cobra.NoArgs, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
if eventID == "" {
return apperrors.NewValidation("missing required flag(s): --event")
}
params := map[string]any{"eventId": eventID}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
params["calendarId"] = v
}
return runCalendarTool(cmd, runner, "get_calendar_participants", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选, 默认主日历)"))
return cmd
}
func newCalendarAttendeeAddCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add", Short: i18n.T("添加日程参会人"),
Example: " dws calendar attendee add --event <eventId> --users userId1,userId2", Args: cobra.NoArgs, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
users := strings.TrimSpace(firstNonEmptyFlag(cmd, "users", "attendees", "user-ids"))
if eventID == "" {
return apperrors.NewValidation("missing required flag(s): --event")
}
if users == "" {
return apperrors.NewValidation("missing required flag(s): --users")
}
params := map[string]any{"eventId": eventID, "attendeesToAdd": csvToList(users)}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "optional")); v != "" {
params["optional"] = v
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
params["calendarId"] = v
}
return runCalendarTool(cmd, runner, "add_calendar_participant", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
cmd.Flags().String("users", "", i18n.T("参会人 userId 列表,逗号分隔 (必填)"))
cmd.Flags().String("optional", "", i18n.T("是否可选参会人 (可选)"))
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选)"))
return cmd
}
func newCalendarAttendeeDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete", Short: i18n.T("移除日程参会人"),
Example: " dws calendar attendee delete --event <eventId> --users userId1", Args: cobra.NoArgs, DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
eventID := strings.TrimSpace(firstNonEmptyFlag(cmd, "event", "event-id"))
users := strings.TrimSpace(firstNonEmptyFlag(cmd, "users", "attendees", "user-ids"))
if eventID == "" {
return apperrors.NewValidation("missing required flag(s): --event")
}
if users == "" {
return apperrors.NewValidation("missing required flag(s): --users")
}
params := map[string]any{"eventId": eventID, "attendeesToRemove": csvToList(users)}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "calendar-id", "calendarId")); v != "" {
params["calendarId"] = v
}
return runCalendarTool(cmd, runner, "remove_calendar_participant", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("event", "", i18n.T("日程 eventId (必填)"))
cmd.Flags().String("users", "", i18n.T("参会人 userId 列表,逗号分隔 (必填)"))
cmd.Flags().String("calendar-id", "", i18n.T("日历 ID (可选)"))
return cmd
}
func runCalendarTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "calendar", tool, params)
if commandDryRun(cmd) {
return writeCommandPayload(cmd, inv)
}
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func csvToList(s string) []any {
parts := strings.Split(s, ",")
out := make([]any, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+7 -6
View File
@@ -121,7 +121,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
newChatBotSearchCommand(runner),
)
root.AddCommand(message, group, bot)
root.AddCommand(message, group, bot, newChatFileGroup(runner))
return root
}
@@ -334,13 +334,14 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("file-type", "", "文件类型/扩展名 (msg-type=file)")
cmd.Flags().String("file-path", "", "文件展示路径 (msg-type=file)")
cmd.Flags().Int64("file-size", 0, "文件大小,单位字节 (msg-type=file)")
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才在消息下方显示 AI 发送角标)")
cmd.Flags().Bool("ai-tag", true, "标记为「通过AI发送」角标,默认带上(透明标识 AI/CLI 代发);仅当 --ai-tag=false 时不带角标(按本人发送)")
return cmd
}
// attachAITag 仅在用户显式传入 --ai-tag 时,给发送参数加上 clawType,
// 由 IM 服务端据此渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。
// 默认不带:是否标记 AI 发送交由用户自行选择,不强加。
// attachAITag 在 --ai-tag 为真时给发送参数加上 clawType,由 IM 服务端据此
// 渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。--ai-tag 默认 true:
// 经 dws/agent 代发的消息默认带角标以透明标识 AI/CLI 代发,仅当用户显式
// 传 --ai-tag=false 时才不带 (按本人发送)。
func attachAITag(cmd *cobra.Command, params map[string]any) {
if on, _ := cmd.Flags().GetBool("ai-tag"); on {
params["clawType"] = edition.ClawType()
@@ -1017,7 +1018,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
cmd.Flags().String("text", "", "回复正文 (必填)")
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才显示 AI 发送角标)")
cmd.Flags().Bool("ai-tag", true, "标记为「通过AI发送」角标,默认带上(透明标识 AI/CLI 代发);仅当 --ai-tag=false 时不带角标(按本人发送)")
return cmd
}
+206
View File
@@ -0,0 +1,206 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"os"
"path/filepath"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
// newChatFileGroup builds `dws chat file upload`. wukong implements it as a
// multi-step upload to the conversation file space: for --file it does
// init_conversation_file_upload (im) -> HTTP PUT -> commit_conversation_file_upload
// (im); for --url it calls upload_conversation_file_by_url (chat). The envelope
// cannot express the local pipeline, so it lives here. Wired into the chat
// handler (see chat.go).
func newChatFileGroup(runner executor.Runner) *cobra.Command {
file := &cobra.Command{
Use: "file",
Short: "会话文件上传",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
file.AddCommand(newChatFileUploadCommand(runner))
return file
}
func newChatFileUploadCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件或 URL 文件到会话文件空间",
Example: " dws chat file upload --group <openConversationId> --file ./report.pdf\n" +
" dws chat file upload --user <userId> --url https://example.com/a.pdf --file-name a.pdf",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
target, err := chatConversationTargetArgs(cmd)
if err != nil {
return err
}
filePath := strings.TrimSpace(firstNonEmptyFlag(cmd, "file", "file-path"))
fileURL := strings.TrimSpace(firstNonEmptyFlag(cmd, "url"))
if filePath == "" && fileURL == "" {
return apperrors.NewValidation("--file or --url is required")
}
if filePath != "" && fileURL != "" {
return apperrors.NewValidation("--file and --url are mutually exclusive")
}
fileName := strings.TrimSpace(firstNonEmptyFlag(cmd, "file-name"))
md5v := strings.TrimSpace(firstNonEmptyFlag(cmd, "md5"))
uuid := strings.TrimSpace(firstNonEmptyFlag(cmd, "uuid"))
// URL path: server pulls the file (chat server).
if fileURL != "" {
if fileName == "" {
fileName = filepath.Base(fileURL)
}
params := cloneStringAnyMap(target)
params["fileUrl"] = fileURL
params["fileName"] = fileName
if md5v != "" {
params["md5"] = md5v
}
if uuid != "" {
params["uuid"] = uuid
}
inv := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), "chat", "upload_conversation_file_by_url", params)
if commandDryRun(cmd) {
return writeCommandPayload(cmd, inv)
}
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
// Local path: init (im) -> HTTP PUT -> commit (im).
fi, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation("cannot read file " + filePath + ": " + err.Error())
}
if fi.IsDir() {
return apperrors.NewValidation(filePath + " is a directory, not a file")
}
if fileName == "" {
fileName = filepath.Base(filePath)
}
fileSize := fi.Size()
if md5v == "" {
if md5v, err = fileMD5Hex(filePath); err != nil {
return err
}
}
if commandDryRun(cmd) {
preview := cloneStringAnyMap(target)
preview["fileName"] = fileName
preview["fileSize"] = fileSize
preview["md5"] = md5v
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", preview))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
initParams := cloneStringAnyMap(target)
initParams["fileName"] = fileName
initParams["fileSize"] = fileSize
initParams["md5"] = md5v
initRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "init_conversation_file_upload", initParams))
if err != nil {
return err
}
resourceURL := findStringDeep(initRes.Response, "resourceUrl", "resourceURL", "url")
if resourceURL == "" {
resourceURL = findFirstInStringArrayDeep(initRes.Response, "resourceUrls", "resourceURLs")
}
uploadKey := findStringDeep(initRes.Response, "uploadKey", "key")
if resourceURL == "" || uploadKey == "" {
return apperrors.NewAPI("incomplete upload credentials: resourceUrl=" + resourceURL + " uploadKey=" + uploadKey)
}
headers := findHeadersDeep(initRes.Response, "headers", "ossHeaders")
if err := httpPutLocalFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
commitParams := cloneStringAnyMap(target)
commitParams["uploadKey"] = uploadKey
commitParams["fileName"] = fileName
commitParams["fileSize"] = fileSize
commitParams["md5"] = md5v
if uuid != "" {
commitParams["uuid"] = uuid
}
commitRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "im", "commit_conversation_file_upload", commitParams))
if err != nil {
return err
}
return writeCommandPayload(cmd, commitRes)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)")
cmd.Flags().String("conversation-id", "", "--group 的别名")
cmd.Flags().String("id", "", "--group 的别名")
cmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)")
cmd.Flags().String("open-dingtalk-id", "", "单聊对方 openDingTalkId(单聊时使用)")
cmd.Flags().String("file", "", "本地文件路径(与 --url 二选一)")
cmd.Flags().String("file-path", "", "--file 的别名")
cmd.Flags().String("url", "", "远程文件 URL(与 --file 二选一,服务端代传)")
cmd.Flags().String("file-name", "", "文件名(可选)")
cmd.Flags().String("md5", "", "文件 MD5(可选,本地不传自动计算)")
cmd.Flags().String("uuid", "", "幂等 UUID(可选)")
return cmd
}
func chatConversationTargetArgs(cmd *cobra.Command) (map[string]any, error) {
group := strings.TrimSpace(firstNonEmptyFlag(cmd, "group", "conversation-id", "id"))
user := strings.TrimSpace(firstNonEmptyFlag(cmd, "user"))
openDingTalkID := strings.TrimSpace(firstNonEmptyFlag(cmd, "open-dingtalk-id"))
if group == "" && user == "" && openDingTalkID == "" {
return nil, apperrors.NewValidation("需指定会话目标:--group(群聊)或 --user / --open-dingtalk-id(单聊)之一")
}
m := map[string]any{}
if group != "" {
m["openConversationId"] = group
}
if user != "" {
m["userId"] = user
}
if openDingTalkID != "" {
m["openDingTalkId"] = openDingTalkID
}
return m, nil
}
func cloneStringAnyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in)+4)
for k, v := range in {
out[k] = v
}
return out
}
+24 -5
View File
@@ -354,8 +354,8 @@ func TestChatMessageAITagControlsClawType(t *testing.T) {
},
}
for _, tc := range cases {
// Default: no --ai-tag → must omit clawType entirely (no badge).
t.Run(tc.name+"/default-no-tag", func(t *testing.T) {
// Default: no --ai-tag → ai-tag defaults to true → must attach clawType.
t.Run(tc.name+"/default-has-tag", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(runner)
var out bytes.Buffer
@@ -365,11 +365,30 @@ func TestChatMessageAITagControlsClawType(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if v, ok := runner.last.Params["clawType"]; ok {
t.Fatalf("default send must omit clawType, got %#v", v)
got, ok := runner.last.Params["clawType"]
if !ok {
t.Fatalf("default send must attach clawType (ai-tag defaults true); got %#v", runner.last.Params)
}
if got != edition.DefaultOSSClawType {
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
}
})
// Opt-in: --ai-tag → attach the edition claw identity.
// Opt-out: --ai-tag=false → omit clawType entirely (no badge).
t.Run(tc.name+"/ai-tag-false", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(append(append([]string{}, tc.args...), "--ai-tag=false"))
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if v, ok := runner.last.Params["clawType"]; ok {
t.Fatalf("--ai-tag=false must omit clawType, got %#v", v)
}
})
// Opt-in (explicit): --ai-tag → attach the edition claw identity.
t.Run(tc.name+"/with-ai-tag", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(runner)
+124 -2
View File
@@ -85,6 +85,7 @@ func (docHandler) Command(runner executor.Runner) *cobra.Command {
newDocPermissionAddCommand(runner),
newDocPermissionUpdateCommand(runner),
newDocPermissionListCommand(runner),
newDocPermissionRemoveCommand(runner),
)
export := &cobra.Command{
@@ -174,6 +175,7 @@ func newDocSearchCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive search or dws wiki node search")
params := map[string]any{}
if keyword := docFlagOrFallback(cmd, "query", "keyword"); keyword != "" {
params["keyword"] = keyword
@@ -222,6 +224,7 @@ func newDocListCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive list or dws wiki node list")
params := map[string]any{}
if folder := docFlagOrFallback(cmd, "folder", "parent-id", "node", "file-id", "nodee"); folder != "" {
params["folderId"] = normalizeDocNodeID(folder)
@@ -274,6 +277,9 @@ func newDocReadCommand(runner executor.Runner) *cobra.Command {
if output := docStringFlag(cmd, "output"); output != "" {
params["__output__"] = output
}
if format == "jsonml" {
return runDocReadJSONML(cmd, runner, params)
}
return runDocTool(cmd, runner, "doc", "get_document_content", params)
},
}
@@ -814,6 +820,7 @@ func newDocFileCreateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "wiki node create")
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
@@ -850,6 +857,7 @@ func newDocFolderCreateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "wiki node create --type folder")
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
@@ -889,6 +897,7 @@ func newDocTransferCommand(runner executor.Runner, use, tool string) *cobra.Comm
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive "+use)
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
@@ -919,6 +928,7 @@ func newDocRenameCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive rename")
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
@@ -1467,6 +1477,56 @@ func docInvocationResult(cmd *cobra.Command, runner executor.Runner, product, to
return runner.Run(cmd.Context(), invocation)
}
func runDocReadJSONML(cmd *cobra.Command, runner executor.Runner, params map[string]any) error {
outputPath, _ := params["__output__"].(string)
result, err := docInvocationResult(cmd, runner, "doc", "get_document_content", params)
if err != nil {
return err
}
if !result.Invocation.Implemented {
return writeCommandPayload(cmd, result)
}
payload := normalizeDocReadJSONMLResult(result)
if outputPath != "" {
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSONML output: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return fmt.Errorf("failed to write output file %s: %w", outputPath, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "[INFO] JSONML 已写入 %s\n", outputPath)
return nil
}
return writeCommandPayload(cmd, payload)
}
func normalizeDocReadJSONMLResult(result executor.Result) map[string]any {
content := result.Response
if nested, ok := result.Response["content"].(map[string]any); ok {
content = nested
}
out := map[string]any{}
for k, v := range content {
if k == "content" {
continue
}
out[k] = v
}
if raw, ok := out["jsonml"].(string); ok {
var decoded any
if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
out["jsonml"] = decoded
}
}
if revision, ok := out["version"]; ok {
if _, exists := out["revision"]; !exists {
out["revision"] = revision
}
}
return out
}
func addDocNodeFlags(cmd *cobra.Command) {
cmd.Flags().String("node", "", i18n.T("文档 nodeId / URL"))
addDocHiddenStringFlag(cmd, "url", "--node alias")
@@ -2082,6 +2142,7 @@ func newDocPermissionAddCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive permission add")
return runDocPermissionMutation(cmd, runner, "add_permission")
},
}
@@ -2091,6 +2152,7 @@ func newDocPermissionAddCommand(runner executor.Runner) *cobra.Command {
addDocHiddenStringFlag(cmd, "users", "--user alias")
cmd.Flags().String("role", "", i18n.T("权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)"))
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填,辅助构造返回的 docUrl)"))
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
@@ -2108,6 +2170,7 @@ func newDocPermissionUpdateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive permission update")
return runDocPermissionMutation(cmd, runner, "update_permission")
},
}
@@ -2118,6 +2181,7 @@ func newDocPermissionUpdateCommand(runner executor.Runner) *cobra.Command {
addDocHiddenStringFlag(cmd, "uid", "--user alias")
cmd.Flags().String("role", "", i18n.T("新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)"))
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
@@ -2135,6 +2199,7 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive permission list")
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
@@ -2157,7 +2222,7 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
}
params["filterRoleIds"] = roles
}
if v, _ := cmd.Flags().GetString("workspace"); v != "" {
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
params["workspaceId"] = v
}
if commandDryRun(cmd) {
@@ -2183,6 +2248,59 @@ func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("filter-role", "", i18n.T("按角色过滤(逗号分隔): OWNER / MANAGER / EDITOR / DOWNLOADER / READER"))
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
func newDocPermissionRemoveCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: i18n.T("移除文档协作者权限"),
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
docDeprecatedNotice(cmd, "drive permission remove")
nodeID, err := docRequiredNode(cmd)
if err != nil {
return err
}
rawUsers := docFlagOrFallback(cmd, "users", "user", "uid")
if strings.TrimSpace(rawUsers) == "" {
return apperrors.NewValidation("--users is required")
}
userIDs, err := parseDocPermissionUsers(rawUsers)
if err != nil {
return err
}
params := map[string]any{
"nodeId": nodeID,
"userIds": userIDs,
}
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
params["workspaceId"] = v
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "remove_permission", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "remove_permission", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
addDocNodeFlags(cmd)
cmd.Flags().String("users", "", i18n.T("被移除用户 userId 列表,逗号分隔,单次最多 30 (必填)"))
addDocHiddenStringFlag(cmd, "user", "--users alias")
addDocHiddenStringFlag(cmd, "uid", "--users alias")
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
return cmd
}
@@ -2217,7 +2335,7 @@ func runDocPermissionMutation(cmd *cobra.Command, runner executor.Runner, mcpToo
"roleId": role,
"userIds": userIDs,
}
if v, _ := cmd.Flags().GetString("workspace"); v != "" {
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
params["workspaceId"] = v
}
if commandDryRun(cmd) {
@@ -2234,6 +2352,10 @@ func runDocPermissionMutation(cmd *cobra.Command, runner executor.Runner, mcpToo
return writeCommandPayload(cmd, result)
}
func docDeprecatedNotice(cmd *cobra.Command, replacement string) {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: deprecated: use dws %s instead.\n", replacement)
}
// TRANSITIONAL: 等 mse 把 delete_document 加入 doc toolOverrides(含
// destructive_hint: true)后,本 helper 可删除——CLI discovery 会自动
// 生成等价命令。工单:plan/mse-yuyuan-patch.md 改动 2.2。
+29
View File
@@ -96,6 +96,35 @@ func TestDocPermissionListLimitAliases(t *testing.T) {
}
}
func TestDocPermissionRemoveRoutesToRemovePermission(t *testing.T) {
t.Parallel()
runner := &docCommandRunner{}
cmd := newDocTestRoot(runner)
_, errOut, err := executeDocCommand(t, cmd,
"permission", "rm",
"--node", "NODE_001",
"--users", "uid1,uid2",
"--workspace", "WS_001",
)
if err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut)
}
if runner.last.Tool != "remove_permission" {
t.Fatalf("tool = %q, want remove_permission", runner.last.Tool)
}
if got := runner.last.Params["nodeId"]; got != "NODE_001" {
t.Fatalf("nodeId = %#v, want NODE_001", got)
}
users, ok := runner.last.Params["userIds"].([]string)
if !ok || strings.Join(users, ",") != "uid1,uid2" {
t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
}
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
t.Fatalf("workspaceId = %#v, want WS_001", got)
}
}
func TestDocPermissionListMaxresultsRejected(t *testing.T) {
t.Parallel()
+468
View File
@@ -79,6 +79,12 @@ func (driveHandler) Command(runner executor.Runner) *cobra.Command {
newDriveCommitCommand(runner),
newDriveUploadCommand(runner),
newDriveDeleteCommand(runner),
newDriveSearchCommand(runner),
newDriveCopyCommand(runner),
newDriveMoveCommand(runner),
newDriveRenameCommand(runner),
newDrivePermissionCommand(runner),
newDriveFolderCommand(runner),
)
return root
}
@@ -98,6 +104,17 @@ func newDriveListCommand(runner executor.Runner) *cobra.Command {
if maxResults <= 0 {
maxResults = 20
}
if workspaceID := driveFlagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
params := map[string]any{"workspaceId": workspaceID}
if folderID := driveFlagOrFallback(cmd, "folder", "parent-id"); folderID != "" {
params["folderId"] = normalizeDocNodeID(folderID)
}
if maxResults > 0 {
params["pageSize"] = maxResults
}
addDriveStringParam(cmd, params, "pageToken", "cursor", "next-token")
return runDriveInvocation(cmd, runner, "doc", "list_nodes", params)
}
params := map[string]any{"maxResults": float64(maxResults)}
addDriveStringParam(cmd, params, "spaceId", "space-id")
if parentID := driveFlagOrFallback(cmd, "folder", "parent-id"); parentID != "" {
@@ -124,6 +141,8 @@ func newDriveListCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("space-id", "", "空间 ID,不传则使用「我的文件」对应 spaceId (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则列出空间根目录 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
cmd.Flags().String("workspace", "", "文档空间/知识库 ID,传入则路由到文档空间")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
cmd.Flags().String("cursor", "", "分页游标,首次不传 (可选)")
addDriveHiddenStringFlag(cmd, "next-token", "--cursor 的兼容别名")
cmd.Flags().String("order-by", "", "排序字段: createTime|modifyTime|name (可选)")
@@ -147,6 +166,7 @@ spaceType 筛选规则:
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(cmd.ErrOrStderr(), "warning: deprecated: use dws wiki space list instead.")
params := map[string]any{}
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
@@ -380,6 +400,9 @@ func newDriveUploadCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("mime-type", "", "文件 MIME 类型,不传则自动推断 (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则上传到空间根目录 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
cmd.Flags().String("workspace", "", "目标知识库 ID,传入时路由到文档空间上传")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
cmd.Flags().Bool("convert", false, "是否转换为钉钉在线文档(文档空间上传时生效)")
return cmd
}
@@ -388,6 +411,9 @@ func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
if strings.TrimSpace(filePath) == "" {
return apperrors.NewValidation("--file is required")
}
if workspaceID := driveFlagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
return runDriveUploadToDoc(cmd, runner, workspaceID)
}
absPath, err := filepath.Abs(filePath)
if err != nil {
@@ -493,6 +519,93 @@ func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
return writeCommandPayload(cmd, result)
}
func runDriveUploadToDoc(cmd *cobra.Command, runner executor.Runner, workspaceID string) error {
filePath, _ := cmd.Flags().GetString("file")
absPath, err := filepath.Abs(filePath)
if err != nil {
return apperrors.NewValidation("无法解析文件路径: " + err.Error())
}
fi, err := os.Stat(absPath)
if err != nil {
return apperrors.NewValidation("文件不存在或无法读取: " + absPath)
}
if fi.IsDir() {
return apperrors.NewValidation("--file 不能是目录: " + absPath)
}
fileSize := fi.Size()
if fileSize <= 0 {
return apperrors.NewValidation("文件为空")
}
fileName := driveFlagOrFallback(cmd, "file-name", "name")
if fileName == "" {
fileName = filepath.Base(absPath)
}
folderID := driveFlagOrFallback(cmd, "folder", "parent-id")
if folderID != "" {
folderID = normalizeDocNodeID(folderID)
}
step1Params := map[string]any{"workspaceId": workspaceID}
if folderID != "" {
step1Params["folderId"] = folderID
}
commitParams := map[string]any{
"name": fileName,
"fileSize": float64(fileSize),
"workspaceId": workspaceID,
}
if folderID != "" {
commitParams["folderId"] = folderID
}
if convert, _ := cmd.Flags().GetBool("convert"); convert {
commitParams["convertToOnlineDoc"] = true
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, map[string]any{
"dry_run": true,
"step_1_get_file_upload_info": executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
),
"step_2_http_put_oss": "PUT file bytes to resourceUrl with returned headers",
"step_3_commit_uploaded_file": executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
),
"file": absPath,
"name": fileName,
"size": fileSize,
})
}
fmt.Fprintf(cmd.ErrOrStderr(), "[1/3] 获取文档空间上传凭证 %s (%d 字节)...\n", fileName, fileSize)
step1Result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
))
if err != nil {
return fmt.Errorf("获取上传凭证失败: %w", err)
}
resourceURL, uploadKey, headers, err := extractDocFileUploadInfo(step1Result.Response)
if err != nil {
return err
}
fmt.Fprintln(cmd.ErrOrStderr(), "[2/3] 上传文件到 OSS...")
if err := httpPutDriveFile(cmd.Context(), resourceURL, headers, absPath, fileSize); err != nil {
return err
}
fmt.Fprintln(cmd.ErrOrStderr(), "[3/3] 提交文件入库...")
commitParams["uploadKey"] = uploadKey
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
))
if err != nil {
return fmt.Errorf("提交文件入库失败: %w", err)
}
return writeCommandPayload(cmd, result)
}
// ── delete (drive surface routed to doc MCP server) ────────
func newDriveDeleteCommand(runner executor.Runner) *cobra.Command {
@@ -528,6 +641,303 @@ func newDriveDeleteCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func newDriveSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "搜索文件(聚合钉盘和文档空间)",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
keyword := driveFlagOrFallback(cmd, "query", "keyword")
if keyword == "" {
return apperrors.NewValidation("--query is required")
}
target := driveStringFlag(cmd, "target")
driveParams := map[string]any{"keyword": keyword}
if target != "" && target != "all" {
driveParams["searchTarget"] = target
}
addDriveStringSliceParam(cmd, driveParams, "fileTypes", "file-types")
addDriveStringSliceParam(cmd, driveParams, "extensions", "extensions")
addDriveStringSliceParam(cmd, driveParams, "creatorUserIds", "creator-uids")
addDriveInt64Param(cmd, driveParams, "createdTimeFrom", "created-from")
addDriveInt64Param(cmd, driveParams, "createdTimeTo", "created-to")
addDriveInt64Param(cmd, driveParams, "modifiedTimeFrom", "modified-from")
addDriveInt64Param(cmd, driveParams, "modifiedTimeTo", "modified-to")
if pageSize := driveIntFlagOrFallback(cmd, "limit", "page-size"); pageSize > 0 {
driveParams["pageSize"] = float64(pageSize)
}
addDriveStringParam(cmd, driveParams, "pageToken", "cursor", "page-token")
if target == "file" || target == "space" {
return runDriveInvocation(cmd, runner, "drive", "search_files", driveParams)
}
driveResult, driveErr := driveInvocationResult(cmd, runner, "drive", "search_files", driveParams)
docParams := map[string]any{"keyword": keyword}
if pageSize := driveIntFlagOrFallback(cmd, "limit", "page-size"); pageSize > 0 {
docParams["pageSize"] = pageSize
}
if extensions, ok := driveParams["extensions"]; ok {
docParams["extensions"] = extensions
}
docResult, docErr := driveInvocationResult(cmd, runner, "doc", "search_documents", docParams)
if driveErr != nil && docErr != nil {
return fmt.Errorf("aggregated search failed: drive: %v; doc: %v", driveErr, docErr)
}
result := map[string]any{}
if driveErr == nil {
result["drive_results"] = driveResult.Response
}
if docErr == nil {
result["doc_results"] = docResult.Response
}
return writeCommandPayload(cmd, map[string]any{"success": true, "result": result})
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("query", "", "搜索关键词 (必填)")
addDriveHiddenStringFlag(cmd, "keyword", "--query 的兼容别名")
cmd.Flags().String("target", "", "搜索范围: all(默认) / file / space")
cmd.Flags().StringSlice("file-types", nil, "按文件内容类型过滤")
cmd.Flags().StringSlice("extensions", nil, "按文件扩展名过滤")
cmd.Flags().StringSlice("creator-uids", nil, "按创建者 userId 过滤")
cmd.Flags().Int64("created-from", 0, "创建时间起始毫秒时间戳")
cmd.Flags().Int64("created-to", 0, "创建时间截止毫秒时间戳")
cmd.Flags().Int64("modified-from", 0, "修改时间起始毫秒时间戳")
cmd.Flags().Int64("modified-to", 0, "修改时间截止毫秒时间戳")
cmd.Flags().Int("limit", 0, "每页返回数量")
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("cursor", "", "分页游标")
addDriveHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
return cmd
}
func newDriveCopyCommand(runner executor.Runner) *cobra.Command {
return newDriveDocTransferCommand(runner, "copy", "copy_document")
}
func newDriveMoveCommand(runner executor.Runner) *cobra.Command {
return newDriveDocTransferCommand(runner, "move", "move_document")
}
func newDriveDocTransferCommand(runner executor.Runner, use, tool string) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: use + " 文档空间节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
params := map[string]any{"nodeId": normalizeDocNodeID(nodeID)}
if folder := driveFlagOrFallback(cmd, "folder", "parent-id", "parent-node-id", "parent-folder-id"); folder != "" {
params["targetFolderId"] = normalizeDocNodeID(folder)
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", tool, params)
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().String("folder", "", "目标文件夹 nodeId")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
addDriveHiddenStringFlag(cmd, "parent-node-id", "--folder 的兼容别名")
addDriveHiddenStringFlag(cmd, "parent-folder-id", "--folder 的兼容别名")
cmd.Flags().String("workspace", "", "目标知识库 ID")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
return cmd
}
func newDriveRenameCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "rename",
Short: "重命名文档空间节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
name, err := driveRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
}
return runDriveInvocation(cmd, runner, "doc", "rename_document", map[string]any{
"nodeId": normalizeDocNodeID(nodeID),
"newName": name,
})
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().String("name", "", "新名称 (必填)")
addDriveHiddenStringFlag(cmd, "title", "--name 的兼容别名")
return cmd
}
func newDrivePermissionCommand(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "permission",
Aliases: []string{"perm"},
Short: "文档空间节点权限管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
root.AddCommand(
newDrivePermissionMutationCommand(runner, "add", "add_permission", true),
newDrivePermissionMutationCommand(runner, "update", "update_permission", true),
newDrivePermissionListCommand(runner),
newDrivePermissionRemoveCommand(runner),
)
preferLegacyLeaf(root)
return root
}
func newDrivePermissionMutationCommand(runner executor.Runner, use, tool string, requireRole bool) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: use + " 文档空间节点权限",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
rawUsers := driveFlagOrFallback(cmd, "users", "user", "uid")
if rawUsers == "" {
return apperrors.NewValidation("--users is required")
}
userIDs, err := parseDocPermissionUsers(rawUsers)
if err != nil {
return err
}
params := map[string]any{
"nodeId": normalizeDocNodeID(nodeID),
"userIds": userIDs,
}
if requireRole {
rawRole, err := driveRequiredFlag(cmd, "role")
if err != nil {
return err
}
role, ok := normalizeDocPermissionRole(rawRole)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("invalid --role: %s", rawRole))
}
params["roleId"] = role
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", tool, params)
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
addDriveHiddenStringFlag(cmd, "user", "--users 的兼容别名")
addDriveHiddenStringFlag(cmd, "uid", "--users 的兼容别名")
if requireRole {
cmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
}
cmd.Flags().String("workspace", "", "知识库 ID (选填)")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
return cmd
}
func newDrivePermissionListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查询文档空间节点协作者",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
params := map[string]any{"nodeId": normalizeDocNodeID(nodeID)}
if limit := driveIntFlagOrFallback(cmd, "limit", "max-results", "page-size"); limit > 0 {
params["maxResults"] = limit
}
if filterRole := driveStringFlag(cmd, "filter-role"); filterRole != "" {
params["filterRoleIds"] = parseDriveRoleList(filterRole)
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", "list_permission", params)
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().Int("limit", 30, "返回成员数上限")
cmd.Flags().Int("max-results", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("max-results")
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("filter-role", "", "按角色过滤,逗号分隔")
cmd.Flags().String("workspace", "", "知识库 ID (选填)")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
return cmd
}
func newDrivePermissionRemoveCommand(runner executor.Runner) *cobra.Command {
cmd := newDrivePermissionMutationCommand(runner, "remove", "remove_permission", false)
cmd.Aliases = []string{"rm"}
return cmd
}
func newDriveFolderCommand(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "folder",
Short: "文档空间文件夹兼容入口(deprecated)",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
create := &cobra.Command{
Use: "create",
Short: "创建文档空间文件夹(deprecated)",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(cmd.ErrOrStderr(), "warning: deprecated: use dws wiki node create --type folder instead.")
name, err := driveRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
}
params := map[string]any{"name": name}
if folder := driveFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
params["folderId"] = normalizeDocNodeID(folder)
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", "create_folder", params)
},
}
preferLegacyLeaf(root)
preferLegacyLeaf(create)
create.Flags().String("name", "", "文件夹名称 (必填)")
addDriveHiddenStringFlag(create, "title", "--name 的兼容别名")
create.Flags().String("folder", "", "父文件夹 nodeId 或 URL")
addDriveHiddenStringFlag(create, "parent-id", "--folder 的兼容别名")
create.Flags().String("workspace", "", "目标知识库 ID")
addDriveHiddenStringFlag(create, "workspace-id", "--workspace 的兼容别名")
root.AddCommand(create)
root.Hidden = true
return root
}
func runDriveInfo(cmd *cobra.Command, runner executor.Runner, params map[string]any) error {
result, err := driveInvocationResult(cmd, runner, "drive", "get_file_info", params)
if err != nil {
@@ -814,6 +1224,64 @@ func addDriveStringParam(cmd *cobra.Command, params map[string]any, paramName st
}
}
func addDriveStringSliceParam(cmd *cobra.Command, params map[string]any, paramName, flag string) {
if !cmd.Flags().Changed(flag) {
return
}
values, err := cmd.Flags().GetStringSlice(flag)
if err != nil || len(values) == 0 {
return
}
cleaned := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
if item := strings.TrimSpace(part); item != "" {
cleaned = append(cleaned, item)
}
}
}
if len(cleaned) > 0 {
params[paramName] = cleaned
}
}
func addDriveInt64Param(cmd *cobra.Command, params map[string]any, paramName, flag string) {
if !cmd.Flags().Changed(flag) {
return
}
value, err := cmd.Flags().GetInt64(flag)
if err == nil && value > 0 {
params[paramName] = value
}
}
func addDriveDocNodeFlags(cmd *cobra.Command) {
cmd.Flags().String("node", "", "节点 ID 或 URL (必填)")
addDriveHiddenStringFlag(cmd, "url", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "id", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "node-id", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "doc-id", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
}
func parseDriveRoleList(raw string) []string {
roles := make([]string, 0)
for _, part := range strings.Split(raw, ",") {
role := strings.ToUpper(strings.TrimSpace(part))
if role == "" {
continue
}
if normalized, ok := normalizeDocPermissionRole(role); ok {
roles = append(roles, normalized)
continue
}
if role == "OWNER" {
roles = append(roles, role)
}
}
return roles
}
func addDriveHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
+108
View File
@@ -21,19 +21,24 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
type driveCommandRunner struct {
calls int
all []executor.Invocation
last executor.Invocation
result executor.Result
err error
}
func (r *driveCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls++
r.last = invocation
r.all = append(r.all, invocation)
if r.err != nil {
return executor.Result{}, r.err
}
@@ -65,6 +70,109 @@ func TestDriveListPageSizeAliasMapsMaxResults(t *testing.T) {
}
}
func TestDriveListWorkspaceRoutesToDocListNodes(t *testing.T) {
t.Parallel()
runner := &driveCommandRunner{}
cmd := newDriveListCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--workspace-id", "WS_001", "--folder", "FOLDER_001", "--limit", "10"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "doc" {
t.Fatalf("product = %q, want doc", runner.last.CanonicalProduct)
}
if runner.last.Tool != "list_nodes" {
t.Fatalf("tool = %q, want list_nodes", runner.last.Tool)
}
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
t.Fatalf("workspaceId = %#v, want WS_001", got)
}
if got := runner.last.Params["folderId"]; got != "FOLDER_001" {
t.Fatalf("folderId = %#v, want FOLDER_001", got)
}
if got := runner.last.Params["pageSize"]; got != 10 {
t.Fatalf("pageSize = %#v, want 10", got)
}
}
func TestDriveCopyAliasesRouteToDoc(t *testing.T) {
t.Parallel()
runner := &driveCommandRunner{}
cmd := newDriveCopyCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--file-id", "NODE_001", "--parent-id", "FOLDER_001", "--workspace-id", "WS_001"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "copy_document" {
t.Fatalf("invocation = %#v, want doc copy_document", runner.last)
}
if got := runner.last.Params["nodeId"]; got != "NODE_001" {
t.Fatalf("nodeId = %#v, want NODE_001", got)
}
if got := runner.last.Params["targetFolderId"]; got != "FOLDER_001" {
t.Fatalf("targetFolderId = %#v, want FOLDER_001", got)
}
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
t.Fatalf("workspaceId = %#v, want WS_001", got)
}
}
func TestDrivePermissionRemoveRoutesToDoc(t *testing.T) {
t.Parallel()
runner := &driveCommandRunner{}
cmd := newDrivePermissionCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"remove", "--node", "NODE_001", "--users", "uid1,uid2"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "remove_permission" {
t.Fatalf("invocation = %#v, want doc remove_permission", runner.last)
}
users, ok := runner.last.Params["userIds"].([]string)
if !ok || strings.Join(users, ",") != "uid1,uid2" {
t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
}
}
func TestDriveSearchAggregatesDriveAndDoc(t *testing.T) {
t.Parallel()
runner := &driveCommandRunner{}
cmd := newDriveSearchCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--query", "报告", "--extensions", "pdf,docx", "--limit", "5"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.all) != 2 {
t.Fatalf("calls = %d, want 2", len(runner.all))
}
if runner.all[0].CanonicalProduct != "drive" || runner.all[0].Tool != "search_files" {
t.Fatalf("first invocation = %#v, want drive search_files", runner.all[0])
}
if runner.all[1].CanonicalProduct != "doc" || runner.all[1].Tool != "search_documents" {
t.Fatalf("second invocation = %#v, want doc search_documents", runner.all[1])
}
}
func TestDriveDownloadOutputDirectoryUsesServerFileName(t *testing.T) {
t.Parallel()
+130
View File
@@ -0,0 +1,130 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler { return mailHandler{} })
}
// mailHandler contributes the hardcoded `mail message list` leaf. The MCP
// backend has no dedicated "list by folder" tool — wukong implements it by
// calling search_emails with a synthesised KQL query (folderId:<id>). The
// envelope/discovery layer cannot express that query construction (pipeline
// $flag templates resolve raw flag values and do not support string
// interpolation), so it lives here as a helper leaf. MergeCommandTree folds
// this single leaf into the envelope-driven mail tree without disturbing the
// other mail commands.
type mailHandler struct{}
func (mailHandler) Name() string { return "mail" }
func (mailHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "mail",
Short: i18n.T("邮箱"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
message := &cobra.Command{
Use: "message",
Short: i18n.T("邮件管理"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
message.AddCommand(newMailMessageListCommand(runner))
root.AddCommand(message)
return root
}
// newMailMessageListCommand builds `mail message list`, listing emails in a
// folder via search_emails with query=folderId:<id> (default inbox=2).
func newMailMessageListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: i18n.T("列出文件夹中的邮件"),
Example: " dws mail message list --email user@company.com # 默认列出收件箱\n" +
" dws mail message list --email user@company.com --folder-id 1 --limit 50",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
email := strings.TrimSpace(firstNonEmptyFlag(cmd, "email"))
if email == "" {
return apperrors.NewValidation("missing required flag(s): --email")
}
folderID := strings.TrimSpace(firstNonEmptyFlag(cmd, "folder-id", "folder"))
if folderID == "" {
folderID = "2" // inbox
}
params := map[string]any{
"email": email,
"query": "folderId:" + folderID,
}
if size := strings.TrimSpace(firstNonEmptyFlag(cmd, "limit", "size", "page-size")); size != "" {
params["size"] = size
}
if cursor := strings.TrimSpace(firstNonEmptyFlag(cmd, "cursor")); cursor != "" {
params["cursor"] = cursor
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "mail", "search_emails", params,
))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "mail", "search_emails", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("email", "", i18n.T("邮件所属邮箱地址 (必填)"))
cmd.Flags().String("folder-id", "", i18n.T("文件夹 ID (可选, 默认收件箱 2)"))
cmd.Flags().String("folder", "", i18n.T("--folder-id 的别名"))
cmd.Flags().String("limit", "", i18n.T("每页返回数量 (可选)"))
cmd.Flags().String("size", "", i18n.T("--limit 的别名"))
cmd.Flags().String("page-size", "", i18n.T("--limit 的别名"))
cmd.Flags().String("cursor", "", i18n.T("分页游标 (可选)"))
return cmd
}
// firstNonEmptyFlag returns the first non-empty string flag value among names.
func firstNonEmptyFlag(cmd *cobra.Command, names ...string) string {
for _, n := range names {
if v, err := cmd.Flags().GetString(n); err == nil && strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+159
View File
@@ -0,0 +1,159 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler { return minutesHandler{} })
}
// minutesHandler contributes the hardcoded `minutes list mine|shared|all`
// leaves. wukong lists minutes through a single tool
// (list_by_keyword_and_time_range) distinguished by belongingConditionId, and
// renames the raw MCP response fields (minutesDetails -> itemList,
// hasNext -> hasMore) for a stable CLI contract. The envelope/discovery layer
// maps these to older split tools and its outputFormat.rename cannot reach the
// nested result, so the canonical behaviour lives here. MergeCommandTree folds
// these leaves into the envelope-driven minutes tree.
type minutesHandler struct{}
func (minutesHandler) Name() string { return "minutes" }
func (minutesHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "minutes",
Short: i18n.T("听记"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
list := &cobra.Command{
Use: "list",
Short: i18n.T("听记列表"),
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
list.AddCommand(
newMinutesListCommand(runner, "mine", "created", i18n.T("查询我创建的听记列表")),
newMinutesListCommand(runner, "shared", "shared", i18n.T("查询他人共享给我的听记列表")),
newMinutesListCommand(runner, "all", "noLimit", i18n.T("查询我有权限访问的所有听记列表")),
)
root.AddCommand(list)
return root
}
func newMinutesListCommand(runner executor.Runner, use, belonging, short string) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
params := map[string]any{"belongingConditionId": belonging}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "limit", "max")); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil {
params["maxResults"] = n
}
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "query", "keyword")); v != "" {
params["keyword"] = v
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "cursor", "next-token", "offset")); v != "" {
params["nextToken"] = v
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "start")); v != "" {
ms, err := parseISOToMillis("start", v)
if err != nil {
return err
}
params["createTimeStart"] = float64(ms)
}
if v := strings.TrimSpace(firstNonEmptyFlag(cmd, "end")); v != "" {
ms, err := parseISOToMillis("end", v)
if err != nil {
return err
}
params["createTimeEnd"] = float64(ms)
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "minutes", "list_by_keyword_and_time_range", params,
)
if commandDryRun(cmd) {
return writeCommandPayload(cmd, inv)
}
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
renameMinutesListFields(result.Response)
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("limit", "", i18n.T("每页返回数量 (可选)"))
cmd.Flags().String("max", "", i18n.T("--limit 的别名"))
cmd.Flags().String("cursor", "", i18n.T("分页游标 (可选)"))
cmd.Flags().String("query", "", i18n.T("关键字筛选 (可选)"))
cmd.Flags().String("start", "", i18n.T("起始时间 ISO-8601 (可选)"))
cmd.Flags().String("end", "", i18n.T("结束时间 ISO-8601 (可选)"))
return cmd
}
// renameMinutesListFields renames the raw list fields inside the MCP response
// content (content.result.{minutesDetails->itemList, hasNext->hasMore}) so the
// CLI contract matches wukong. Safe no-op when the shape differs.
func renameMinutesListFields(resp map[string]any) {
if resp == nil {
return
}
content, ok := resp["content"].(map[string]any)
if !ok {
return
}
target := content
if inner, ok := content["result"].(map[string]any); ok {
target = inner
}
if v, ok := target["minutesDetails"]; ok {
target["itemList"] = v
delete(target, "minutesDetails")
}
if v, ok := target["hasNext"]; ok {
target["hasMore"] = v
delete(target, "hasNext")
}
}
func parseISOToMillis(label, s string) (int64, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UnixMilli(), nil
}
}
return 0, apperrors.NewValidation("--" + label + " 时间格式无效,请用 ISO-8601 (如 2026-03-10T14:00:00+08:00)")
}
File diff suppressed because it is too large Load Diff
+514
View File
@@ -0,0 +1,514 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"encoding/json"
"fmt"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// sheet range update 单元格结构的客户端校验,与 wukong 产品层 validateComplexValueCell
// 系列保持一致:拦住缺 type / richText 与整格 hyperlink 共存 / hyperlink.text 与
// cell.text 不一致 / 非法 dataValidation 等非法输入,避免把脏 payload 发到服务端。
var sheetComplexValueStyleFields = map[string]string{
"bold": "bool",
"italic": "bool",
"underline": "bool",
"strike": "bool",
"color": "string",
"size": "number",
}
func sheetValidateComplexValueStyle(style map[string]any, path string) error {
for k, v := range style {
kind, ok := sheetComplexValueStyleFields[k]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s 含未知字段 %q(合法字段: bold/italic/underline/strike/color/size)", path, k))
}
switch kind {
case "bool":
if _, ok := v.(bool); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为 boolean", path, k))
}
case "string":
if _, ok := v.(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为字符串(如 \"#FF0000\")", path, k))
}
case "number":
if _, ok := v.(float64); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.%s 必须为数字", path, k))
}
}
}
return nil
}
func sheetValidateRichTextItem(item map[string]any, path string) error {
typeRaw, ok := item["type"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: text/link/attachment/image)", path))
}
typeVal, ok := typeRaw.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
}
switch typeVal {
case "text":
if _, ok := item["text"].(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=text 必须包含 text 字符串字段", path))
}
case "link":
if _, ok := item["text"].(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=link 必须包含 text 字符串字段(显示文字)", path))
}
if s, ok := item["link"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=link 必须包含非空 link 字符串字段(超链接 URL)", path))
}
case "attachment":
if _, ok := item["text"].(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含 text 字符串字段(显示文件名)", path))
}
if s, ok := item["resourceId"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含非空 resourceId 字符串字段(通过 dws sheet media-upload 获取)", path))
}
if s, ok := item["mimeType"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=attachment 必须包含非空 mimeType 字符串字段", path))
}
case "image":
if s, ok := item["resourceId"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=image 必须包含非空 resourceId 字符串字段(通过 dws sheet media-upload 获取)", path))
}
if s, ok := item["resourceUrl"].(string); !ok || s == "" {
return apperrors.NewValidation(fmt.Sprintf("%s: type=image 必须包含非空 resourceUrl 字符串字段", path))
}
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: text/link/attachment/image)", path, typeVal))
}
if styleRaw, exists := item["style"]; exists {
if typeVal != "text" && typeVal != "link" {
return apperrors.NewValidation(fmt.Sprintf("%s: style 字段仅 type=text / link 子项支持", path))
}
style, ok := styleRaw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.style 必须为 object", path))
}
if err := sheetValidateComplexValueStyle(style, path+".style"); err != nil {
return err
}
}
return nil
}
func sheetValidateCellHyperlink(raw any, path string) error {
if raw == nil {
return nil
}
hyperlink, ok := raw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s 必须为 object 或 null", path))
}
typeRaw, ok := hyperlink["type"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: path / sheet / range / none)", path))
}
typeVal, ok := typeRaw.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
}
switch typeVal {
case "path", "sheet", "range":
linkRaw, ok := hyperlink["link"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 link 字段", path))
}
link, ok := linkRaw.(string)
if !ok || strings.TrimSpace(link) == "" {
return apperrors.NewValidation(fmt.Sprintf("%s.link 必须为非空字符串", path))
}
if textRaw, exists := hyperlink["text"]; exists {
if _, ok := textRaw.(string); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.text 必须为字符串", path))
}
}
case "none":
// type=none 表示显式清除单元格级超链接,不需其他字段
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: path / sheet / range / none)", path, typeVal))
}
return nil
}
func sheetValidateDataValidation(dvRaw any, path string) error {
dv, ok := dvRaw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s 必须为 object", path))
}
dvType, ok := dv["type"].(string)
if !ok || dvType == "" {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为非空字符串(合法值: dropdown / checkbox / none)", path))
}
switch dvType {
case "dropdown":
optionsRaw, ok := dv["options"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=dropdown 必须包含 options 数组", path))
}
options, ok := optionsRaw.([]any)
if !ok || len(options) == 0 {
return apperrors.NewValidation(fmt.Sprintf("%s.options 必须为非空数组", path))
}
for i, opt := range options {
optMap, ok := opt.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i))
}
val, ok := optMap["value"].(string)
if !ok || val == "" {
return apperrors.NewValidation(fmt.Sprintf("%s.options[%d].value 必须为非空字符串", path, i))
}
}
case "checkbox":
if c, exists := dv["checked"]; exists {
if _, ok := c.(bool); !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.checked 必须为 boolean", path))
}
}
case "none":
// type=none 表示显式清除单元格 DV,不需其他字段
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: dropdown / checkbox / none)", path, dvType))
}
return nil
}
// sheet range read 回读投影:MCP 框架会按 schema 把 dataValidation / hyperlink
// 填成"全字段 null"的空壳,导致清除后回读仍带着这些 key。与 wukong
// callMCPToolCellInfos 一致:递归找到 cells,把全 null 的空壳 delete 掉,
// 让"清除后回读不应再有该 key"的语义成立。
func sheetCleanCellInfos(v any) {
switch t := v.(type) {
case map[string]any:
if cellsRaw, ok := t["cells"]; ok {
sheetStripCellsMeta(cellsRaw)
}
for _, child := range t {
sheetCleanCellInfos(child)
}
case []any:
for _, child := range t {
sheetCleanCellInfos(child)
}
}
}
// sheetProjectFlatValues walks an MCP get_cell_infos response and, next to any
// rich `cells` array, adds a flat `values` 2D array of each cell's scalar
// `value`. This gives consumers the simple wukong get_range shape
// (values: [["姓名","部门"]]) without dropping the rich `cells` payload.
// Idempotent: skips when `values` already exists or there are no cells.
func sheetProjectFlatValues(v any) {
switch t := v.(type) {
case map[string]any:
if cellsRaw, ok := t["cells"]; ok {
if _, exists := t["values"]; !exists {
if flat := sheetFlatValuesFromCells(cellsRaw); flat != nil {
t["values"] = flat
}
}
}
for _, child := range t {
sheetProjectFlatValues(child)
}
case []any:
for _, child := range t {
sheetProjectFlatValues(child)
}
}
}
func sheetFlatValuesFromCells(cellsRaw any) []any {
rows, ok := cellsRaw.([]any)
if !ok {
return nil
}
out := make([]any, 0, len(rows))
for _, row := range rows {
rowSlice, ok := row.([]any)
if !ok {
return nil
}
flatRow := make([]any, 0, len(rowSlice))
for _, cell := range rowSlice {
if cellMap, ok := cell.(map[string]any); ok {
flatRow = append(flatRow, cellMap["value"])
} else {
flatRow = append(flatRow, cell)
}
}
out = append(out, flatRow)
}
return out
}
func sheetStripCellsMeta(cellsRaw any) {
rows, ok := cellsRaw.([]any)
if !ok {
return
}
for _, row := range rows {
rowSlice, ok := row.([]any)
if !ok {
continue
}
for _, cell := range rowSlice {
cellMap, ok := cell.(map[string]any)
if !ok {
continue
}
if dv, ok := cellMap["dataValidation"]; ok && sheetIsEmptyMetaShell(dv) {
delete(cellMap, "dataValidation")
}
if hl, ok := cellMap["hyperlink"]; ok && sheetIsEmptyMetaShell(hl) {
delete(cellMap, "hyperlink")
}
}
}
}
// sheet info 坐标投影:只向 agent 暴露 A1/UI 语义的 nonEmptyRange
// (range / lastCell / lastRow / lastColumn),并清掉服务端的 legacy 0-based
// 字段。与 wukong normalizeSheetInfoCoordinatesForAgent 一致。
func sheetNormalizeInfoCoordinates(v any) {
switch t := v.(type) {
case map[string]any:
_, hasNonEmpty := t["nonEmptyRange"]
_, hasLegacyRow := t["lastNonEmptyRow"]
if hasNonEmpty || hasLegacyRow {
sheetApplyNonEmptyRange(t)
}
for _, child := range t {
sheetNormalizeInfoCoordinates(child)
}
case []any:
for _, child := range t {
sheetNormalizeInfoCoordinates(child)
}
}
}
func sheetApplyNonEmptyRange(sheet map[string]any) {
if ner := sheetNormalizeNonEmptyRangeObject(sheet["nonEmptyRange"]); ner != nil {
sheet["nonEmptyRange"] = ner
} else if ner := sheetBuildNonEmptyRangeFromLegacy(sheet); ner != nil {
sheet["nonEmptyRange"] = ner
} else {
sheet["nonEmptyRange"] = nil
}
delete(sheet, "lastNonEmptyRow")
delete(sheet, "lastNonEmptyColumn")
delete(sheet, "lastNonEmptyIndexBase")
delete(sheet, "lastNonEmptyRowNumber")
delete(sheet, "lastNonEmptyColumnLetter")
delete(sheet, "nonEmptyRangeA1")
}
func sheetNormalizeNonEmptyRangeObject(v any) map[string]any {
ner, ok := v.(map[string]any)
if !ok {
return nil
}
rangeValue, hasRange := ner["range"].(string)
lastCell, hasLastCell := ner["lastCell"].(string)
lastRow, hasLastRow := sheetNonNegativeJSONInt(ner["lastRow"])
lastColumn, hasLastColumn := ner["lastColumn"].(string)
if hasRange && hasLastCell && hasLastRow && hasLastColumn {
return map[string]any{
"range": rangeValue,
"lastCell": lastCell,
"lastRow": lastRow,
"lastColumn": lastColumn,
}
}
return nil
}
func sheetBuildNonEmptyRangeFromLegacy(sheet map[string]any) map[string]any {
row, hasRow := sheetNonNegativeJSONInt(sheet["lastNonEmptyRow"])
col, hasCol := sheetNonNegativeJSONInt(sheet["lastNonEmptyColumn"])
if !hasRow || !hasCol {
return nil
}
lastColumnLetter := sheetColumnLetterFromZeroBased(col)
lastRowNumber := row + 1
lastCellA1 := fmt.Sprintf("%s%d", lastColumnLetter, lastRowNumber)
return map[string]any{
"range": "A1:" + lastCellA1,
"lastCell": lastCellA1,
"lastRow": lastRowNumber,
"lastColumn": lastColumnLetter,
}
}
func sheetNonNegativeJSONInt(v any) (int, bool) {
switch n := v.(type) {
case int:
return n, n >= 0
case int64:
if n < 0 {
return 0, false
}
return int(n), true
case float64:
if n < 0 {
return 0, false
}
i := int(n)
if float64(i) != n {
return 0, false
}
return i, true
case json.Number:
i, err := n.Int64()
if err != nil || i < 0 {
return 0, false
}
return int(i), true
default:
return 0, false
}
}
func sheetColumnLetterFromZeroBased(index int) string {
if index < 0 {
return ""
}
index++
var b strings.Builder
for index > 0 {
index--
b.WriteByte(byte('A' + index%26))
index /= 26
}
letters := []byte(b.String())
for i, j := 0, len(letters)-1; i < j; i, j = i+1, j-1 {
letters[i], letters[j] = letters[j], letters[i]
}
return string(letters)
}
// sheetIsEmptyMetaShell 判断 metadata 块是否为全 null 空壳(MCP 框架按 schema 填充的)。
func sheetIsEmptyMetaShell(v any) bool {
if v == nil {
return true
}
m, ok := v.(map[string]any)
if !ok {
return false
}
for _, vv := range m {
if vv != nil {
return false
}
}
return true
}
func sheetValidateComplexValueCell(cell map[string]any, path string) error {
if dvRaw, exists := cell["dataValidation"]; exists {
if err := sheetValidateDataValidation(dvRaw, path+".dataValidation"); err != nil {
return err
}
}
if hyperlinkRaw, exists := cell["hyperlink"]; exists {
if err := sheetValidateCellHyperlink(hyperlinkRaw, path+".hyperlink"); err != nil {
return err
}
}
typeRaw, hasType := cell["type"]
// 没有 type 时,必须有 metadata 字段(不写值,只更新元数据)
if !hasType {
_, hasDV := cell["dataValidation"]
_, hasCS := cell["cellStyles"]
_, hasHyperlink := cell["hyperlink"]
if hasDV || hasCS || hasHyperlink {
return nil
}
return apperrors.NewValidation(fmt.Sprintf("%s: 缺少 type 字段(合法值: text/richText)", path))
}
typeVal, ok := typeRaw.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.type 必须为字符串", path))
}
switch typeVal {
case "text":
var cellText string
hasCellText := false
if t, exists := cell["text"]; exists {
textValue, ok := t.(string)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.text 必须为字符串(text=\"\" 表示清空 cell)", path))
}
cellText = textValue
hasCellText = true
}
if styleRaw, exists := cell["style"]; exists {
style, ok := styleRaw.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.style 必须为 object", path))
}
if err := sheetValidateComplexValueStyle(style, path+".style"); err != nil {
return err
}
}
if hyperlinkRaw, exists := cell["hyperlink"]; exists && hyperlinkRaw != nil {
hyperlink, _ := hyperlinkRaw.(map[string]any)
if hyperlinkText, ok := hyperlink["text"].(string); ok && hasCellText && hyperlinkText != cellText {
return apperrors.NewValidation(fmt.Sprintf("%s.hyperlink.text 与 %s.text 不一致,请只传 cell.text 或保持两者相同", path, path))
}
}
case "richText":
if _, hasHyperlink := cell["hyperlink"]; hasHyperlink {
return apperrors.NewValidation(fmt.Sprintf("%s: cell-level hyperlink 不能与 type=richText 同时使用;整格链接用 hyperlink,片段链接用 richText.texts[].type=link", path))
}
textsRaw, ok := cell["texts"]
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: type=richText 必须包含 texts 数组", path))
}
texts, ok := textsRaw.([]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.texts 必须为数组", path))
}
if len(texts) == 0 {
return apperrors.NewValidation(fmt.Sprintf("%s.texts 不能为空数组", path))
}
for i, item := range texts {
itemMap, ok := item.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s.texts[%d] 必须为 object", path, i))
}
if err := sheetValidateRichTextItem(itemMap, fmt.Sprintf("%s.texts[%d]", path, i)); err != nil {
return err
}
}
default:
return apperrors.NewValidation(fmt.Sprintf("%s.type 非法值 %q(合法值: text / richText;不再支持 number/boolean/null,数字布尔请用 {type:text,text:\"...\"} 字符串形式)", path, typeVal))
}
return nil
}
+217
View File
@@ -0,0 +1,217 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
type sheetCommandRunner struct {
last executor.Invocation
calls []executor.Invocation
}
func (r *sheetCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.last = invocation
r.calls = append(r.calls, invocation)
return executor.Result{Invocation: invocation}, nil
}
func executeSheetCommand(t *testing.T, runner *sheetCommandRunner, args ...string) {
t.Helper()
cmd := sheetHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
}
func TestSheetCreateCallsCreateWorkspaceSheet(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner, "create", "--name", "销售数据", "--folder", "FOLDER_001", "--workspace", "WS_001")
if runner.last.Tool != "create_workspace_sheet" {
t.Fatalf("tool = %q, want create_workspace_sheet", runner.last.Tool)
}
if got := runner.last.Params["name"]; got != "销售数据" {
t.Fatalf("name = %#v", got)
}
if got := runner.last.Params["folderId"]; got != "FOLDER_001" {
t.Fatalf("folderId = %#v", got)
}
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
t.Fatalf("workspaceId = %#v", got)
}
}
func TestSheetRangeUpdateCallsSetCellRange(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"range", "update",
"--node", "NODE_001",
"--sheet-id", "SHEET_001",
"--range", "A1:B1",
"--values", `[[{"type":"text","text":"姓名"},{"type":"text","text":"分数"}]]`,
)
if runner.last.Tool != "set_cell_range" {
t.Fatalf("tool = %q, want set_cell_range", runner.last.Tool)
}
if got := runner.last.Params["rangeAddress"]; got != "A1:B1" {
t.Fatalf("rangeAddress = %#v", got)
}
cells, ok := runner.last.Params["cells"].([][]any)
if !ok || len(cells) != 1 || len(cells[0]) != 2 {
t.Fatalf("cells = %#v", runner.last.Params["cells"])
}
}
func TestSheetRangeSetStyleExpandsScalarStyle(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"range", "set-style",
"--node", "NODE_001",
"--sheet-id", "SHEET_001",
"--range", "A1:B2",
"--bg-color", "#FFF2CC",
"--font-weight", "bold",
)
if runner.last.Tool != "update_range" {
t.Fatalf("tool = %q, want update_range", runner.last.Tool)
}
bg, ok := runner.last.Params["backgroundColors"].([][]string)
if !ok || len(bg) != 2 || len(bg[0]) != 2 || bg[1][1] != "#FFF2CC" {
t.Fatalf("backgroundColors = %#v", runner.last.Params["backgroundColors"])
}
weights, ok := runner.last.Params["fontWeights"].([][]string)
if !ok || weights[0][0] != "bold" {
t.Fatalf("fontWeights = %#v", runner.last.Params["fontWeights"])
}
}
func TestSheetFilterViewUpdateCriteriaCallsSetCriteria(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"filter-view", "update-criteria",
"--node", "NODE_001",
"--sheet-id", "SHEET_001",
"--filter-view-id", "FV_001",
"--column", "2",
"--filter-criteria", `{"filterType":"values","visibleValues":["销售部"]}`,
)
if runner.last.Tool != "set_filter_view_criteria" {
t.Fatalf("tool = %q, want set_filter_view_criteria", runner.last.Tool)
}
if got := runner.last.Params["filterViewId"]; got != "FV_001" {
t.Fatalf("filterViewId = %#v", got)
}
if got := runner.last.Params["column"]; got != 2 {
t.Fatalf("column = %#v", got)
}
}
func TestSheetCondFormatCreateExpandsCondition(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"cond-format", "create",
"--node", "NODE_001",
"--sheet-id", "SHEET_001",
"--ranges", `["A1:A10"]`,
"--condition", `{"numberCondition":{"operator":"greater","value1":"80"}}`,
"--cell-style", `{"backgroundColor":"#FFCDD2"}`,
)
if runner.last.Tool != "create_cond_format" {
t.Fatalf("tool = %q, want create_cond_format", runner.last.Tool)
}
if _, ok := runner.last.Params["numberCondition"]; !ok {
t.Fatalf("numberCondition missing from %#v", runner.last.Params)
}
if _, ok := runner.last.Params["cellStyle"]; !ok {
t.Fatalf("cellStyle missing from %#v", runner.last.Params)
}
}
func TestSheetCSVCommandsAcceptFileIDAlias(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"csv-get",
"--file-id", "NODE_001",
"--sheet-id", "SHEET_001",
"--range", "A1:B2",
)
if runner.last.Tool != "get_range_as_csv" {
t.Fatalf("tool = %q, want get_range_as_csv", runner.last.Tool)
}
if got := runner.last.Params["nodeId"]; got != "NODE_001" {
t.Fatalf("csv-get nodeId = %#v", got)
}
executeSheetCommand(t, runner,
"csv-put",
"--file-id", "NODE_002",
"--sheet-id", "SHEET_002",
"--csv", "a,b\n1,2",
"--start-cell", "A1",
)
if runner.last.Tool != "set_range_from_csv" {
t.Fatalf("tool = %q, want set_range_from_csv", runner.last.Tool)
}
if got := runner.last.Params["nodeId"]; got != "NODE_002" {
t.Fatalf("csv-put nodeId = %#v", got)
}
}
func TestSheetReplaceAllowsEmptyReplacement(t *testing.T) {
t.Parallel()
runner := &sheetCommandRunner{}
executeSheetCommand(t, runner,
"replace",
"--node", "NODE_001",
"--sheet-id", "SHEET_001",
"--find", "临时",
"--replacement", "",
)
if runner.last.Tool != "replace_all" {
t.Fatalf("tool = %q, want replace_all", runner.last.Tool)
}
if got := runner.last.Params["replaceText"]; got != "" {
t.Fatalf("replaceText = %#v, want empty string", got)
}
}
+1
View File
@@ -69,6 +69,7 @@ func (todoHandler) Command(runner executor.Runner) *cobra.Command {
newTodoTaskDoneCommand(runner),
newTodoTaskGetCommand(runner),
newTodoTaskDeleteCommand(runner),
newTodoAddAttachmentCommand(runner),
)
root.AddCommand(task)
return root
+328
View File
@@ -0,0 +1,328 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
// newTodoAddAttachmentCommand builds the hardcoded `todo task add-attachment`
// leaf. The MCP backend exposes attachment upload as a multi-step orchestration
// that the envelope/pipeline layer cannot express (no upload step, local file
// IO): init upload credentials -> HTTP PUT the local file -> commit -> add.
// wukong implements it in code; this is the open-edition equivalent. It is
// wired into the existing todo handler's task group (see todo.go).
func newTodoAddAttachmentCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add-attachment",
Short: i18n.T("上传待办附件"),
Long: i18n.T("上传本地文件作为待办附件(init → 上传 → commit → add 四步)。会真实上传文件,请确认待办存在。"),
Example: " dws todo task add-attachment --task-id <taskId> --file-path <filePath>",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
taskID := strings.TrimSpace(firstNonEmptyFlag(cmd, "task-id", "id"))
filePath := strings.TrimSpace(firstNonEmptyFlag(cmd, "file-path", "file"))
if taskID == "" {
return apperrors.NewValidation("missing required flag(s): --task-id")
}
if filePath == "" {
return apperrors.NewValidation("missing required flag(s): --file-path")
}
fi, err := os.Stat(filePath)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("cannot read file %s: %v", filePath, err))
}
if fi.IsDir() {
return apperrors.NewValidation(fmt.Sprintf("%s is a directory, not a file", filePath))
}
fileName := filepath.Base(filePath)
fileType := strings.TrimPrefix(filepath.Ext(fileName), ".")
fileSize := fi.Size()
md5Hex, err := fileMD5Hex(filePath)
if err != nil {
return err
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "add_todo_attachment", map[string]any{
"todoAttachmentAddRequest": map[string]any{
"taskId": taskID,
"fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
},
}))
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
// 1) init upload credentials
initRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "init_todo_file_upload", map[string]any{
"todoAttachmentInitUploadInfoRequest": map[string]any{
"fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
},
}))
if err != nil {
return err
}
resourceURL := findStringDeep(initRes.Response, "resourceUrl", "resourceURL", "url")
if resourceURL == "" {
resourceURL = findFirstInStringArrayDeep(initRes.Response, "resourceUrls", "resourceURLs")
}
uploadKey := findStringDeep(initRes.Response, "uploadKey", "key")
if resourceURL == "" || uploadKey == "" {
return apperrors.NewAPI(fmt.Sprintf("incomplete upload credentials: resourceUrl=%q uploadKey=%q", resourceURL, uploadKey))
}
headers := findHeadersDeep(initRes.Response, "headers", "ossHeaders")
// 2) PUT the local file to the resource URL
if err := httpPutLocalFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
// 3) commit upload
commitRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "commit_todo_file_upload", map[string]any{
"todoAttachmentCommitUploadInfoRequest": map[string]any{
"uploadKey": uploadKey, "fileName": fileName, "fileSize": fileSize, "md5": md5Hex,
},
}))
if err != nil {
return err
}
dentryID := findInt64Deep(commitRes.Response, "dentryId", "dentryID")
spaceID := findInt64Deep(commitRes.Response, "spaceId", "spaceID")
if dentryID == 0 || spaceID == 0 {
return apperrors.NewAPI("uploaded file response missing dentryId or spaceId")
}
// 4) add attachment to the todo
addRes, err := runner.Run(ctx, executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "todo", "add_todo_attachment", map[string]any{
"todoAttachmentAddRequest": map[string]any{
"taskId": taskID,
"attachmentList": []any{map[string]any{
"fileId": strconv.FormatInt(dentryID, 10),
"fileName": fileName,
"fileSize": fileSize,
"spaceId": strconv.FormatInt(spaceID, 10),
"fileType": fileType,
}},
},
}))
if err != nil {
return err
}
return writeCommandPayload(cmd, addRes)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("task-id", "", i18n.T("待办任务 ID (必填)"))
cmd.Flags().String("id", "", i18n.T("--task-id 的别名"))
cmd.Flags().String("file-path", "", i18n.T("本地文件路径 (必填)"))
cmd.Flags().String("file", "", i18n.T("--file-path 的别名"))
return cmd
}
func fileMD5Hex(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("cannot open file %s: %v", path, err))
}
defer f.Close()
h := md5.New()
if _, err := io.Copy(h, f); err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("md5 of %s: %v", path, err))
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func httpPutLocalFile(ctx context.Context, url string, headers map[string]string, path string, size int64) error {
f, err := os.Open(path)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("cannot open file %s: %v", path, err))
}
defer f.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, f)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("build PUT request: %v", err))
}
req.ContentLength = size
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return apperrors.NewAPI(fmt.Sprintf("upload PUT failed: %v", err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return apperrors.NewAPI(fmt.Sprintf("upload PUT returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
}
return nil
}
// findStringDeep recursively searches a decoded JSON value for the first
// non-empty string value under any of the given keys.
func findStringDeep(v any, keys ...string) string {
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if s, ok := scalarString(t[k]); ok && s != "" {
return s
}
}
for _, child := range t {
if s := findStringDeep(child, keys...); s != "" {
return s
}
}
case []any:
for _, child := range t {
if s := findStringDeep(child, keys...); s != "" {
return s
}
}
}
return ""
}
// findFirstInStringArrayDeep recursively finds the first non-empty string in an
// array stored under any of the given keys (e.g. "resourceUrls").
func findFirstInStringArrayDeep(v any, keys ...string) string {
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if arr, ok := t[k].([]any); ok {
for _, e := range arr {
if s, ok := scalarString(e); ok && s != "" {
return s
}
}
}
}
for _, child := range t {
if s := findFirstInStringArrayDeep(child, keys...); s != "" {
return s
}
}
case []any:
for _, child := range t {
if s := findFirstInStringArrayDeep(child, keys...); s != "" {
return s
}
}
}
return ""
}
func findInt64Deep(v any, keys ...string) int64 {
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if n, ok := scalarInt64(t[k]); ok && n != 0 {
return n
}
}
for _, child := range t {
if n := findInt64Deep(child, keys...); n != 0 {
return n
}
}
case []any:
for _, child := range t {
if n := findInt64Deep(child, keys...); n != 0 {
return n
}
}
}
return 0
}
func findHeadersDeep(v any, keys ...string) map[string]string {
out := map[string]string{}
switch t := v.(type) {
case map[string]any:
for _, k := range keys {
if h, ok := t[k].(map[string]any); ok {
for name, val := range h {
if s, ok := scalarString(val); ok && s != "" {
out[name] = s
}
}
}
}
if len(out) == 0 {
for _, child := range t {
if h := findHeadersDeep(child, keys...); len(h) > 0 {
return h
}
}
}
}
return out
}
func scalarString(v any) (string, bool) {
switch s := v.(type) {
case string:
return s, true
case json.Number:
return s.String(), true
case float64:
return strconv.FormatFloat(s, 'f', -1, 64), true
}
return "", false
}
func scalarInt64(v any) (int64, bool) {
switch n := v.(type) {
case json.Number:
if i, err := n.Int64(); err == nil {
return i, true
}
if f, err := n.Float64(); err == nil {
return int64(f), true
}
case float64:
return int64(n), true
case int64:
return n, true
case string:
if i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64); err == nil {
return i, true
}
}
return 0, false
}
+297 -1
View File
@@ -14,6 +14,8 @@
package helpers
import (
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
@@ -64,6 +66,7 @@ func (wikiHandler) Command(runner executor.Runner) *cobra.Command {
newWikiSpaceGetCommand(runner),
newWikiSpaceListCommand(runner),
newWikiSpaceSearchCommand(runner),
newWikiSpaceDeleteCommand(runner),
)
root.AddCommand(space)
@@ -81,6 +84,7 @@ func (wikiHandler) Command(runner executor.Runner) *cobra.Command {
newWikiMemberAddCommand(runner),
newWikiMemberUpdateCommand(runner),
newWikiMemberListCommand(runner),
newWikiMemberRemoveCommand(runner),
)
root.AddCommand(member)
return root
@@ -150,6 +154,20 @@ func newWikiSpaceListCommand(runner executor.Runner) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
params := map[string]any{}
if spaceType := wikiFlagOrFallback(cmd, "type"); spaceType != "" {
if spaceType == "orgSpace" || spaceType == "mySpace" {
driveParams := map[string]any{"spaceType": spaceType}
if limit := wikiFlagOrFallback(cmd, "limit", "page-size"); limit != "" {
if n, err := strconv.Atoi(limit); err == nil {
driveParams["maxResults"] = n
} else {
driveParams["maxResults"] = limit
}
}
if pageToken := wikiFlagOrFallback(cmd, "cursor", "page-token"); pageToken != "" {
driveParams["nextToken"] = pageToken
}
return runWikiProductTool(cmd, runner, "drive", "list_spaces", driveParams)
}
params["wikiSpaceType"] = spaceType
}
if limit := wikiFlagOrFallback(cmd, "limit", "page-size"); limit != "" {
@@ -211,6 +229,32 @@ func newWikiSpaceSearchCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func newWikiSpaceDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: "删除知识库",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
if !confirmDeletePrompt(cmd, "知识库", workspaceID) {
return nil
}
return runWikiTool(cmd, runner, "delete_wikiSpace", map[string]any{
"workspaceId": workspaceID,
})
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
addWikiHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
return cmd
}
func newWikiMemberAddCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add",
@@ -313,10 +357,233 @@ func newWikiMemberListCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func newWikiMemberRemoveCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Aliases: []string{"rm"},
Short: "移除知识库成员",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
user, err := wikiRequiredFlagOrFallback(cmd, "users", "user", "uid")
if err != nil {
return err
}
return runWikiTool(cmd, runner, "remove_member", map[string]any{
"workspaceId": workspaceID,
"userIds": wikiCSV(user),
})
},
}
preferLegacyLeaf(cmd)
addWikiMemberListWorkspaceFlag(cmd)
cmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
addWikiHiddenStringFlag(cmd, "user", "--users 的兼容别名")
addWikiHiddenStringFlag(cmd, "uid", "--users 的兼容别名")
return cmd
}
func newWikiNodeListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "列出知识库节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
params := map[string]any{"workspaceId": workspaceID}
if folder := wikiFlagOrFallback(cmd, "folder", "node", "parent-id"); folder != "" {
params["folderId"] = normalizeDocNodeID(folder)
}
if limit := wikiIntFlagOrFallback(cmd, "limit", "page-size"); limit > 0 {
params["pageSize"] = limit
}
if cursor := wikiFlagOrFallback(cmd, "cursor", "page-token"); cursor != "" {
params["pageToken"] = cursor
}
return runWikiProductTool(cmd, runner, "doc", "list_nodes", params)
},
}
preferLegacyLeaf(cmd)
addWikiNodeWorkspaceFlag(cmd)
cmd.Flags().String("folder", "", "父节点 nodeId")
addWikiHiddenStringFlag(cmd, "node", "--folder 的兼容别名")
addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
cmd.Flags().Int("limit", 0, "每页数量")
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("cursor", "", "分页游标")
addWikiHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
return cmd
}
func newWikiNodeCreateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "在知识库中创建节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
name, err := wikiRequiredFlag(cmd, "name")
if err != nil {
return err
}
params := map[string]any{
"workspaceId": workspaceID,
"name": name,
}
if nodeType := wikiFlagOrFallback(cmd, "type"); nodeType != "" {
params["type"] = nodeType
}
if folder := wikiFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
params["folderId"] = normalizeDocNodeID(folder)
}
return runWikiProductTool(cmd, runner, "doc", "create_file", params)
},
}
preferLegacyLeaf(cmd)
addWikiNodeWorkspaceFlag(cmd)
cmd.Flags().String("name", "", "节点名称 (必填)")
cmd.Flags().String("type", "adoc", "节点类型: adoc / asheet / folder / axls")
cmd.Flags().String("folder", "", "父节点 nodeId")
addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
return cmd
}
func newWikiNodeCopyCommand(runner executor.Runner) *cobra.Command {
return newWikiNodeTransferCommand(runner, "copy", "copy_document")
}
func newWikiNodeMoveCommand(runner executor.Runner) *cobra.Command {
return newWikiNodeTransferCommand(runner, "move", "move_document")
}
func newWikiNodeTransferCommand(runner executor.Runner, use, tool string) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: use + " 知识库节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
nodeID, err := wikiRequiredFlagOrFallback(cmd, "node", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
params := map[string]any{
"nodeId": normalizeDocNodeID(nodeID),
"workspaceId": workspaceID,
}
if folder := wikiFlagOrFallback(cmd, "folder", "parent-id", "parent-node-id", "parent-folder-id"); folder != "" {
params["targetFolderId"] = normalizeDocNodeID(folder)
}
return runWikiProductTool(cmd, runner, "doc", tool, params)
},
}
preferLegacyLeaf(cmd)
addWikiNodeWorkspaceFlag(cmd)
addWikiNodeIDFlags(cmd)
cmd.Flags().String("folder", "", "目标文件夹 nodeId")
addWikiHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
addWikiHiddenStringFlag(cmd, "parent-node-id", "--folder 的兼容别名")
addWikiHiddenStringFlag(cmd, "parent-folder-id", "--folder 的兼容别名")
return cmd
}
func newWikiNodeDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: "删除知识库节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if _, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId"); err != nil {
return err
}
nodeID, err := wikiRequiredFlagOrFallback(cmd, "node", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if !confirmDeletePrompt(cmd, "知识库节点", nodeID) {
return nil
}
return runWikiProductTool(cmd, runner, "doc", "delete_document", map[string]any{
"nodeId": normalizeDocNodeID(nodeID),
})
},
}
preferLegacyLeaf(cmd)
addWikiNodeWorkspaceFlag(cmd)
addWikiNodeIDFlags(cmd)
cmd.Flags().BoolP("yes", "y", false, "跳过确认直接删除")
return cmd
}
func newWikiNodeSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "在知识库中搜索节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := wikiRequiredFlagOrFallback(cmd, "workspace", "workspace-id", "workspaceId")
if err != nil {
return err
}
query := wikiFlagOrFallback(cmd, "query", "keyword")
if query == "" {
return apperrors.NewValidation("--query is required")
}
params := map[string]any{
"keyword": query,
"workspaceIds": []string{workspaceID},
}
if values, _ := cmd.Flags().GetStringSlice("extensions"); len(values) > 0 {
params["extensions"] = splitWikiStringSlice(values)
}
if limit := wikiIntFlagOrFallback(cmd, "limit"); limit > 0 {
params["pageSize"] = limit
}
if cursor := wikiFlagOrFallback(cmd, "cursor", "page-token"); cursor != "" {
params["pageToken"] = cursor
}
return runWikiProductTool(cmd, runner, "doc", "search_documents", params)
},
}
preferLegacyLeaf(cmd)
addWikiNodeWorkspaceFlag(cmd)
cmd.Flags().String("query", "", "搜索关键词 (必填)")
addWikiHiddenStringFlag(cmd, "keyword", "--query 的兼容别名")
cmd.Flags().StringSlice("extensions", nil, "按扩展名过滤,逗号分隔")
cmd.Flags().Int("limit", 0, "每页数量")
cmd.Flags().String("cursor", "", "分页游标")
addWikiHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
return cmd
}
func runWikiTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
return runWikiProductTool(cmd, runner, "wiki", tool, params)
}
func runWikiProductTool(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"wiki",
product,
tool,
params,
)
@@ -344,6 +611,19 @@ func addWikiMemberListWorkspaceFlag(cmd *cobra.Command) {
addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
}
func addWikiNodeWorkspaceFlag(cmd *cobra.Command) {
cmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
addWikiHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
addWikiHiddenStringFlag(cmd, "workspaceId", "--workspace 的兼容别名")
}
func addWikiNodeIDFlags(cmd *cobra.Command) {
cmd.Flags().String("node", "", "节点 ID 或 URL (必填)")
addWikiHiddenStringFlag(cmd, "node-id", "--node 的兼容别名")
addWikiHiddenStringFlag(cmd, "doc-id", "--node 的兼容别名")
addWikiHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
}
func addWikiHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
@@ -397,3 +677,19 @@ func wikiCSV(raw string) []string {
}
return values
}
func splitWikiStringSlice(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
if item := strings.TrimSpace(part); item != "" {
out = append(out, item)
}
}
}
return out
}
func wikiUnsupportedCommand(name string) error {
return fmt.Errorf("unsupported wiki command: %s", name)
}
+8 -3
View File
@@ -38,15 +38,19 @@ func addWikiProxyCommands(root *cobra.Command, runner executor.Runner) {
newWikiProxyLeaf(runner, "search", wikiProxyTargetSpace, []string{"space", "search"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "create", wikiProxyTargetSpace, []string{"space", "create"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "get", wikiProxyTargetSpace, []string{"space", "get"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "delete", wikiProxyTargetSpace, []string{"space", "delete"}, wikiProxyOptions{}),
)
node := newWikiProxyGroup("node", "知识库节点兼容入口")
node.AddCommand(
newWikiProxyLeaf(runner, "list", wikiProxyTargetDoc, []string{"list"}, wikiProxyOptions{}),
newWikiNodeListCommand(runner),
newWikiProxyLeaf(runner, "read", wikiProxyTargetDoc, []string{"read"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "info", wikiProxyTargetDoc, []string{"info"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "create", wikiProxyTargetDoc, []string{"create"}, wikiProxyOptions{}),
newWikiProxyLeaf(runner, "search", wikiProxyTargetDoc, []string{"search"}, wikiProxyOptions{workspaceToWorkspaceIDs: true}),
newWikiNodeCreateCommand(runner),
newWikiNodeCopyCommand(runner),
newWikiNodeMoveCommand(runner),
newWikiNodeDeleteCommand(runner),
newWikiNodeSearchCommand(runner),
)
file := newWikiProxyGroup("file", "知识库文件兼容入口")
@@ -164,6 +168,7 @@ func newWikiProxySpaceTargetRoot(runner executor.Runner) *cobra.Command {
newWikiSpaceGetCommand(runner),
newWikiSpaceListCommand(runner),
newWikiSpaceSearchCommand(runner),
newWikiSpaceDeleteCommand(runner),
)
root.AddCommand(space)
return root
+89
View File
@@ -16,6 +16,7 @@ package helpers
import (
"bytes"
"context"
"reflect"
"strconv"
"strings"
"testing"
@@ -259,6 +260,94 @@ func TestWikiMemberAddUsesWorkspaceIDAlias(t *testing.T) {
}
}
func TestWikiSpaceListDriveTypesRouteToDrive(t *testing.T) {
t.Parallel()
runner := &wikiCommandRunner{}
cmd := wikiHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"space", "list", "--type", "orgSpace", "--limit", "10"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "drive" {
t.Fatalf("product = %q, want drive", runner.last.CanonicalProduct)
}
if runner.last.Tool != "list_spaces" {
t.Fatalf("tool = %q, want list_spaces", runner.last.Tool)
}
if got := runner.last.Params["spaceType"]; got != "orgSpace" {
t.Fatalf("spaceType = %#v, want orgSpace", got)
}
if got := runner.last.Params["maxResults"]; got != 10 {
t.Fatalf("maxResults = %#v, want 10", got)
}
}
func TestWikiNodeSearchRoutesToDoc(t *testing.T) {
t.Parallel()
runner := &wikiCommandRunner{}
cmd := wikiHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"node", "search",
"--workspace-id", "WS_001",
"--keyword", "方案",
"--extensions", "adoc,asheet",
"--limit", "5",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "doc" || runner.last.Tool != "search_documents" {
t.Fatalf("invocation = %#v, want doc search_documents", runner.last)
}
if got := runner.last.Params["keyword"]; got != "方案" {
t.Fatalf("keyword = %#v, want 方案", got)
}
if got := runner.last.Params["workspaceIds"]; !reflect.DeepEqual(got, []string{"WS_001"}) {
t.Fatalf("workspaceIds = %#v, want []string{WS_001}", got)
}
if got := runner.last.Params["extensions"]; !reflect.DeepEqual(got, []string{"adoc", "asheet"}) {
t.Fatalf("extensions = %#v, want adoc/asheet", got)
}
if got := runner.last.Params["pageSize"]; got != 5 {
t.Fatalf("pageSize = %#v, want 5", got)
}
}
func TestWikiMemberRemoveRoutesToWiki(t *testing.T) {
t.Parallel()
runner := &wikiCommandRunner{}
cmd := wikiHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"member", "remove", "--workspace-id", "WS_001", "--user", "uid1,uid2"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.CanonicalProduct != "wiki" || runner.last.Tool != "remove_member" {
t.Fatalf("invocation = %#v, want wiki remove_member", runner.last)
}
if got := runner.last.Params["workspaceId"]; got != "WS_001" {
t.Fatalf("workspaceId = %#v, want WS_001", got)
}
users, ok := runner.last.Params["userIds"].([]string)
if !ok || strings.Join(users, ",") != "uid1,uid2" {
t.Fatalf("userIds = %#v, want uid1,uid2", runner.last.Params["userIds"])
}
}
func TestWikiMemberUpdateAcceptsWukongUsersAlias(t *testing.T) {
t.Parallel()
+5 -3
View File
@@ -36,9 +36,11 @@ const (
// discoveryAPIPath is the path appended to BaseURL when fetching the
// MCP server list. Kept as a single constant so the version-coded
// segment (".../bamboo") lives in one place and future version bumps
// only touch here. See registry_test.go for the matching fixture path.
discoveryAPIPath = "/cli/discovery/apis/bamboo"
// segment (".../cedar") lives in one place and future version bumps
// only touch here. Version codes step by first letter (bamboo -> cedar
// -> ...); cedar carries the dws-wukong alignment. See registry_test.go
// for the matching fixture path.
discoveryAPIPath = "/cli/discovery/apis/cedar"
)
type Client struct {
+2 -2
View File
@@ -262,7 +262,7 @@ func TestFetchServers(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cli/discovery/apis/bamboo" {
if r.URL.Path != "/cli/discovery/apis/cedar" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
payload := ListResponse{
@@ -299,7 +299,7 @@ func TestFetchServersFollowsNextCursor(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cli/discovery/apis/bamboo" {
if r.URL.Path != "/cli/discovery/apis/cedar" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
+27
View File
@@ -52,3 +52,30 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool {
}
return cmd.Annotations[SourceAnnotation] == SourceEnvelope
}
// KindAnnotation records the structural role of a command. It distinguishes a
// pure group container (a heading whose RunE only prints help) from a runnable
// leaf. Both have a RunE set, so cobra's Runnable() cannot tell them apart —
// this annotation can. Kept here next to SourceAnnotation so the literals stay
// in one place.
const KindAnnotation = "dws.kind"
// KindGroup marks a command created as a group container (see NewGroupCommand).
const KindGroup = "group"
// MarkGroup stamps cmd as a group container. Safe on a command without an
// existing Annotations map.
func MarkGroup(cmd *cobra.Command) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[KindAnnotation] = KindGroup
}
// IsGroup reports whether cmd was created as a group container.
func IsGroup(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[KindAnnotation] == KindGroup
}
+9 -1
View File
@@ -20,6 +20,8 @@ package edition
// their environment.
const DefaultOSSClawType = "openClaw"
const openAitableHelperEndpoint = "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95"
// defaultHooks returns the open-source edition defaults.
//
// MergeHeaders is the only hook that ships with behaviour: it pins the
@@ -43,10 +45,16 @@ func defaultHooks() *Hooks {
func openSupplementServers() []ServerInfo {
return []ServerInfo{
{
ID: "aitable-helper",
Name: "AI 多维表(辅助)",
Endpoint: openAitableHelperEndpoint,
Prefixes: []string{"form", "share_form"},
},
{
ID: "aitable-form",
Name: "AI 多维表(表单)",
Endpoint: "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95",
Endpoint: openAitableHelperEndpoint,
Prefixes: []string{"form", "share_form"},
},
}
+24
View File
@@ -34,3 +34,27 @@ func TestClawTypeUsesOverlayValue(t *testing.T) {
t.Fatalf("ClawType() = %q, want overlay value %q", got, "wukong")
}
}
func TestOpenSupplementServersIncludeAitableHelperAlias(t *testing.T) {
servers := openSupplementServers()
byID := make(map[string]ServerInfo, len(servers))
for _, server := range servers {
byID[server.ID] = server
}
helper, ok := byID["aitable-helper"]
if !ok {
t.Fatalf("openSupplementServers() missing aitable-helper: %#v", servers)
}
if helper.Endpoint != openAitableHelperEndpoint {
t.Fatalf("aitable-helper endpoint = %q, want %q", helper.Endpoint, openAitableHelperEndpoint)
}
form, ok := byID["aitable-form"]
if !ok {
t.Fatalf("openSupplementServers() missing aitable-form: %#v", servers)
}
if form.Endpoint != openAitableHelperEndpoint {
t.Fatalf("aitable-form endpoint = %q, want %q", form.Endpoint, openAitableHelperEndpoint)
}
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0 (the "License").
#
# check-phantom-overrides.py — publish-time guard against "phantom" CLI
# commands: toolOverrides in the discovery envelope whose backing MCP tool is
# not actually deployed on the server. Such overrides render in `dws <svc>
# --help` but fail at invocation ("tool not found"). This is the discovery-side
# (Plan A) complement to the runtime guard in internal/compat/dynamic_commands.go.
#
# TRUTH SOURCE: the live tools/list snapshots written by `dws cache refresh`
# into <cache-dir>/<partition>/tools/*.json, mapped to servers via
# <cache-dir>/<partition>/market/servers.json (cli.id slug -> server key).
#
# An override is a phantom ONLY if its tool name is absent from the resolved
# server's live tool set AND it is not otherwise explained:
# - serverOverride: resolve against the TARGET server's tool set;
# - pipeline: orchestrates multiple tools, no single backing tool;
# - redirectTo/target: a redirect stub, not a real tool invocation;
# - hidden:true: explicitly acknowledged (e.g. lead metadata for a tool
# not yet deployed) — reported as INFO, never fails CI.
#
# Exit code: 1 if any UN-acknowledged phantom override is found, else 0.
#
# Usage:
# dws cache refresh
# python3 scripts/dev/check-phantom-overrides.py
# python3 scripts/dev/check-phantom-overrides.py --envelope envelope/discovery.pre.json
import argparse
import glob
import json
import os
import sys
CLI_META_KEY = "com.dingtalk.mcp.registry/cli"
def load_real_tools(cache_dir, partition):
"""Return (name2tools, slug2tools): display-name and cli.id slug -> set(tool names)."""
base = os.path.join(cache_dir, partition)
real_by_key = {}
for f in glob.glob(os.path.join(base, "tools", "*.json")):
try:
d = json.load(open(f))
except (OSError, ValueError):
continue
names = {(t.get("name") or "").strip() for t in (d.get("tools") or [])}
names.discard("")
real_by_key[d.get("server_key")] = names
servers_path = os.path.join(base, "market", "servers.json")
if not os.path.exists(servers_path):
sys.exit(f"error: {servers_path} not found — run `dws cache refresh` first")
servers = json.load(open(servers_path)).get("servers", [])
name2tools, slug2tools = {}, {}
for s in servers:
ts = real_by_key.get(s.get("key"), set())
if s.get("display_name"):
name2tools[s["display_name"].strip()] = ts
slug = ((s.get("cli") or {}).get("id") or "").strip()
if slug:
slug2tools[slug] = ts
return name2tools, slug2tools, real_by_key
def cli_block(server):
"""toolOverrides live under _meta['com.dingtalk.mcp.registry/cli'] (flat slash key)."""
return (server.get("_meta", {}) or {}).get(CLI_META_KEY, {}) or {}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--envelope", default="envelope/discovery.pre.json")
ap.add_argument("--cache-dir", default=os.path.expanduser("~/.dws/cache"))
ap.add_argument("--partition", default="default_default")
args = ap.parse_args()
if not os.path.exists(args.envelope):
sys.exit(f"error: envelope not found: {args.envelope}")
name2tools, slug2tools, real_by_key = load_real_tools(args.cache_dir, args.partition)
if not real_by_key:
sys.exit("error: no tools snapshots in cache — run `dws cache refresh` first")
env = json.load(open(args.envelope))
phantom, acknowledged = [], []
for s in env.get("servers", []):
cli = cli_block(s)
name = (s.get("server", {}).get("name") or "").strip()
own = name2tools.get(name, set())
ov = cli.get("toolOverrides") or {}
for tool, o in ov.items():
so = (o.get("serverOverride") or "").strip()
if o.get("pipeline") or o.get("redirectTo") or o.get("target"):
continue
target = slug2tools.get(so, set()) if so else own
if tool in target:
continue
entry = (name, tool, o.get("cliName", ""), o.get("group", ""), so or "self")
(acknowledged if o.get("hidden") else phantom).append(entry)
if acknowledged:
print(f"INFO: {len(acknowledged)} acknowledged (hidden:true) phantom overrides — OK:")
for name, tool, cn, g, via in acknowledged:
print(f" [hidden] {name}: {tool} (cli={cn!r} group={g!r} via={via})")
if phantom:
print(f"\nFAIL: {len(phantom)} un-acknowledged phantom override(s) "
f"(tool not deployed; will fail at invocation):")
for name, tool, cn, g, via in phantom:
print(f" {name}: {tool} -> cli={cn!r} group={g!r} via={via}")
print("\nFix: remove the override, route via serverOverride/pipeline, "
"or mark hidden:true if it is intentional lead metadata.")
return 1
print(f"\nOK: no un-acknowledged phantom overrides in {args.envelope}")
return 0
if __name__ == "__main__":
sys.exit(main())
+623
View File
@@ -0,0 +1,623 @@
#!/usr/bin/env bash
# End-to-end regression script for multi-profile / multi-organization login.
# It uses an isolated DWS_CONFIG_DIR and DWS_KEYCHAIN_DIR, seeds post-login
# token results through the production auth storage API, then verifies the real
# dws CLI command surface.
#
# Usage:
# bash scripts/dev/test-multi-profile-e2e.sh
# bash scripts/dev/test-multi-profile-e2e.sh --skip-go-tests --verbose
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_GO_TESTS=1
VERBOSE=0
KEEP_WORKDIR=0
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-go-tests)
RUN_GO_TESTS=0
shift
;;
--verbose)
VERBOSE=1
shift
;;
--keep-workdir)
KEEP_WORKDIR=1
shift
;;
-h|--help)
sed -n '1,12p' "$0"
exit 0
;;
*)
echo "unknown option: $1" >&2
exit 2
;;
esac
done
mkdir -p "$ROOT/.tmp-bin"
WORKDIR="$(mktemp -d "$ROOT/.tmp-bin/multi-profile-e2e.XXXXXX")"
BIN="$WORKDIR/bin/dws"
HELPER_DIR="$WORKDIR/helper"
CONFIG_DIR="$WORKDIR/config"
KEYCHAIN_DIR="$WORKDIR/keychain"
CACHE_DIR="$WORKDIR/cache"
OUT_DIR="$WORKDIR/out"
cleanup() {
if [[ "$KEEP_WORKDIR" -eq 1 ]]; then
echo "[INFO] kept workdir: $WORKDIR"
else
rm -rf "$WORKDIR"
fi
}
trap cleanup EXIT
export DWS_CONFIG_DIR="$CONFIG_DIR"
export DWS_KEYCHAIN_DIR="$KEYCHAIN_DIR"
export DWS_DISABLE_KEYCHAIN=1
export DWS_CACHE_DIR="$CACHE_DIR"
export DWS_PERF_REPORT=
export DWS_PERF_DEBUG=
mkdir -p "$HELPER_DIR" "$CONFIG_DIR" "$KEYCHAIN_DIR" "$CACHE_DIR" "$OUT_DIR" "$(dirname "$BIN")"
log() {
printf '\n==> %s\n' "$*"
}
fail() {
echo "[FAIL] $*" >&2
exit 1
}
run() {
if [[ "$VERBOSE" -eq 1 ]]; then
"$@"
else
"$@" >/dev/null
fi
}
capture() {
local file="$1"
shift
if [[ "$VERBOSE" -eq 1 ]]; then
echo "+ $*" >&2
fi
"$@" >"$file" 2>"$file.stderr"
}
expect_contains() {
local file="$1"
local needle="$2"
if ! grep -F -- "$needle" "$file" >/dev/null; then
echo "----- $file -----" >&2
cat "$file" >&2
fail "expected $file to contain: $needle"
fi
}
expect_not_contains_line_command() {
local file="$1"
local command="$2"
if grep -E "^[[:space:]]+$command([[:space:]]|$)" "$file" >/dev/null; then
echo "----- $file -----" >&2
cat "$file" >&2
fail "did not expect command '$command' in $file"
fi
}
expect_fail() {
local needle="$1"
shift
local output
set +e
output="$("$@" 2>&1)"
local code=$?
set -e
if [[ "$code" -eq 0 ]]; then
echo "$output" >&2
fail "expected command to fail: $*"
fi
if ! grep -F -- "$needle" <<<"$output" >/dev/null; then
echo "$output" >&2
fail "expected failure output to contain: $needle"
fi
}
cat >"$HELPER_DIR/main.go" <<'GOEOF'
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
auth "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
type profileListResponse struct {
Success bool `json:"success"`
PrimaryProfile string `json:"primaryProfile"`
CurrentProfile string `json:"currentProfile"`
PreviousProfile string `json:"previousProfile"`
Profiles []profileView `json:"profiles"`
}
type profileUseResponse struct {
Success bool `json:"success"`
Profile profileView `json:"profile"`
}
type profileView struct {
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
UserID string `json:"userId"`
UserName string `json:"userName"`
Status string `json:"status"`
IsPrimary bool `json:"isPrimary"`
IsCurrent bool `json:"isCurrent"`
}
type authStatusResponse struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
TokenValid bool `json:"token_valid"`
RefreshTokenValid bool `json:"refresh_token_valid"`
CorpID string `json:"corp_id"`
CorpName string `json:"corp_name"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
}
type multiProfileResponse struct {
Success bool `json:"success"`
MultiProfile bool `json:"multiProfile"`
Summary multiProfileSummary `json:"summary"`
Profiles []multiProfileResult `json:"profiles"`
}
type multiProfileSummary struct {
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}
type multiProfileResult struct {
Selector string `json:"selector"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
OK bool `json:"ok"`
Result map[string]any `json:"result"`
}
func main() {
if len(os.Args) < 2 {
die("missing helper command")
}
configDir := os.Getenv("DWS_CONFIG_DIR")
if strings.TrimSpace(configDir) == "" {
die("DWS_CONFIG_DIR is required")
}
switch os.Args[1] {
case "seed":
needArgs(7)
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
must(auth.SaveTokenData(configDir, data))
case "seed-legacy":
needArgs(7)
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
must(auth.SaveTokenDataKeychain(data))
must(auth.WriteTokenMarker(configDir))
case "write-app-config":
needArgs(4)
must(auth.SaveAppConfig(configDir, &auth.AppConfig{
ClientID: os.Args[2],
ClientSecret: auth.PlainSecret(os.Args[3]),
}))
case "assert-app-config":
needArgs(3)
cfg, err := auth.LoadAppConfig(configDir)
must(err)
switch os.Args[2] {
case "exists":
if cfg == nil || strings.TrimSpace(cfg.ClientID) == "" {
die("expected app config to exist")
}
case "absent":
if cfg != nil {
die("expected app config to be absent, got clientID=%q", cfg.ClientID)
}
default:
die("unknown app config expectation %q", os.Args[2])
}
case "assert-profiles":
needArgs(6)
cfg, err := auth.LoadProfiles(configDir)
must(err)
wantCount := atoi(os.Args[2])
if len(cfg.Profiles) != wantCount {
die("profiles len=%d, want %d: %#v", len(cfg.Profiles), wantCount, cfg.Profiles)
}
assertEqual("primaryProfile", cfg.PrimaryProfile, emptySentinel(os.Args[3]))
assertEqual("currentProfile", cfg.CurrentProfile, emptySentinel(os.Args[4]))
assertEqual("previousProfile", cfg.PreviousProfile, emptySentinel(os.Args[5]))
assertNoSecrets(configDir)
assertProfileMetadata(cfg)
case "assert-list-json":
needArgs(7)
var resp profileListResponse
raw := readJSON(os.Args[2], &resp)
if strings.Contains(string(raw), `"name"`) {
die("profile list JSON must not expose local name: %s", string(raw))
}
if !resp.Success {
die("profile list success=false")
}
wantCount := atoi(os.Args[3])
if len(resp.Profiles) != wantCount {
die("list profiles len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles)
}
assertEqual("list primaryProfile", resp.PrimaryProfile, emptySentinel(os.Args[4]))
assertEqual("list currentProfile", resp.CurrentProfile, emptySentinel(os.Args[5]))
assertEqual("list previousProfile", resp.PreviousProfile, emptySentinel(os.Args[6]))
for _, p := range resp.Profiles {
if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" {
die("profile list item missing corp identity: %#v", p)
}
if p.CorpID == resp.PrimaryProfile && !p.IsPrimary {
die("profile %s should be primary", p.CorpID)
}
if p.CorpID == resp.CurrentProfile && !p.IsCurrent {
die("profile %s should be current", p.CorpID)
}
}
case "assert-switch-json":
needArgs(5)
var resp profileUseResponse
readJSON(os.Args[2], &resp)
if !resp.Success {
die("switch JSON success=false")
}
assertEqual("switch corpId", resp.Profile.CorpID, os.Args[3])
assertEqual("switch corpName", resp.Profile.CorpName, os.Args[4])
if !resp.Profile.IsCurrent {
die("switch profile isCurrent=false")
}
case "assert-status-json":
needArgs(6)
var resp authStatusResponse
readJSON(os.Args[2], &resp)
if !resp.Success || !resp.Authenticated || !resp.TokenValid || !resp.RefreshTokenValid {
die("bad auth status response: %#v", resp)
}
assertEqual("status corpId", resp.CorpID, os.Args[3])
assertEqual("status corpName", resp.CorpName, os.Args[4])
assertEqual("status userId", resp.UserID, os.Args[5])
case "assert-multi-profile-json":
needArgs(5)
var resp multiProfileResponse
readJSON(os.Args[2], &resp)
if !resp.Success || !resp.MultiProfile {
die("bad multi-profile response: %#v", resp)
}
wantCount := atoi(os.Args[3])
if len(resp.Profiles) != wantCount {
die("multi-profile len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles)
}
if resp.Summary.Total != wantCount || resp.Summary.Succeeded != wantCount || resp.Summary.Failed != 0 {
die("bad multi-profile summary: %#v", resp.Summary)
}
wantCorpIDs := strings.Split(os.Args[4], ",")
if len(wantCorpIDs) != wantCount {
die("want corpId count=%d, want %d", len(wantCorpIDs), wantCount)
}
for i, want := range wantCorpIDs {
want = strings.TrimSpace(want)
got := resp.Profiles[i]
if !got.OK {
die("profile %d ok=false: %#v", i, got)
}
assertEqual(fmt.Sprintf("multi-profile corpId[%d]", i), got.CorpID, want)
if got.Result["_mock"] != true {
die("profile %s result is not mock payload: %#v", got.CorpID, got.Result)
}
}
case "assert-token":
needArgs(5)
data, err := loadToken(configDir, os.Args[2])
must(err)
assertEqual("token corpId", data.CorpID, os.Args[3])
assertEqual("token access", data.AccessToken, os.Args[4])
case "assert-empty-auth":
needArgs(2)
cfg, err := auth.LoadProfiles(configDir)
must(err)
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
die("expected empty profiles after reset, got %#v", cfg)
}
if auth.TokenDataExistsKeychain() {
die("legacy auth-token still exists")
}
case "assert-duplicate-name-fallback":
needArgs(4)
cfg, err := auth.LoadProfiles(configDir)
must(err)
p := findProfile(cfg, os.Args[2])
if p == nil {
die("profile %q not found", os.Args[2])
}
if p.CorpName != os.Args[3] {
die("profile %s corpName=%q, want %q", p.CorpID, p.CorpName, os.Args[3])
}
if p.Name == os.Args[3] || !strings.HasPrefix(p.Name, os.Args[3]+"-") {
die("profile %s name=%q, want stable fallback prefix %q", p.CorpID, p.Name, os.Args[3]+"-")
}
default:
die("unknown helper command %q", os.Args[1])
}
}
func token(corpID, corpName, userID, userName, access string) *auth.TokenData {
return &auth.TokenData{
AccessToken: access,
RefreshToken: "refresh-" + corpID,
PersistentCode: "persistent-" + corpID,
ExpiresAt: time.Now().Add(2 * time.Hour),
RefreshExpAt: time.Now().Add(720 * time.Hour),
CorpID: corpID,
CorpName: corpName,
UserID: userID,
UserName: userName,
ClientID: "client-" + corpID,
Source: "multi-profile-e2e",
}
}
func needArgs(n int) {
if len(os.Args) != n {
die("%s: got %d args, want %d", os.Args[1], len(os.Args)-2, n-2)
}
}
func loadToken(configDir, selector string) (*auth.TokenData, error) {
if selector == "default" {
return auth.LoadTokenData(configDir)
}
return auth.LoadTokenDataForProfile(configDir, selector)
}
func readJSON(path string, dst any) []byte {
data, err := os.ReadFile(path)
must(err)
if err := json.Unmarshal(data, dst); err != nil {
die("parse %s: %v\n%s", path, err, string(data))
}
return data
}
func assertProfileMetadata(cfg *auth.ProfilesConfig) {
names := map[string]string{}
for _, p := range cfg.Profiles {
if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" {
die("profile missing corp metadata: %#v", p)
}
if prev, ok := names[p.Name]; ok {
die("duplicate profile local name %q for %s and %s", p.Name, prev, p.CorpID)
}
names[p.Name] = p.CorpID
}
}
func assertNoSecrets(configDir string) {
data, err := os.ReadFile(filepath.Join(configDir, "profiles.json"))
if err != nil {
if os.IsNotExist(err) {
return
}
must(err)
}
for _, forbidden := range []string{"access_token", "refresh_token", "persistent_code", "client_secret"} {
if strings.Contains(string(data), forbidden) {
die("profiles.json contains secret field %q", forbidden)
}
}
}
func findProfile(cfg *auth.ProfilesConfig, corpID string) *auth.Profile {
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == corpID {
return &cfg.Profiles[i]
}
}
return nil
}
func atoi(raw string) int {
var n int
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil {
die("invalid integer %q", raw)
}
return n
}
func emptySentinel(s string) string {
if s == "_" {
return ""
}
return s
}
func assertEqual(label, got, want string) {
if got != want {
die("%s=%q, want %q", label, got, want)
}
}
func must(err error) {
if err != nil {
die("%v", err)
}
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
GOEOF
cd "$ROOT"
if [[ "$RUN_GO_TESTS" -eq 1 ]]; then
log "running multi-profile Go regressions"
go test -timeout 180s -count=1 ./internal/auth ./internal/app ./test/cli
fi
log "building dws"
run go build -o "$BIN" ./cmd
helper() {
go run "$HELPER_DIR" "$@"
}
log "checking command surface"
capture "$OUT_DIR/root-help.txt" "$BIN" --help
expect_contains "$OUT_DIR/root-help.txt" "--profile"
expect_contains "$OUT_DIR/root-help.txt" "--yes"
expect_contains "$OUT_DIR/root-help.txt" "--dry-run"
expect_contains "$OUT_DIR/root-help.txt" "profile"
capture "$OUT_DIR/profile-help.txt" "$BIN" profile --help
expect_contains "$OUT_DIR/profile-help.txt" "list"
expect_contains "$OUT_DIR/profile-help.txt" "switch"
expect_contains "$OUT_DIR/profile-help.txt" "use"
expect_contains "$OUT_DIR/profile-help.txt" "--profile"
capture "$OUT_DIR/auth-login-help.txt" "$BIN" auth login --help
expect_contains "$OUT_DIR/auth-login-help.txt" "--device"
expect_contains "$OUT_DIR/auth-login-help.txt" "--token"
expect_contains "$OUT_DIR/auth-login-help.txt" "--recommend"
expect_contains "$OUT_DIR/auth-login-help.txt" "--yes"
capture "$OUT_DIR/skill-setup-help.txt" "$BIN" skill setup --help
expect_contains "$OUT_DIR/skill-setup-help.txt" "--mode"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--target"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--yes"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--skill"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--exclude"
capture "$OUT_DIR/upgrade-help.txt" "$BIN" upgrade --help
expect_contains "$OUT_DIR/upgrade-help.txt" "--dry-run"
expect_contains "$OUT_DIR/upgrade-help.txt" "--yes"
capture "$OUT_DIR/dev-connect-help.txt" "$BIN" dev connect --help
expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-id"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-secret"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--unified-app-id"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--agent-cmd"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--daemon"
capture "$OUT_DIR/doc-delete-help.txt" "$BIN" doc delete --help
expect_contains "$OUT_DIR/doc-delete-help.txt" "--yes"
capture "$OUT_DIR/aitable-base-delete-help.txt" "$BIN" aitable base delete --help
expect_contains "$OUT_DIR/aitable-base-delete-help.txt" "--yes"
capture "$OUT_DIR/auth-help.txt" "$BIN" auth --help
expect_not_contains_line_command "$OUT_DIR/auth-help.txt" "switch"
log "verifying empty profile list"
capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-empty.json" 0 _ _ _
log "seeding first organization profile"
helper seed corp_alpha "Alpha Org" user_alpha "Alice Alpha" access-alpha-v1
capture "$OUT_DIR/list-alpha.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-alpha.json" 1 corp_alpha corp_alpha _
helper assert-profiles 1 corp_alpha corp_alpha _
helper assert-token default corp_alpha access-alpha-v1
helper assert-token corp_alpha corp_alpha access-alpha-v1
capture "$OUT_DIR/status-alpha-default.json" "$BIN" auth status --format json
helper assert-status-json "$OUT_DIR/status-alpha-default.json" corp_alpha "Alpha Org" user_alpha
log "seeding second organization profile"
helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v1
capture "$OUT_DIR/list-alpha-beta.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-alpha-beta.json" 2 corp_alpha corp_beta corp_alpha
helper assert-profiles 2 corp_alpha corp_beta corp_alpha
helper assert-token default corp_beta access-beta-v1
helper assert-token corp_alpha corp_alpha access-alpha-v1
helper assert-token corp_beta corp_beta access-beta-v1
log "refreshing existing organization without duplicating profile"
helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v2
capture "$OUT_DIR/list-beta-refresh.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-beta-refresh.json" 2 corp_alpha corp_beta corp_alpha
helper assert-profiles 2 corp_alpha corp_beta corp_alpha
helper assert-token corp_beta corp_beta access-beta-v2
log "seeding duplicate organization name and checking stable fallback"
helper seed corp_gamma "Beta Org" user_gamma "Gina Gamma" access-gamma-v1
capture "$OUT_DIR/list-duplicate-name.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-duplicate-name.json" 3 corp_alpha corp_gamma corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_beta
helper assert-duplicate-name-fallback corp_gamma "Beta Org"
log "switching profiles and verifying legacy mirror"
capture "$OUT_DIR/switch-alpha.json" "$BIN" profile switch corp_alpha --format json
helper assert-switch-json "$OUT_DIR/switch-alpha.json" corp_alpha "Alpha Org"
helper assert-profiles 3 corp_alpha corp_alpha corp_gamma
helper assert-token default corp_alpha access-alpha-v1
capture "$OUT_DIR/switch-beta.txt" "$BIN" profile switch corp_beta --format table
expect_contains "$OUT_DIR/switch-beta.txt" "Beta Org"
expect_contains "$OUT_DIR/switch-beta.txt" "corp_beta"
helper assert-profiles 3 corp_alpha corp_beta corp_alpha
helper assert-token default corp_beta access-beta-v2
capture "$OUT_DIR/switch-previous.json" "$BIN" profile switch - --format json
helper assert-switch-json "$OUT_DIR/switch-previous.json" corp_alpha "Alpha Org"
helper assert-profiles 3 corp_alpha corp_alpha corp_beta
capture "$OUT_DIR/use-gamma.json" "$BIN" profile use corp_gamma --format json
helper assert-switch-json "$OUT_DIR/use-gamma.json" corp_gamma "Beta Org"
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
log "checking profile switch validation"
expect_fail "profile selector required" "$BIN" profile switch
expect_fail "只能指定一个组织选择器" "$BIN" profile switch corp_alpha --corpId corp_beta
expect_fail "missing_org" "$BIN" profile switch missing_org
log "checking one-shot profile override without changing current profile"
capture "$OUT_DIR/status-root-profile-alpha.json" "$BIN" --profile corp_alpha auth status --format json
helper assert-status-json "$OUT_DIR/status-root-profile-alpha.json" corp_alpha "Alpha Org" user_alpha
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
capture "$OUT_DIR/status-local-profile-beta.json" "$BIN" auth status --profile corp_beta --format json
helper assert-status-json "$OUT_DIR/status-local-profile-beta.json" corp_beta "Beta Org" user_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
capture "$OUT_DIR/status-current-gamma.json" "$BIN" auth status --format json
helper assert-status-json "$OUT_DIR/status-current-gamma.json" corp_gamma "Beta Org" user_gamma
capture "$OUT_DIR/contact-multi-profile.json" "$BIN" --mock --profile corp_alpha, corp_beta contact user get-self --format json
helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile.json" 2 corp_alpha,corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
capture "$OUT_DIR/contact-multi-profile-leaf-profile.json" "$BIN" --mock contact user get-self --profile corp_alpha, corp_beta --format json
helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile-leaf-profile.json" 2 corp_alpha,corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
log "checking auth reset cleanup"
helper write-app-config client-reset secret-reset
helper assert-app-config exists
capture "$OUT_DIR/auth-reset.txt" "$BIN" auth reset
expect_contains "$OUT_DIR/auth-reset.txt" "[OK]"
helper assert-empty-auth
helper assert-app-config absent
log "checking legacy single-slot migration"
helper seed-legacy corp_legacy "Legacy Org" user_legacy "Lena Legacy" access-legacy-v1
helper assert-profiles 0 _ _ _
capture "$OUT_DIR/list-legacy-migrated.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-legacy-migrated.json" 1 corp_legacy corp_legacy _
helper assert-profiles 1 corp_legacy corp_legacy _
helper assert-token default corp_legacy access-legacy-v1
helper assert-token corp_legacy corp_legacy access-legacy-v1
log "multi-profile e2e passed"
echo "[PASS] isolated multi-profile chain completed"
+10 -10
View File
@@ -1,22 +1,22 @@
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# One-command installer for the dws Dev preview on native Windows (PowerShell).
# Downloads the dev binary (dws.exe) + dingtalk-dev skill from the fork's GitHub Releases.
# One-command installer for dws dev on native Windows (PowerShell).
# Downloads the dev binary (dws.exe) + dingtalk-dev skill from the DingTalk-Real-AI GitHub Releases.
#
# Usage:
# irm https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.ps1 | iex
# irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
#
# Env (all optional):
# DEVAPP_REPO fork holding dev releases (default: wxianfeng/dingtalk-workspace-cli)
# DEVAPP_VERSION pin a dev release tag (default: latest release on the fork)
# DEVAPP_REPO repo holding dev releases (default: DingTalk-Real-AI/dingtalk-workspace-cli)
# DEVAPP_VERSION pin a release tag (default: latest release)
# DWS_ARCH architecture override (amd64 or arm64)
# DWS_INSTALL_DIR binary dir (default: ~/.local/bin)
# DWS_NO_SKILLS set 1 to skip the dev skill
$ErrorActionPreference = "Stop"
$Repo = if ($env:DEVAPP_REPO) { $env:DEVAPP_REPO } else { "wxianfeng/dingtalk-workspace-cli" }
$Repo = if ($env:DEVAPP_REPO) { $env:DEVAPP_REPO } else { "DingTalk-Real-AI/dingtalk-workspace-cli" }
$Version = $env:DEVAPP_VERSION
$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
@@ -40,15 +40,15 @@ function Get-Arch {
}
}
# GitHub's /releases/latest excludes prereleases; read the releases list (newest
# first) and take the top tag — the dev preview is published as a prerelease.
# Read the releases list (newest first) and take the top tag, so this also works
# if a release is ever published as a prerelease (which /releases/latest skips).
if (-not $Version) {
try {
$rel = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases?per_page=1" `
-Headers @{ "User-Agent" = "dws-devapp-installer" } -UseBasicParsing
$Version = $rel[0].tag_name
} catch {}
if (-not $Version) { Die "No release found on $Repo. Push a dev tag (e.g. v1.0.39-dev.1) to trigger CI, or set DEVAPP_VERSION." }
if (-not $Version) { Die "No release found on $Repo. Set DEVAPP_VERSION to a published release tag." }
}
$arch = Get-Arch
@@ -56,7 +56,7 @@ $tmp = Join-Path $env:TEMP ("dws-dev-" + [System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
Write-Host ""
Say "dws Dev preview installer (Windows, pre-built binary)"
Say "dws dev installer (Windows, pre-built binary)"
Say "Repo: $Repo"
Say "Version: $Version"
Say "Target: windows/$arch"
+10 -10
View File
@@ -2,21 +2,21 @@
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# One-command installer for the dws Dev preview — pre-built binary, no build tools.
# Downloads the dev binary + dingtalk-dev skill from the fork's GitHub Releases.
# One-command installer for dws dev — pre-built binary, no build tools.
# Downloads the dev binary + dingtalk-dev skill from the DingTalk-Real-AI GitHub Releases.
# Requires only curl + tar (no go / make / git).
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/wxianfeng/dingtalk-workspace-cli/feat/dws-devapp/scripts/install-devapp.sh | sh
# curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
#
# Env (all optional):
# DEVAPP_REPO fork holding dev releases (default: wxianfeng/dingtalk-workspace-cli)
# DEVAPP_VERSION pin a dev release tag (default: latest release on the fork)
# DEVAPP_REPO repo holding dev releases (default: DingTalk-Real-AI/dingtalk-workspace-cli)
# DEVAPP_VERSION pin a release tag (default: latest release)
# DWS_INSTALL_DIR binary dir (default: ~/.local/bin)
# DWS_NO_SKILLS set 1 to skip the dev skill
set -eu
DEVAPP_REPO="${DEVAPP_REPO:-wxianfeng/dingtalk-workspace-cli}"
DEVAPP_REPO="${DEVAPP_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
DEVAPP_VERSION="${DEVAPP_VERSION:-}"
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
NO_SKILLS="${DWS_NO_SKILLS:-0}"
@@ -45,8 +45,8 @@ detect_arch() {
esac
}
# GitHub's /releases/latest excludes prereleases, so read the releases list
# (newest first) and take the top tag — the dev preview is published as a prerelease.
# Read the releases list (newest first) and take the top tag, so this also works
# if a release is ever published as a prerelease (which /releases/latest skips).
# Prefer `gh` CLI (authenticated, 5 000 req/h) over raw curl (60 req/h, easily rate-limited).
resolve_version() {
[ -n "$DEVAPP_VERSION" ] && return 0
@@ -68,7 +68,7 @@ resolve_version() {
DEVAPP_VERSION="$(grep -m1 '"tag_name"' "$_tmpfile" | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')"
rm -f "$_tmpfile"
[ -n "$DEVAPP_VERSION" ] || err "No release found on ${DEVAPP_REPO}. Push a dev tag (e.g. v1.0.39-dev.1) to trigger CI, or set DEVAPP_VERSION."
[ -n "$DEVAPP_VERSION" ] || err "No release found on ${DEVAPP_REPO}. Set DEVAPP_VERSION to a published release tag."
}
install_skill() {
@@ -104,7 +104,7 @@ main() {
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT INT TERM
printf '\n'
say "dws Dev preview installer (pre-built binary)"
say "dws dev installer (pre-built binary)"
say "Repo: ${DEVAPP_REPO}"
say "Version: ${DEVAPP_VERSION}"
say "Target: ${os}/${arch}"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# Build and install dws directly from a Git branch checkout.
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/shangguanxuan633-lab/dingtalk-workspace-cli/codex/dws-multi-profile-login/scripts/install-from-branch.sh | sh
#
# Environment variables:
# DWS_SOURCE_REPO owner/repo to clone (default: shangguanxuan633-lab/dingtalk-workspace-cli)
# DWS_SOURCE_BRANCH branch to build (default: codex/dws-multi-profile-login)
# DWS_INSTALL_DIR passed through to scripts/install.sh (default there: ~/.local/bin)
# DWS_INSTALL_NAME passed through to scripts/install.sh (default: dws)
# DWS_NO_SKILLS passed through to scripts/install.sh (set 1 to skip skills)
# DWS_KEEP_SOURCE set 1 to keep the temporary source checkout
set -eu
REPO="${DWS_SOURCE_REPO:-shangguanxuan633-lab/dingtalk-workspace-cli}"
BRANCH="${DWS_SOURCE_BRANCH:-codex/dws-multi-profile-login}"
KEEP_SOURCE="${DWS_KEEP_SOURCE:-0}"
say() {
printf ' %s\n' "$@"
}
err() {
printf ' ❌ %s\n' "$@" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"
}
need_cmd git
need_cmd sh
tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t dws-src)"
cleanup() {
if [ "$KEEP_SOURCE" != "1" ]; then
rm -rf "$tmpdir"
else
say "Source checkout kept at: $tmpdir"
fi
}
trap cleanup EXIT INT TERM
say "Cloning dws source:"
say " repo: https://github.com/${REPO}.git"
say " branch: ${BRANCH}"
git clone --depth 1 --branch "$BRANCH" "https://github.com/${REPO}.git" "$tmpdir"
say "Building and installing from source..."
sh "$tmpdir/scripts/install.sh"
+2
View File
@@ -27,6 +27,8 @@ $ErrorActionPreference = "Stop"
$Repo = "DingTalk-Real-AI/dingtalk-workspace-cli"
$BinName = "dws"
# GitHub "latest release" URL; Resolve-LatestVersion follows its redirect to get the tag.
$LatestUrl = "https://github.com/$Repo/releases/latest"
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via the Gitee API.
$GiteeRepo = if ($env:DWS_GITEE_REPO) { $env:DWS_GITEE_REPO } else { "" }
# Auto-fallback Gitee mirror used when GitHub is unreachable (see Resolve-Source).
+83 -11
View File
@@ -62,23 +62,95 @@ fi
[ -n "$release_id" ] || { echo "❌ Could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" >&2; exit 1; }
echo " Gitee release id = ${release_id}"
# ── Upload each artifact as a release attachment ──────────────────────────────
# ── Mirror each artifact, verifying content so re-runs self-heal ─────────────
# Pull the current attachment list (name + attach id + download url) so we can,
# per file:
# • skip it when it is already on Gitee, unique, AND byte-identical,
# • REPLACE it when present but stale (different bytes) — e.g. the darwin
# binaries are re-signed and so differ between GitHub and a prior mirror
# run, which breaks install.sh's checksums.txt verification on macOS,
# • DEDUP it when the same name has >1 attachment — a prior run that failed to
# delete (see below) left the old copy *and* an extra upload; Gitee then
# serves the OLDER one by name, so the stale binary wins. We delete every
# copy and upload one fresh.
# • upload it when missing.
# This brings the Gitee release into byte-for-byte agreement with $DIST_DIR,
# which the caller fills from the GitHub release whose checksums.txt is what
# install.sh verifies against.
#
# We list attachments via the dedicated /attach_files endpoint, NOT the release
# detail (/releases/{id}) endpoint: the latter's "assets" array omits the attach
# id, so DELETE /attach_files/{id} was previously called with an empty id and
# silently no-op'd — leaving stale + duplicate darwin binaries on Gitee.
assets_map="$(curl -fsSL "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
| python3 -c 'import json,sys
try:
data=json.load(sys.stdin)
rows=data if isinstance(data,list) else data.get("attach_files",[])
for a in rows:
n=a.get("name",""); i=a.get("id",""); u=a.get("browser_download_url","")
if n and i!="":
print("%s\t%s\t%s" % (n, i, u))
except Exception:
pass' 2>/dev/null || true)"
sha256_of() { # sha256 of a file ($1) or, with no arg, of stdin
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}';
else shasum -a 256 ${1:+"$1"} | awk '{print $1}'; fi
}
gitee_attach() { # upload file $1; success when the response carries a download url
printf '%s' "$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
-F "access_token=${GITEE_TOKEN}" -F "file=@${1}" 2>/dev/null || true)" \
| grep -q '"browser_download_url"'
}
gitee_delete() { # delete attachment by id $1
curl -fsSL -X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
>/dev/null 2>&1 || true
}
uploaded=0
replaced=0
skipped=0
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
[ -f "$f" ] || continue
fn="$(basename "$f")"
echo " ⬆ ${fn}"
resp="$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
-F "access_token=${GITEE_TOKEN}" \
-F "file=@${f}" 2>/dev/null || true)"
if printf '%s' "$resp" | grep -q '"browser_download_url"'; then
uploaded=$((uploaded + 1))
else
echo " ⚠ upload may have failed for ${fn}: ${resp}" >&2
local_sha="$(sha256_of "$f")"
# Every attach id currently carrying this name (may be >1 from a botched run).
ids="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $2}')"
aurl="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $3; exit}')"
count="$(printf '%s' "$ids" | grep -c . || true)"
if [ "$count" -eq 0 ]; then
echo " ⬆ ${fn} (new)"
if gitee_attach "$f"; then uploaded=$((uploaded + 1)); else echo " ⚠ upload may have failed for ${fn}" >&2; fi
continue
fi
if [ "$count" -eq 1 ]; then
gitee_sha="$(curl -fsSL "$aurl" 2>/dev/null | sha256_of || true)"
if [ "$gitee_sha" = "$local_sha" ]; then
echo " ✓ ${fn} already correct on Gitee — skip"
skipped=$((skipped + 1))
continue
fi
echo " ↻ ${fn} differs on Gitee (stale) — deleting + re-uploading"
else
echo " ↻ ${fn} has ${count} copies on Gitee (dup) — deleting all + re-uploading one"
fi
# Delete every copy, then upload exactly one fresh, correct file.
printf '%s\n' "$ids" | while read -r aid; do
[ -n "$aid" ] && gitee_delete "$aid"
done
if gitee_attach "$f"; then replaced=$((replaced + 1)); else echo " ⚠ re-upload may have failed for ${fn}" >&2; fi
done
[ "$uploaded" -gt 0 ] || { echo "❌ No artifacts uploaded. Did the build (goreleaser) run?" >&2; exit 1; }
echo "✅ Uploaded ${uploaded} asset(s) to Gitee release ${VERSION}."
if [ "$uploaded" -eq 0 ] && [ "$replaced" -eq 0 ] && [ "$skipped" -eq 0 ]; then
echo "❌ No artifacts found to mirror. Did the build (goreleaser) run / were assets downloaded into ${DIST_DIR}?" >&2
exit 1
fi
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped} (already correct)."
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
+31 -4
View File
@@ -27,6 +27,7 @@ cli_version: ">=1.0.15"
- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/<name>.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤
- **业务域最佳实践优先**:文档类多步任务先读 [04-document.md](./references/best_practices/04-document.md);AI 表格读取/统计/写入/导入导出先读 [06-data-analytics.md](./references/best_practices/06-data-analytics.md)。本仓库只迁入这些业务域 best practices,不引入其它产品行动指南。
- 知识库容器只用 `dws wiki space/member`;知识库内文件/文档的浏览、搜索、读取、创建、移动、复制统一切到 `dws doc`。`workspaceId` 只能传给 `wiki --workspace`、`doc --workspace` 或 `doc search --workspace-ids`,禁止传给 `doc list --folder`,也不要使用不存在的 `--space-id`。
- 找群 / 找人 / 找数据在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile <corpId>` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。
## 开放平台文档 RAG / 错误码排查
@@ -44,10 +45,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) |
@@ -72,6 +73,30 @@ cli_version: ">=1.0.15"
4. **Fallback 单产品路由**:仅当行动指南未命中,且用户意图明确是单一产品单步操作时,才按「产品总览」和「意图判断决策树」选择产品,并读取对应 `references/products/*.md`。
5. **追问**:以上步骤都无法判断时,主动追问用户澄清,严禁猜测命令、flag、URL、ID 或字段名。
## 多组织处理
dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 按当前 profile 自动注入,不是只支持单组织)。
**触发条件(命中任一即进入本节)**:
- 显式:用户提到 切换 / 换 / 跨组织、另一个钉钉、别的公司、看登录了哪些组织、当前是哪个组织、某人 / 某群 / 某数据在别的组织
- 隐式(最常见、易漏):在当前组织读 / 搜没找到目标(群 / 人 / 数据),且 `dws profile list` 显示已登录 ≥2 个组织 —— 别急着判「不存在」,按下方跨组织铁律去其他组织找
- 需要跨多个组织汇总 / 对比数据
- 用户问认证状态 / 登录了哪些组织 / 主组织是哪个
**不触发**:只登录 1 个组织时,按当前组织正常处理,不带 `--profile`,不进本节。
命令:
- `dws profile list` — 列出已登录组织(主 / 当前标记、状态、有效期),只读元数据
- `dws profile switch <名称|corpId|->` — 持久切换当前组织;`-` 切回上一个;无参数在交互终端弹选择器(非交互须显式传参)。`dws profile use` 是其别名
- 全局 `--profile <名称|corpId>` — 单次指定本命令用哪个组织,一次性、不改当前组织
- `dws auth login` — 再登一个组织即新增 profile(自动从授权账号取 corpId / corpName);同组织重复 login = 刷新
- `dws auth status [--profile <名称>]` — 查看认证状态
多组织数据聚合步骤:`dws profile list` 拿到所有已登录组织,对每个组织带 `--profile <corpId>` 各取一次数,合并并标注来源组织;某组织失败则标「该组织暂不可用」并继续返回其余。
安全护栏:
- 只有 `dws profile list` 显示 ≥2 个组织才启用上面的跨组织逻辑;单组织直接按当前组织走,不带 `--profile`。
- 自动跨组织只对「读 / 搜」。写 / 发 / 删 / 撤回等操作默认只在当前组织做;确需带 `--profile` 跨组织写时,必须先与用户确认目标组织。
- 持久切换 `dws profile switch`(改默认组织)按写操作对待:未经用户明确要求不得执行。跨组织找数一律用一次性 `--profile`,不改当前组织。
## 行动指南(优先匹配)
> 将用户意图与下表做**语义比对**,不要求字面包含关键词。命中后必须读取该行动指南文件,并按其中固定路线执行;多个场景同时命中时,按下方「消歧规则」选择。
@@ -94,13 +119,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`
@@ -112,6 +137,7 @@ cli_version: ">=1.0.15"
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"切换组织/换组织/跨组织/另一个钉钉/别的公司/多组织/看所有组织/profile/登录了哪些组织" → `profile`(见「多组织 / profile」节)
关键区分: **dev(创建/配置/建联机器人)** vs **chat(查询/发消息已有机器人)**。`dws chat bot search/find` 只查询机器人;**建号**(创建钉钉智能体机器人)走 `dws dev app robot submit`;**建联**(把机器人接到本地 agent 的 Stream)走 `dws dev connect`。凡是"创建机器人""建机器人""接入 agent""建联"一律路由到 `dev`,禁止走 `chat`。
关键区分: aitable(数据表格) vs todo(待办任务)
@@ -149,6 +175,7 @@ cli_version: ">=1.0.15"
| `oa` | `approval reject` | 拒绝待审批(需加明确理由) |
| `todo` | `task delete` | 删除待办 |
| `minutes` | `replace-text` | 全文批量替换转写与摘要 |
| `auth` | `logout` | **默认退出所有已登录组织**;只退一个加 `--profile <名称\|corpId>`。注意:退主组织不会被拦,会静默把「主」改选为剩下第一个组织,退主前必须向用户确认 |
### 确认流程
```
@@ -5,7 +5,7 @@
## 专用规则(#8 非 lite 步骤必守)
- **角色类查人优先 label**:用户说"角色为XX的员工/XX角色的员工/XX角色的人员""所有主管/主管理员/财务/HR/总经理"等角色类型人员时,**优先** `contact label list` 获取全部角色 → 匹配目标角色 → `contact label list-members --id <labelId>`;若用户明确指定了角色名称(如"角色为总经理"),则先用 `contact label get --names <XX>` 精确匹配,**若精确匹配无结果,降级 `label list` 模糊匹配**(如用户说"管理员"可匹配到"主管理员"和"子管理员")。
- **角色/职责类查人优先 aisearch**:用户说"角色为XX的员工/XX角色的人员""所有主管/财务/HR/总经理""谁负责 XX"等角色/职责类查询时,**优先** `aisearch person --keyword "<角色或职责>" --dimension duty`(`contact label` 角色查询命令已下线,不再可用)。
- **脚本优先**:按部门拉成员**优先** `python scripts/contact_dept_members.py --query "<部门名>"`(`--dry-run` / `--format json`);失败再 `dept search` → `dept list-members --depts`。
- **详情链路**:用户要子部门、职位、联系方式、汇报关系等,在 `user search` 之后**必须**再 `contact user get --ids <userId>`;禁止仅用 search 的浅表字段交差。
- **`user get` 后部门仍空**:不得过早结束或只建议用户去 App;须在 CLI 能力内尝试 **用户点名的部门** `dept search` + `dept list-members` 等与 `userId` 交叉核对,再结构化汇总「返回中有哪些字段 / 哪些为空及可能原因」。
@@ -20,7 +20,7 @@
## 与其他场景消歧
- **按角色/职位类型查人(主管/管理员/财务等)** → 优先 `contact label list` + `label list-members`;label 精确命中角色维度,返回完整名单;aisearch 是语义模糊搜索不保证完整性。
- **按角色/职位类型查人(主管/管理员/财务等)** → 优先 `aisearch person --dimension duty`(按职责维度找人);`contact label` 角色查询命令已下线。
- **搜人/找人/找同事/查工号/查手机号** → 首选 **`aisearch person`**(AI 语义搜索,支持姓名/部门/职责/上下级/手机号/工号维度),见 `aisearch`(开源版未引入,悟空内部产品)。
- **需要 userId 做后续操作 / 按手机号查 / 按 userId 查详情** → `contact`(精确查询)。
- **纯查部门与子部门成员 / 验证归属 / 组织关系** → `contact`。
@@ -31,7 +31,7 @@
| Recipe | 步骤 |
|--------|------|
| `lookup-label-members` | 1. `contact label list` → 浏览全部角色,匹配目标角色的 labelId<br>2. `contact label list-members --id <labelId>` → 该角色下的成员列表 |
| `lookup-role-members` | 1. `aisearch person --keyword "<角色或职责>" --dimension duty` → 按职责维度找人(`contact label` 已下线) |
| `search-user-by-mobile` | 1. `contact user search-mobile --mobile "<手机号>"` → 按需 `contact user get --ids <userId>` |
| `lookup-dept-id` | 1. `contact dept search --query "<部门关键词>"` → 回显 `deptId`(多命中须消歧) |
| `list-subdepts` | 1. 已有父 `deptId` → `contact dept list-children --dept <父deptId>` 直接取直属子部门列表<br>2. 只有部门名 → 先 `lookup-dept-id` 取 `deptId`,再 `list-children` |
+2 -2
View File
@@ -82,10 +82,10 @@
**用 `report` 的场景**:
- "帮我看看收到的日报" — 日志收件箱
- "帮我写/提交今天的日报(钉钉日志模版)" — 先 `report template list` / `template detail`,再 `report create`
- "帮我写/提交今天的日报(钉钉日志模版)" — 先 `report template list` / `template get`,再 `report entry submit --contents-file <tmp>.json`
- "有什么日志模版" — 查看模版
- "看看这个日志的已读统计" — 阅读状态
- "我发过的日志有哪些" — 已发送列表 (`report sent`)
- "我发过的日志有哪些" — 已发送列表 (`report outbox list`)
- 用户提到"日报"、"周报"、"日志"
**用 `todo` 的场景**:
+280 -39
View File
@@ -7,9 +7,15 @@
| 资源 | URI 格式 |
|------|----------|
| Base 文档 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` |
| 指定数据表 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}` |
| 指定数据表+视图 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}` |
| 模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` |
> **操作后请返回文档 URI**:每次执行 base list/search/create/get 操作后,从返回数据中提取 `baseId`,拼接为 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 返回给用户。
> **操作后请返回文档 URI**:返回链接时必须带上当前操作的数据表 tableId,让用户点击后直接看到目标数据表,而不是落在空白的默认表。
> - 已知 tableId + viewId 时(view create 返回、view get 中提取):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}`
> - 已知 tableId 时(table create 返回、base get 中提取、record 操作所用的 tableId):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}`
> - 仅有 baseId、无明确 tableId 时(如 base list/search):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}`
>
> 补充:如果 URL 不是来自 `aitable` 命令返回,而是用户直接贴的原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe,确认是 `able` 后再按 AI 表格处理。
## 命令索引表
@@ -19,10 +25,9 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` |
| `base search` | 搜索 Base;不传关键词时列出最近 Base | — | 可选 `--query`;不传时走 list_bases |
| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 |
| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID |
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId |
| `base copy` | 复制 Base 到目标文件夹 | `--base-id` `--target-folder-id` | 目标必须是 `dws doc folder create/list` 返回的文档文件夹 `nodeId`;不要传钉盘数字 `dentryId`,也不要用手工新建 base/table 代替 |
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId;**默认新建的 base 自带一个空白「数据表」(含 3 行空记录)和一个空白仪表盘**,如需干净的空 base,传 `--template-id 1743` |
| `base update` | 更新 Base 名称 | `--base-id` `--name` | — |
| `base delete` | 删除 Base | `--base-id` | 不可逆 |
@@ -31,8 +36,8 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `table get` | 获取表结构(字段+视图目录) | `--base-id` | 不传 `--table-ids` 返回全部表 |
| `table create` | 创建数据表 | `--base-id` `--name` | `--fields` 可选;不传时创建空字段表 |
| `table update` | 重命名表 | `--base-id` `--table-id` `--name` | — |
| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 |
| `table update` | 修改表名 / 备注 / 行命名规则 | `--base-id` `--table-id` + 三选一(`--name` / `--description` / `--record-name-key`) | `--record-name-key` 是固定枚举(如 task/project/event/customer/ji_lu 等),非字段 ID |
| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 |
### field (字段管理) → 详见 [aitable-field.md](./aitable/aitable-field.md)、[field-properties](./aitable/aitable-field-properties.md)
@@ -44,6 +49,30 @@
| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 |
| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 |
#### 搜索字段选项
```
Usage:
dws aitable field search-options [flags]
Example:
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID>
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --keyword 已完成
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --limit 100
Flags:
--base-id string Base ID (必填)
--field-id string 目标字段 ID,必须是 singleSelect / multipleSelect 类型 (必填)
--keyword string 模糊搜索关键词,大小写不敏感、contains 匹配 option name;不传返回全部
--limit int 返回的最大 option 数量,默认 3000(全量),最大 3000
--table-id string Table ID (必填)
```
仅适用于 **singleSelect / multipleSelect** 字段。其他类型(text/number/date/...)调用会返回错误。
适用场景:
- options 较多,只想要含某关键词的子集(避免 `field get` 拉取整个字段配置带回所有 options)。
- 写入 record 前预览选项 id ↔ name 的映射,确认要使用的选项确实存在。
> **写 record 时**:`record create / update` 对 singleSelect/multipleSelect 可直接传 option **name**,不需要用本命令。本命令主要用于 **filter** 写法(filters 优先用 option **id**)或选项较多需要精确定位时。
### record (记录管理)
| 命令 | 用途 | 必读 reference | 路由提醒 |
@@ -51,36 +80,66 @@
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference |
| `record get` | 按 ID 取记录(`record query --record-ids` 的窄别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 已知 recordId 时首选;必填 `--record-ids`(单次最多 100 条);未暴露 filters/sort/query/cursor/limit |
| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 |
| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;`--records` 是 `[{recordId,cells},...]` 数组;同一组值批量更新也用此命令展开 records |
| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;`--records` 是 `[{recordId,cells},...]` 数组 |
| `record batch-update` | 批量更新(同一 cells 应用到多条 recordId) | [aitable-record-update.md](./aitable/aitable-record-update.md)、[aitable-cell-value.md](./aitable/aitable-cell-value.md) | 适合"统一标记完成/统一改负责人"等共享 patch 场景;`--cells` 是 JSON object(key=fieldId,value 按字段类型见 cell-value.md),与 record update 的单条 cells 结构完全一致;必填 `--record-ids` `--cells`;单次最多 100 条 |
| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 |
| `record history-list` | 查询单条记录的变更历史 | [aitable-record-history.md](./aitable/aitable-record-history.md) | 必填 `--record-id`;分页 `--offset --limit`,limit 范围 [1,50] 默认 20 |
| `record query-empty` | 查询完全没填用户字段的空行 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 一页扫描 `--limit` [1,100] 默认 100;扫完前需用 `--cursor` 翻页(nextCursor 为空才表扫完) |
| `record share-url` | 批量获取记录分享链接 | [aitable-record-share.md](./aitable/aitable-record-share.md) | 必填 `--record-ids`(CSV,单次最多 20 条);可选 `--view-id` 带视图上下文 |
| `record upsert` | 批量创建或更新(按 recordId 是否存在自动拆分) | [aitable-record-upsert.md](./aitable/aitable-record-upsert.md) | --records 同 record update 格式;带 recordId 走 update,不带走 create;单次最多 100 |
| `record primary-doc-get` | 查询记录的主键文档 nodeId | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 返回的 nodeId 可直接用于 `dws doc read/update --node` |
| `record primary-doc-create` | 为记录创建主键文档(幂等) | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | fieldId 必须是 primaryDoc 类型;已存在则返回已有 nodeId |
### view (视图管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `view get` | 获取视图配置 | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
| `view list` | 列出全部视图(`view get` 不传 `--view-ids` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价;只需视图列表时优先 |
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;可选 `--name` 指定视图名称(未传时自动生成)、`--config` 传初始配置 JSON |
| `view update` | 更新视图(**调整字段顺序的入口**) | `--base-id` `--table-id` `--view-id` | `visibleFieldIds` 重排字段顺序 |
| `view get` | 获取视图配置(不传子命令) | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
| `view get <attr>` | 获取视图某个属性 | `--view-id` | 12 个:card/timebar/aggregate/filter/sort/group/visible-fields/field-widths(详见 [aitable-view-config.md](./aitable/aitable-view-config.md))+ lock/frozen-cols/row-height/fill-color-rule(详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)) |
| `view list` | 列出全部视图(`view get` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价 |
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;**Gantt 创建后必须 `view update timebar` 绑定日期字段** |
| `view update` | 整体更新视图 / 多属性合并更新 | `--base-id` `--table-id` `--view-id` | 可传 `--name --desc --config '{...}'`,**`--config` 路径继续保留** |
| `view update <attr>` | 按属性局部更新(推荐)| `--view-id` + typed flag / `--json` | 12 个:card/timebar/aggregate/field-widths/visible-fields/filter/sort/group/name + frozen-cols/row-height/fill-color-rule |
| `view lock [--off]` | 锁定/解锁视图 | `--base-id` `--table-id` `--view-id` | 默认锁定;`--off` 解锁。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view duplicate` | 复制视图 | `--base-id` `--table-id` `--view-id` | 可选 `--new-name`;保留源视图全部配置。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view delete` | 删除视图 | `--base-id` `--table-id` `--view-id` | 不可删最后一个/锁定视图 |
> **"移动字段/调整字段顺序"** 在 AI 表格里没有 `field reorder` 命令,必须通过 `view update --config '{"visibleFieldIds":[...]}'` 完成。
> **优先用 `view get <attr>` / `view update <attr>` 子命令**:每个属性独立命令,typed flag 友好,agent 不必拼 JSON。**`view update --config '{...}'` 仍可用**,适合一次性多属性更新或脚本场景。
> **view update --config 支持的 key 白名单**(传入其他 key 会报错):
> - `visibleFieldIds` — 视图可见字段列表及顺序(首列字段必须保留在第一位)
> - `filter` — 筛选规则**数组**(⚠️ 注意是数组 `[...]`,不是对象 `{...}`)
> - `sort` — 排序规则**数组**
> - `group` — 分组规则**数组**
> - `fieldWidths` — 列宽映射(仅 Grid 视图有效)
>
> **filter/sort/group 必须传数组格式**,不要和 `record query --filters`(对象格式)混淆。详见 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md) § view update 章节。
> CLI 会自动容错(对象→数组 wrap),但建议直接使用正确格式。
>
> 不支持 `formInfo`、`requiredFields`、`conditionalRules` 等 FormDesigner 高级配置,这些 key 会被服务端忽略。
> **属性按 attr 分类,决定该读哪份子文档**:
> - card / timebar / aggregate / filter / sort / group / visible-fields / field-widths → [aitable-view-config.md](./aitable/aitable-view-config.md)
> - lock / frozen-cols / row-height / fill-color-rule / duplicate → [aitable-view-extras.md](./aitable/aitable-view-extras.md)
> 后一类**不能**塞进 `view update --config '{...}'`,必须用各自专属子命令;如果错传 `flags` / `frozenColCount` / `cellHeight` / `conditionalFormats` 等 key 进 `--config`,CLI 会在 stderr 提示应改用的命令。
### 表单视图 → 详见 [aitable-form.md](./aitable/aitable-form.md)
> **`view update --config` 支持的 9 个 key**:
> `visibleFieldIds` / `filter` / `sort` / `group` / `fieldWidths`(Grid) / `aggregate`(Grid) / `kanbanCard`(Kanban) / `ganttTimebar`(Gantt) / `galleryCard`(Gallery)。
> filter/sort/group 必须传**数组**格式(与 `record query --filters` 的对象格式不同;CLI 会自动容错)。其他 key 会被服务端忽略并打 warning。
悟空命令面不暴露 `form` 命令组;表单按 `viewType=FormDesigner` 的视图处理,创建/查看/更新/删除都使用 `view` 命令。
### form (表单管理) → 详见 [aitable-form.md](./aitable/aitable-form.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 详情见 [aitable-form.md](./aitable/aitable-form.md) |
| `form get` | 按 viewId 取单个表单详情 | `--base-id` `--table-id` `--view-id` | — |
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | — |
| `form update` | 更新表单配置 | `--base-id` `--table-id` `--view-id` | title/name/description 至少一项 |
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` | 不可逆 |
| `form field list/update/hide` | 表单字段管理 | — | 详情见子文档 |
| `form questions create/delete` | 题目管理(=field create/delete) | — | 详情见子文档 |
| `form share get/update` | 表单分享配置 | — | 详情见子文档 |
> **创建表单**有两种等价方式:`form create --name "..."`(推荐)或 `view create --view-type FormDesigner --name "..."`。
### workflow (自动化工作流) → 详见 [aitable-workflow.md](./aitable/aitable-workflow.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
> **当前不支持通过 CLI 新建/修改/删除工作流**,请去 AI 表格 Web 端(数据表页面 → 自动化)配置。
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
@@ -88,6 +147,7 @@
|------|------|
| `dashboard get/create/update/delete` | 仪表盘管理 |
| `dashboard config-example` | 查看仪表盘配置模板 |
| `dashboard arrange` | 自动重排仪表盘图表布局(智能填满网格,避免空缺) |
| `chart get/create/update/delete` | 图表管理 |
| `chart widgets-example` | 查看图表 widgets 配置模板 |
@@ -111,15 +171,162 @@
|------|------|----------|
| `template search` | 搜索模板 | `--query` |
## 评测执行硬约束
### advperm (高级权限/自定义角色) → 详见 [aitable-advperm.md](./aitable/aitable-advperm.md)
- 多轮任务必须执行到用户要求的最后一步;不要只回复"现在开始/下一步执行",也不要在创建 base/table/field 后提前结束。
- 每个写操作后用 `base get`、`table get`、`field get`、`record query` 或对应 `view get/list` 读回验证真实 ID 与结果。
- 字段批量 JSON 推荐 `fieldName`;CLI 兼容 `name`,但 skill 生成时不要主动使用 `name`。字段类型统一用小写/规范值,如 `text`、`number`、`singleSelect`、`attachment`。
- 成员/负责人字段类型使用 `user`,不要生成 `member`。
- 复制 AI 表格必须调用 `dws aitable base copy --base-id <BASE_ID> --target-folder-id <FOLDER_NODE_ID> --format json`。目标目录必须是 `dws doc folder create` 或 `dws doc list` 返回的文档文件夹 `nodeId`;不要传 `drive list` 返回的数字 `dentryId`,不要用新建 base/table 的手工方式代替 `base copy`。
- 用户未指定目标文件夹时:先 `dws doc info --node <BASE_ID> --format json` 取 `workspaceId`,再 `dws doc folder create --workspace <WORKSPACE_ID> --name "AI表格副本" --format json` 创建目标文件夹,最后把返回的 `nodeId` 传给 `base copy`。
- 导入 Excel/CSV 前先用 `find` 或 `ls` 确认真实文件路径;遇到中文文件名乱码或路径不匹配时,重新查找实际文件,不要停在解释阶段。
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `advperm enable` | 开启 Base 高级权限总开关 | `--base-id` | 不开启时角色规则不生效 |
| `advperm disable` | 关闭 Base 高级权限总开关(高危) | `--base-id` `--yes` | 关闭后全员回退默认权限 |
| `advperm role-list` | 列出 Base 下所有角色 | `--base-id` | 同时返回自定义角色和系统角色;`roleType == "custom"` 是自定义,前缀 `system_` 是系统角色 |
| `advperm role-get` | 获取单角色完整配置 | `--base-id` `--role-id` | 含 subRoles 与字段/行级规则 |
| `advperm role-create` | 创建自定义角色 | `--base-id` `--name` | 可选 `--sub-roles` 同时指定子角色权限规则 |
| `advperm role-update` | 增量更新自定义角色(PATCH) | `--base-id` `--role-id` | 未传字段不变;`--sub-roles` 按 (targetId,targetType) 合并 |
| `advperm role-delete` | 删除自定义角色 | `--base-id` `--role-id` `--yes` | 不可逆;系统角色禁删;**调用者必须是该 AI 表格的管理员/Owner**,非管理员会得到 401 AUTH_ERROR |
> **角色 CRUD 已全支持**:create/get/list/update/delete 都可走 CLI。
> 所有写命令(enable/disable/role-create/role-update/role-delete)需要 Base 管理员权限;非管理员只能调 `role-list` / `role-get`(只读)。
> "角色 ↔ 成员"绑定当前 CLI 不支持,仍需在 AI 表格 Web 端 → Base 设置 → 高级权限面板手动完成。
### section (文件夹与节点管理)
> 用于在 Base 的导航树中组织 table / dashboard / 表单视图 / 文档等节点(类似文件夹)。
> 操作前建议先用 `section list-nodes` 拿到 nodeId / sectionId 与父级关系。
#### 创建文件夹
```
Usage:
dws aitable section create [flags]
Example:
dws aitable section create --base-id <BASE_ID> --name 我的文件夹
dws aitable section create --base-id <BASE_ID> --name 子文件夹 --parent-section-id <SECTION_ID> --index 0
Flags:
--base-id string Base ID (必填)
--name string 文件夹名称 (必填)
--parent-section-id string 父文件夹 ID;不传或空字符串表示创建在 Base 根目录下
--index int 在父文件夹下的目标位置(0-based);不传则追加到末尾
```
返回 `data.sectionId` 与 `data.name`。
#### 重命名文件夹
```
Usage:
dws aitable section rename [flags]
Example:
dws aitable section rename --base-id <BASE_ID> --section-id <SECTION_ID> --new-name 新名称
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
--new-name string 新的文件夹名称 (必填)
```
#### 删除文件夹
```
Usage:
dws aitable section delete [flags]
Example:
dws aitable section delete --base-id <BASE_ID> --section-id <SECTION_ID>
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
```
> **注意**:删除不可逆;删除前可先用 `section list-empty` 确认是否为空文件夹。
#### 调整文件夹顺序
```
Usage:
dws aitable section reorder [flags]
Example:
dws aitable section reorder --base-id <BASE_ID> --section-id <SECTION_ID> --target-index 0
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
--target-index int 目标位置(0-based)(必填)
```
> 在**当前父文件夹下**调整展示顺序。跨父级移动请用 `section move-node`。
#### 列出空文件夹
```
Usage:
dws aitable section list-empty [flags]
Example:
dws aitable section list-empty --base-id <BASE_ID>
Flags:
--base-id string Base ID (必填)
```
返回 `data.items: [{sectionId, name, parentSectionId}]` 与 `data.total`,用于清理或诊断导航树(parentSectionId 为空串表示在根目录下)。
#### 列出全部节点
```
Usage:
dws aitable section list-nodes [flags]
Example:
dws aitable section list-nodes --base-id <BASE_ID>
Flags:
--base-id string Base ID (必填)
```
返回 `data.items: [{nodeId, nodeType, parentSectionId, name?}]` 与 `data.total`,涵盖文件夹 / AI 表格 / 表单视图 / 仪表盘 / 文档 / 查询视图。
> **与其他命令的关联**:是 `section move-node` / `section reorder` 的前置定位命令——先用它拿到 nodeId 与 parentSectionId。
#### 移动节点
```
Usage:
dws aitable section move-node [flags]
Example:
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id <SECTION_ID>
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id "" --target-index 0
Flags:
--base-id string Base ID (必填)
--node-id string 要移动的节点 ID(文件夹/AI表格/表单视图/仪表盘/文档/查询视图)(必填)
--new-parent-section-id string 目标父文件夹 ID;空字符串表示移到 Base 根目录 (必填)
--target-index int Base 内节点的全局位置(0-based);不传则不调整
```
> 服务端自动识别节点类型,无需区分文件夹与非文件夹。返回 `data.nodeId / newParentSectionId / nodeType`。
> 对文件夹节点带 `--target-index` 时会先 move 再 reorder,中间失败会返回 `MOVE_OK_REORDER_FAILED`,可用 `section reorder` 重试。
## 复杂操作
### 仪表盘 / 图表(建议顺序)
```bash
# 1) 先看配置模板(JSONC)
dws aitable dashboard config-example --format json
dws aitable chart widgets-example --format json
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
```
要点:
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
### 导出数据(两阶段轮询)
`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
```bash
# 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
```
参数约束
- `scope=all`:只需 `base-id`
- `scope=table`:必须 `table-id`
- `scope=view`:必须同时 `table-id + view-id`
## 意图判断
@@ -127,14 +334,14 @@
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
- 详情 → `base get`
- 创建 → `base create`
- 复制 → `base copy`,必须调用 `dws aitable base copy --base-id <BASE_ID> --target-folder-id <FOLDER_NODE_ID> --format json`;若无目标文件夹,先 `doc info --node <BASE_ID>` 取 `workspaceId`,再 `doc folder create --workspace <WORKSPACE_ID>` 创建文档文件夹作为目标。服务端返回 `Invalid target folder ID` 时,改用 `doc folder create` 新建目标文件夹后重试一次;不要手工重建副本。
- 修改 → `base update`
- 删除 → `base delete`
用户说"数据表/子表/table":
- 查看 → `table get`
- 创建 → `table create`
- 重命名 → `table update`
- 重命名 / 改备注 / 改行命名规则 → `table update`(三选一:`--name` / `--description` / `--record-name-key`)
- 用户说"行命名规则/记录别名/卡片显示成 task/project/event 这种" → `table update --record-name-key <枚举键>`,**中文 → 枚举键**对照见 [aitable-record-name-key.md](./aitable/aitable-record-name-key.md)
- 删除 → `table delete`
用户说"字段/列/column":
@@ -145,19 +352,35 @@
用户说"记录/行/数据/row":
- 查看/搜索 → `record query`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 找空行 / 没填东西的行 → `record query-empty`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 已知 recordId 反查字段值 → `record get`(按 ID 取专用,等价 `record query --record-ids`)
- 添加/写入 → `record create`(读 [aitable-record-create.md](./aitable/aitable-record-create.md))
- 修改/更新(每条独立 cells) → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md))
- **批量更新同一字段值**(统一标记/统一改值) → `record update --records '[{"recordId":"rec1","cells":{...}},{"recordId":"rec2","cells":{...}}]'`
- **批量更新同一字段值**(统一标记/统一改值) → `record batch-update --record-ids ... --cells '{...}'`
- 删除 → `record delete`
- **查记录的字段变更历史 / 操作审计** → `record history-list`(读 [aitable-record-history.md](./aitable/aitable-record-history.md))
- **取记录分享链接 / 把这行发给同事** → `record share-url`(读 [aitable-record-share.md](./aitable/aitable-record-share.md))
- **不知道有没有 → 有就改、没有就建** → `record upsert`(读 [aitable-record-upsert.md](./aitable/aitable-record-upsert.md))
用户说"视图/view":
- 列出/查看全部视图 → `view list`(或 `view get` 不传 --view-ids,二者等价)
- 看某个视图详情 → `view get --view-ids <ID>`
- 创建 → `view create`
- 修改(含"调整字段顺序/隐藏字段") → `view update --config '{"visibleFieldIds":[...]}'`
- 修改某一项配置(filter/sort/group/card/timebar/aggregate 等)→ `view update <attr>`(读 [aitable-view-config.md](./aitable/aitable-view-config.md))
- 锁定 / 冻结列 / 行高 / 数据高亮规则 / 复制视图 → 读 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
- 删除 → `view delete`
用户说"锁定视图/解锁视图/lock view" → `view lock` / `view lock --off`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"冻结列/冻结首列/frozen columns" → `view update frozen-cols --count N`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"行高/单元格高度/紧凑模式/cell height" → `view update row-height --cell-height N`(合法档位 32/56/88/128),详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"数据高亮/条件格式/单元格上色/fill color rule" → `view update fill-color-rule --json '[...]'`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"复制视图/duplicate view" → `view duplicate --view-id ... [--new-name ...]`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"筛选/过滤/filter" → 读 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md)
用户说"统计/分析/聚合/TOP N/全量" → 读 [aitable-data-analysis-sop.md](./aitable/aitable-data-analysis-sop.md)
@@ -166,16 +389,33 @@
用户说"查找引用/lookup/filterUp/跨表" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)(§5.4 跨表引用)
用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md),使用 `view create --view-type FormDesigner`
用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md)
用户说"自动化/工作流/流程/触发/automation/workflow" → 读 [aitable-workflow.md](./aitable/aitable-workflow.md)
- 看 Base 里有哪些流程 / 哪些在跑 → `workflow list`(看 `recordCount` / `runningCount`)
- 看某个流程具体配置(触发条件、动作步骤) → `workflow get`
- 启用流程 → `workflow enable`
- 临时停掉流程(调试 / 数据迁移)→ `workflow disable --yes`
- **新建 / 修改 / 删除流程**:当前不支持,引导用户到 AI 表格 Web 端 → 数据表 → 自动化 面板手动完成
用户说"仪表盘/图表/chart" → 读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
用户说"仪表盘排版乱了/图表对不齐/重新排布/自动布局/美化仪表盘" → `dashboard arrange`(读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md))
用户说"附件/上传文件" → 读 [aitable-attachment.md](./aitable/aitable-attachment.md)
用户说"导入/导出/import/export" → 读 [aitable-export-import.md](./aitable/aitable-export-import.md)
用户说"模板" → `template search`
用户说"高级权限/角色/权限控制/谁能看/谁能改" → 读 [aitable-advperm.md](./aitable/aitable-advperm.md)
- 开/关高级权限 → `advperm enable` / `advperm disable --yes`
- 看角色配置 → `advperm role-list` 或 `advperm role-get`
- 建角色(可同时指定子角色权限) → `advperm role-create --name ... --sub-roles '[...]'`
- 改角色名 / 改子角色权限(PATCH 语义,未传字段不变) → `advperm role-update --role-id ... [--name ...] [--sub-roles '[...]']`
- 删角色 → `advperm role-delete --yes`
- **角色 ↔ 成员绑定**:当前 CLI 不支持,仍需在 AI 表格 Web 端面板手动完成
命令报错/操作失败 → 读 [aitable-error-recovery.md](./aitable/aitable-error-recovery.md)
**关键区分**: base=表格文件, table=数据表, field=列, record=行
@@ -190,7 +430,7 @@ dws aitable base search --query "项目" --format json
dws aitable base get --base-id <BASE_ID> --format json
# 3. 获取表结构 — 提取 fieldId
dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID> --format json
dws aitable table get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 4. 查询记录
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --format json
@@ -206,7 +446,8 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|------|-------------|------|
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
| `base create` | `baseId` | 后续命令 + 文档 URI |
| `base get` | `tables[].tableId` | --table-id |
| `base get` | `tables[].tableId` | --table-id,拼接指定数据表 URI |
| `table create` | `tableId` | 后续命令 + 拼接指定数据表 URI |
| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete |
| `record query` | `recordId` | record update/delete;按 ID 反查字段值用 `record get` |
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
@@ -0,0 +1,251 @@
# advperm — 高级权限管理
控制 Base 的高级权限总开关,并管理自定义角色(增删改查 + 子角色权限规则)。
适用场景:"如何控制谁能看/改 Base 数据"、"开启/关闭高级权限"、"新建/修改/删除角色"、"按字段或行配置权限"。
## 命令一览
| 命令 | 用途 |
|------|------|
| `advperm enable` | 开启 Base 高级权限总开关 |
| `advperm disable` | 关闭 Base 高级权限总开关(高危) |
| `advperm role-list` | 列出 Base 下全部角色 |
| `advperm role-get` | 获取单角色完整配置 |
| `advperm role-create` | 创建自定义角色 |
| `advperm role-update` | 增量更新自定义角色(PATCH 语义) |
| `advperm role-delete` | 删除自定义角色(不可逆) |
> 所有子命令的 `--base-id` 必填,可用隐藏别名 `--base`。
## 命令详情
### advperm enable — 开启高级权限
```bash
dws aitable advperm enable --base-id BASE_ID --format json
```
返回 `{baseId, enabled: true}`。
只有开启后角色配置才会真正限制成员的可访问范围;关闭状态下角色配置仍可读但不生效。
### advperm disable — 关闭高级权限(高危)
```bash
dws aitable advperm disable --base-id BASE_ID --yes --format json
```
返回 `{baseId, enabled: false}`。关闭后所有角色配置即刻失效,全员回退到默认权限。涉及多人协作或敏感数据务必和用户二次确认,建议先 `role-list` 留底。
### advperm role-list — 列出全部角色
```bash
dws aitable advperm role-list --base-id BASE_ID --format json
```
返回结构:
```json
{
"data": {
"enabled": true,
"defaultRole": { "mode": 0 },
"roles": [
{
"roleId": "10685308981",
"name": "可查看角色",
"roleType": "custom",
"system": false,
"subRoles": [
{
"authLevel": "read",
"targetId": "HMEaRQ4",
"targetType": "sheet",
"config": { "actions": 268435455 },
"display": {
"authLevelLabel": "仅查看",
"targetTypeLabel": "数据表",
"permissionScopeNote": "...",
"actionsLabels": ["新增视图", "删除视图", "修改视图"],
"actionsNote": "..."
}
}
]
}
]
}
}
```
关键字段:
- `roleType`:`custom`(自定义) / `system_editor` / `system_reader` / `5000`(owner) / `4000`(manager)。
- `system`:boolean,true 表示系统角色(不可删)。
- `subRoles[].display.*`:服务端返回的人类可读标签,可直接拼接给用户阅读,无需自行映射枚举。
- 不返回角色成员列表;如需"成员-角色"映射请去 AI 表格 Web 端。
- 新建 Base 默认 `enabled=false`,开启后只有 `owner` / `manager` 两个 meta 角色;`system_editor` / `system_reader` 需要在 Web UI 给成员授权"可编辑/可查看"后才会被服务端自动生成。
`role-list` / `role-get` 不需要管理员权限,普通成员也可读。
### advperm role-get — 获取单角色配置
```bash
dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID --format json
```
返回结构同 `role-list` 中单个 role 对象(含完整 `subRoles[].config` 字段/行级规则与 `display.*` 标签)。
### advperm role-create — 创建自定义角色
```bash
# 仅指定 name,子角色由服务端按默认(none)填充
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" --format json
# 创建时即指定 sub-roles(推荐——避免再走一次 role-update)
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" \
--sub-roles '[{"targetId":"<sheetId>","targetType":"sheet","authLevel":"read"}]' --format json
```
| flag | 必填 | 说明 |
|------|:---:|------|
| `--name` | ✅ | 角色名称 |
| `--role-type` | | 角色类型字符串(留空由服务端决定默认值,如 `custom`) |
| `--flow-type` | | 流程类型字符串(按业务需要) |
| `--sub-roles` | | JSON 数组:`[{targetId, targetType, authLevel, appId?, config?}]`,详见下方"sub-roles 子字段"段 |
返回新建角色的完整配置(同 `role-get` 出参格式,含自动生成的 default subRoles)。
系统角色无法通过本命令创建。
### advperm role-update — 增量更新自定义角色(PATCH 语义)
```bash
# 只改名
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name "新名字"
# 只改 sheet 子角色 authLevel,name 不传保持不变
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID \
--sub-roles '[{"targetId":"<sheetId>","targetType":"sheet","authLevel":"edit-own"}]'
```
| flag | 必填 | 说明 |
|------|:---:|------|
| `--role-id` | ✅ | 目标自定义角色 ID(数字 long 字符串) |
| `--name` | | 新角色名称;不传不修改 |
| `--role-type` / `--flow-type` | | 可选 |
| `--sub-roles` | | JSON 数组,**PATCH 合并语义**:按 `(targetId, targetType)` 合并到现有 subRoles,入参中的 sub 整体替换该 sub,**入参未提及的 sub 保留不变**(无需先调 `role-get` 自行 merge) |
**系统角色禁止更新**(包括 owner / manager / system_editor / system_reader)。
### sub-roles 子字段
每个 sub-role 描述「角色对某个权限目标的访问粒度」:
| 字段 | 类型 | 说明 |
|------|------|------|
| `targetId` | string | 目标资源 ID(数据表 → `tableId`;仪表盘 → `dashboardId`;应用 → `appId`) |
| `targetType` | string | `sheet` / `dashboard` / `app` |
| `authLevel` | string | `manage` / `edit-own` / `edit-custom-field` / `edit-field-range` / `read` / `none` |
| `appId` | string(可选) | 仅 `targetType=app` 时使用 |
| `config` | object(可选) | 字段/行级细化规则;含 `actions`(位图)/ `rows` / `cells`。结构与 `role-get` 出参 `subRoles[].config` 对齐 |
### advperm role-delete — 删除自定义角色(不可逆)
```bash
dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes --format json
```
要求同时满足:
1. 该 Base 已开启高级权限(`role-list` 返回 `enabled=true`)。
2. 当前 dws 登录用户是该 Base 的管理员/Owner。
3. `--role-id` 是 `role-list` 返回的数字 long 字符串(如 `"10685308981"`),且对应角色 `system=false`。
不可逆,删前先 `role-get` 留底。
## 能力边界
| 能力 | 状态 |
|------|------|
| 开/关高级权限 | ✅ 需管理员 |
| 列出 / 读取角色 | ✅ 普通成员也可读 |
| 创建自定义角色 | ✅ 需管理员 |
| 增量修改角色(PATCH 语义,不清空未传字段) | ✅ 需管理员 |
| 删除自定义角色 | ✅ 需管理员 |
| 修改/删除系统角色 | ❌ 服务端禁止;只能在 AI 表格 Web 端操作 |
| 角色 ↔ 成员绑定 | ❌ CLI 暂不支持,需在 AI 表格 Web 端 → Base 设置 → 高级权限 → 角色管理面板手动完成 |
## 错误码速查
| 场景 | code | type | message |
|------|------|------|---------|
| advperm 关闭时调用写接口(如 `role-delete` / `role-create` / `role-update`) | `ADVANCED_PERMISSION_DISABLED` | `USER_ERROR` | `Advanced permission is disabled for base <BASE>, please enable it via setAdvancedPermission before managing roles` |
| 非管理员调用 `enable` / `disable` / `role-create` / `role-update` / `role-delete` | `401` | `AUTH_ERROR` | `the current user must be a manager (administrator) of this base to manage roles or advanced permission` |
| 删除/更新系统角色(`system=true`) | `600` | `USER_ERROR` | `Illegal argument` |
| 操作不存在的数字 roleId(get/update/delete) | `600` | `USER_ERROR` | `Illegal argument` |
| 传非数字 roleId(如 `owner` / `manager`) | `INVALID_PARAMS` | `INPUT_ERROR` | `roleId is required` |
| `role-create` 缺 `--name` | `INVALID_PARAMS` | `INPUT_ERROR` | `name is required` |
| `--sub-roles` JSON 不是数组 / 解析失败 | (CLI 层拦截) | — | `--sub-roles 解析失败 ...` / `--sub-roles 必须是 JSON 数组` |
| `--base-id` 无法解析 | `INVALID_BASE_ID` | `INPUT_ERROR` | `baseId cannot be resolved to docId` |
> `600 / Illegal argument` 同时覆盖"操作系统角色"和"操作不存在 roleId"两种情况。拿到 `600` 时先 `role-list` 自查目标 roleId 是否存在、是否 `system=true`,再据此引导用户。
## 典型工作流
### 排查"成员看不到某些字段/记录"
```bash
dws aitable advperm role-list --base-id BASE_ID --format json
# 若 enabled=false:高级权限未开,所有规则不生效,与用户确认是否需要 enable
dws aitable advperm enable --base-id BASE_ID --format json
dws aitable advperm role-list --base-id BASE_ID --format json
# 看 roles[] 里有哪些自定义角色
dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID --format json
# 检查 subRoles[].config 中的字段/行级权限规则
```
### 新建一个"市场可读"角色
```bash
# 1. 确保高级权限已开
dws aitable advperm enable --base-id BASE_ID --format json
# 2. 拿目标 sheet 的 tableId
dws aitable table get --base-id BASE_ID --format json
# 3. 创建角色 + 指定 sheet 子角色 authLevel=read
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" \
--sub-roles '[{"targetId":"<tableId>","targetType":"sheet","authLevel":"read"}]' \
--format json
# → 返回新角色完整配置,含 roleId,记下后续 patch / delete 使用
```
### 升级角色权限(read → edit-own),保留其他配置
```bash
# 只传 sub-roles,name 等其他字段保持不变(PATCH 语义)
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID \
--sub-roles '[{"targetId":"<tableId>","targetType":"sheet","authLevel":"edit-own"}]' \
--format json
```
### 改角色名(不影响权限规则)
```bash
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name "新名字"
```
### 清理废弃角色
```bash
dws aitable advperm role-list --base-id BASE_ID --format json
dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes --format json
```
### 关闭高级权限(恢复全员可见)
```bash
dws aitable advperm role-list --base-id BASE_ID --format json > /tmp/roles-backup.json
dws aitable advperm disable --base-id BASE_ID --yes --format json
```
@@ -1,6 +1,8 @@
# attachment — 附件上传
> **STOP — 不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。必须使用以下流程。
>
> **STOP — 严禁在 record create/update 的 cells 里直接传图片 URL!** 直传 `{"url":"https://..."}` 会导致服务端同步下载图片,批量写入时触发 TIMEOUT_ERROR。正确做法:先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。
## 准备附件上传
@@ -38,8 +40,8 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
dws aitable attachment upload --base-id <BASE_ID> --file-name report.pdf --size 204800 --format json
# → 返回 uploadUrl、fileToken
# 2. PUT 上传(Content-Type 留空)
curl -X PUT "<uploadUrl>" -H "Content-Type:" --data-binary @report.pdf
# 2. PUT 上传(Content-Type 必须是文件的具体 MIME type)
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdf
# 3. 写入记录
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
@@ -4,35 +4,30 @@
| 字段类型 | 可写 | 正确方式 |
|----------|------|----------|
| 文本/数字/日期/单选/多选/复选框/URL | 是 | `dws aitable record create` / `dws aitable record update` |
| 附件 | 是,但需先上传 | 先 `dws aitable attachment upload` 取 `uploadUrl/fileToken`,PUT 后把 `fileToken` 写入记录 |
| 创建人/修改人/创建时间/修改时间 | 否 | 系统字段,只读 |
| 公式/查找引用 | 否 | 由系统计算,只读 |
| AI 字段 | 否 | 由 AI 自动计算,只读 |
| 文本/数字/日期/单选/多选/复选框/URL | ✅ | record create/update |
| 附件 | ⚠️ | 必须先走 [attachment upload 流程](./aitable-attachment.md) |
| 创建人/修改人/创建时间/修改时间 | ❌ | 系统字段,只读 |
| 公式/查找引用 | ❌ | 只读,由系统计算 |
| AI 字段 | ❌ | 只读,由 AI 自动计算 |
## 2. 查询执行契约
1. 优先用 `dws aitable record query --filters` 在服务端过滤,不要先拉全量再在上下文里手动筛选。
2. 返回 `has_more=true` 时不能做全局结论,数据可能不完整。
3. 查询前先用 `dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID>` 获取真实 fieldId,不要猜字段 ID。
4. 只需要部分字段时,用 `dws aitable record query --field-ids fld1,fld2` 降低响应体积。
5. 已知 recordId 时,用 `dws aitable record get --record-ids rec1,rec2`,不要构造无意义 filters。
1. **不要拉全量后在 context 里手动统计** — 优先用 `--filters` 在服务端过滤
2. **has_more=true 时不能做全局结论** — 数据可能不完整
3. **优先用 `--filters` 在服务端过滤** — 不要拉全量后在本地 jq/grep
4. **字段名必须来自 `table get` 真实返回** — 不要猜测 fieldId
5. **减少响应体积** — 用 `--field-ids` 仅返回需要的字段
## 3. 任务选路
| 用户诉求 | 优先方案 | 不要误走 |
|---------|----------|----------|
| 查看几条数据 | `dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID>` | 不要默认 `--all` |
| 全量拉取/统计 | `dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --all` | 不要手动循环 cursor |
| 全量导出 | `dws aitable export data --base-id <BASE_ID> --scope all --format excel` | 不要 `--all` 拉全量再写文件 |
| 文件级导入 | `dws aitable import upload --base-id <BASE_ID> --file-name data.xlsx --file-size <字节数>` + `dws aitable import data --import-id <ID>` | 不要手动解析 xlsx 再逐条写入 |
| 批量写入多条不同数据 | `dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> --records '[{"cells":{"<FIELD_ID>":"值"}}]'` | 不要一次超过 100 条 |
| 批量给多条记录写同一组值 | `dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> --records '[{"recordId":"rec1","cells":{"<FIELD_ID>":"值"}},{"recordId":"rec2","cells":{"<FIELD_ID>":"值"}}]'` | 不要使用隐藏兼容命令 |
| 附件上传 | `dws aitable attachment upload --base-id <BASE_ID> --file-name report.pdf --size <字节数>` + PUT + `record create/update` | 不要用钉盘 drive 上传 |
| 调整字段顺序 | `dws aitable view update --base-id <BASE_ID> --table-id <TABLE_ID> --view-id <VIEW_ID> --config '{"visibleFieldIds":["fld1","fld2"]}'` | 没有 `field reorder` 命令 |
| 查看视图列表 | `dws aitable view list --base-id <BASE_ID> --table-id <TABLE_ID>` | 不需要用 `view get --view-ids` |
| 创建收集表/问卷 | `dws aitable view create --base-id <BASE_ID> --table-id <TABLE_ID> --view-type FormDesigner --name "表单名"` | 不要使用隐藏兼容命令 |
| 仪表盘/图表 | 先 `dashboard config-example` / `chart widgets-example`,再 create/update | 不要猜 config 结构 |
| 查看几条数据 | `record query` | 不要用 `--all` |
| 全量拉取/统计 | `record query --all` | 不要手动循环 cursor |
| 全量导出为文件 | `export data` | 不要 `--all` 拉全量再写文件 |
| 批量写入 | `record create`(分批 100 条) | 不要一次传超过 100 条 |
| 附件/图片上传 | `attachment upload` 获取 fileToken → `record create/update` 用 fileToken 写入 | **严禁直接传图片 URL 到附件字段**(服务端同步下载会超时) |
| 文件级导入 | `import upload` + `import data` | 不要手动解析 xlsx 再逐条写入 |
## 4. 创建/修改后回读确认
@@ -40,34 +35,12 @@
| 写操作 | 建议回读命令 | 确认内容 |
|--------|-------------|----------|
| `dws aitable base create` | `dws aitable base get --base-id <BASE_ID>` | base 名称、tables 列表 |
| `dws aitable table create` | `dws aitable table get --base-id <BASE_ID> --table-ids <TABLE_ID>` | 表名、字段列表是否符合预期 |
| `dws aitable field create` | `dws aitable field get --base-id <BASE_ID> --table-id <TABLE_ID>` | 新字段是否出现在字段列表中 |
| `dws aitable record create/update` | `dws aitable record get --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids <RECORD_ID>` | 写入值是否正确 |
| `dws aitable view update` | `dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID> --view-ids <VIEW_ID>` | `visibleFieldIds` 顺序是否正确 |
| `dws aitable view create/update` | `dws aitable view get --base-id <BASE_ID> --table-id <TABLE_ID> --view-ids <VIEW_ID>` | 表单视图名称、描述和配置 |
| `table create` | `table get --table-ids <新tableId>` | 表名、字段列表是否符合预期 |
| `field create` | `table get --table-ids <tableId>` | 新字段是否出现在字段列表中 |
| `record create/update` | `record query --record-ids <新recordId>` | 写入值是否正确 |
## 5. 导入导出与异步任务
## 5. AI 字段注意事项
- `export data` 的 `--format` 是导出格式,不要在此命令上追加全局 `--format json`。
- 创建导出任务:
```bash
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> \
--format excel --timeout-ms 1000
```
- 续等已有导出任务:
```bash
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
```
- 导入本地文件:
```bash
dws aitable import upload --base-id <BASE_ID> --file-name data.xlsx --file-size <字节数> --format json
curl -X PUT "<uploadUrl>" -H "Content-Type:" --data-binary @data.xlsx
dws aitable import data --import-id <IMPORT_ID> --format json
```
## 6. AI 字段注意事项
- AI 字段的 prompt 必须至少包含一个 `fieldRef` 引用,纯文本 prompt 会被后端拒绝。
- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用。
- `outputType` 必须与字段类型一致,例如 `outputType=text` 配 `--type text`。
- AI 字段的 prompt **必须至少包含一个 `fieldRef` 引用**,纯文本 prompt 会被后端拒绝
- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用
- `outputType` 必须与字段类型一致(如 `outputType=text` 配 `--type text`)
@@ -86,11 +86,16 @@
{"fldDateId": "2026-03-15T09:00+08:00"}
```
**读取**:RFC3339 字符串
**读取**:RFC3339 字符串(带时区)
```json
{"fldDateId": "2026-03-15T09:00:00+08:00"}
```
**过滤**(`record query --filters`):日期字段**只能用日期专用操作符** `date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`,比较值用日期字符串(如 `"2026-03-15"`)。
- ❌ 通用 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对日期字段无效,会静默返回 0 条;
- ❌ 不支持区间 `date_between` 与相对 `from_now`(CLI 会直接拒绝),范围查询用 `not_before` + `not_after` 组合。
- 详见 [aitable-filter-sort.md](./aitable-filter-sort.md) §日期字段过滤。
---
### currency(货币)
@@ -236,15 +241,14 @@
### attachment(附件)
**写入**:对象数组,支持 `fileToken` 或 `url` 形式
**写入**:对象数组,**必须使用 `fileToken`**
```json
{"fldAttachId": [{"fileToken": "ft_xxx"}]}
{"fldAttachId": [{"url": "https://example.com/file.pdf"}]}
```
> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 获取 `fileToken`**。
> URL 形式是 best-effort 异步转存,不保证立即可用。
> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 上传文件获取 `fileToken`,再将 `fileToken` 写入 cells。**
> ❌ **严禁直接传 `{"url": "https://..."}` 形式写入附件/图片字段** — 服务端会同步下载图片,10 条记录即触发 TIMEOUT_ERROR 超时。
> 写入会**整体覆盖**原附件列表,不是追加。
**读取**:对象数组(含下载链接、文件名、大小)
@@ -335,7 +339,7 @@
|------|----------|
| cells key 用字段名称 `"课程名称"` | 用 fieldId `"fldXXX"` |
| progress 写入 `75` | 写入 `0.75`(范围 0~1) |
| attachment 直接传文件路径 | 必须先 upload 获取 fileToken |
| attachment 直接传文件路径或图片 URL | 必须先 `attachment upload` 获取 fileToken,再用 fileToken 写入(直传 URL 会超时) |
| user 字段传用户名字符串 | 传对象数组 `[{"userId":"...", "corpId":"..."}]` |
| group 字段用 `openConversationId` | 用 `cid` |
| singleSelect 传 option id 字符串 | 传 name 字符串或 `{"id":"...", "name":"..."}` 对象 |
@@ -27,17 +27,18 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
| `dashboard arrange` | 自动重排图表布局 | `--base-id` `--dashboard-id` | 把图表按行铺满网格,避免某行只占半幅、留下大片空白;返回 `{totalColumns, layout, alignedChartCount}` |
## chart 子命令
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` `--layout` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` |
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` |
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 |
## 配置获取流程
创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置;同时必须传 `--layout` 指定图表位置和尺寸,例如 `--layout '{"x":0,"y":0,"w":6,"h":4}'`。
创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置。

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