Compare commits

...
16 Commits
Author SHA1 Message Date
修雨 ea6fd16d11 chore(changelog): prepare v1.0.51 stable (#595) 2026-07-10 17:35:12 +08:00
修雨 4c43108bdf sync wukong hardcoded command additions
sync wukong hardcoded command additions

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-07-09 21:28:57 +08:00
修雨 5e9a920b76 fix(connect): prevent agent mid-turn blocking
fix(connect): prevent agent mid-turn blocking

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-07-09 21:28:35 +08:00
xuanandshangguanxuan.sgx 36b0528d90 fix: default pat chmod grants to permanent (#584)
* fix: default pat chmod grants to permanent

* docs(changelog): note pat chmod permanent default

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-09 11:28:45 +08:00
xuanandshangguanxuan.sgx a2201b4ab4 test(pat): remove external example auth URL trigger (#583)
* test(pat): remove external example auth URL trigger

* test(app): prevent browser launches during tests

* test(pat): build auth URL fixture as JSON

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-09 11:28:40 +08:00
修雨 181cdf4a03 Merge pull request #578 from LastdianXuan/fix/dek-readonly-keychain-status
fix: keep keychain reads side-effect free
2026-07-08 18:46:33 +08:00
修雨 818b8b29e3 chore(changelog): add v1.0.50 release notes (#580)
Covers PR #575 (global --jq/--fields honored on product commands,
skill --dry-run preview, sheet batch-style JSON mode, skill docs
alignment) and the exported cmdutil leaf-merge / provenance helpers.
2026-07-08 14:49:25 +08:00
Ari 109ad13844 fix: honor global --jq/--fields on product commands; round-2 QA fixes (#575)
* fix: honor global --jq/--fields on product commands; round-2 QA fixes

Make the global --jq / --fields output filters actually work for the
product (MCP) commands. The helper Formatter used by every product
command ignored them, so they were silent no-ops there (they already
worked for `dws api`). Expose Fields()/JQ() on the ToolCaller interface
and apply the existing output.WriteFiltered path in the helper
Formatter's PrintJSON. The handful of bespoke utility commands
(auth/config/profile/...) still encode directly and are documented as
such.

Additional CLI fixes surfaced by the second real-machine QA pass:
- sheet write-image: emit clean JSON under --format json (suppress the
  progress lines that leaked onto stdout, same as media-upload/export)
- sheet range batch-set-style: under --format json, collect per-item
  results into a single JSON object instead of printing N separate ones
- chat download-media: create the output directory when missing and
  strip URL-encoded path separators from the inferred filename so the
  file actually lands instead of failing on a missing subdirectory
- pat chmod, aitable, sheet, chat, attendance: correct --help text
  (real scope names, non-existent subcommands, flag requiredness,
  alxs -> axls typo)

Helper scripts (mono and multi):
- minutes_extract_todos: parse dingtalkTodoList/actions (there is no
  todos key), so todos are no longer silently dropped
- sync the multi copies of chat_export_messages / chat_history_with_user
  (were crashing with AttributeError), minutes_list_parse /
  minutes_recent_summary, and calendar_free_slot_finder to the fixed
  mono versions

Skill docs (mono and multi): correct return-structure keys, flag names,
deprecated command routing (doc download -> drive download), enum values
and server-side limitations across products; update the global
reference to note --jq/--fields now apply to product commands.

* fix(skill): make skill setup --dry-run a no-op preview; doc/help fixups

skill setup ignored the global --dry-run flag and always wrote the skill
files (overwriting an existing install). Short-circuit into a preview
that lists the source, target dirs and selected sub-skills without
touching the filesystem.

Also correct a few doc/help mismatches found in the round-3 health check:
- attendance vacation balance/records quick-reference examples were
  missing the required --leave-code flag
- mail mailbox list --help described the returned field as "mailboxes"
  but the real field is "emailAccounts"

* docs: clarify --fields projects top-level/list keys, use --jq for nested

* docs: drop QA voice ("真机") and don't state env-specific quirks as absolute rules

The QA-driven doc/comment edits leaked test-process narration ("真机实测")
and this environment/account's quirks stated as universal rules into the
skill files, which are general-purpose instructions for any org/account.
Strip the "真机" narration everywhere; reword environment-specific findings
(PUBLIC sharing disabled by org policy, transient 1002, sender-open-dingtalk-id
behaviour) from absolute bans into conditional hints; keep genuinely
universal command behaviour, just without the QA voice.
2026-07-08 14:06:57 +08:00
张卓澎 fd6bbd928e fix: keep keychain reads side-effect free 2026-07-08 11:23:44 +08:00
张卓澎 67417d3fb1 fix: diagnose macos keychain auth failures 2026-07-08 11:23:44 +08:00
修雨 91dfc8b926 fix: export command merge helpers 2026-07-08 10:47:31 +08:00
修雨 b794d802f2 release: prepare 1.0.49 stable (#574) 2026-07-08 00:14:57 +08:00
修雨 e6c1dfe15c Merge pull request #570 from DingTalk-Real-AI/fix/release-publish-unblock
ci: unblock npm release from Gitee mirror
2026-07-07 23:46:21 +08:00
修雨 32d32cd827 Merge pull request #572 from audanye-sudo/fix/qa-optimize-6products
fix: resolve real-machine QA findings across CLI, scripts and skill docs
2026-07-07 23:44:00 +08:00
qinze a65d6f23ec fix: resolve real-machine QA findings across CLI, scripts and skill docs
Fix CLI command bugs surfaced by full real-machine QA:
- aitable: make chart/dashboard share update --enabled a string flag so
  "--enabled false" disables instead of silently enabling (bool flag +
  space-syntax help example inverted the action); clarify chart update
  requires --config; make form get filter by view-id client-side so it
  returns a single form; drop inline // comments from chart JSON examples
- chat: resolve conversation-info --user to openDingTalkId, register
  --id/--conversation-id/--chat aliases; cap list-all-conversations
  --limit at 100 and reject larger values instead of silent truncation;
  detect webhook errcode failures instead of wrapping them as success;
  remove duplicate group/members subcommand registration in help
- contact: register --dept/--depts as the primary dept flags to match
  the RunE parsing (were only registered as --id/--ids)
- sheet: emit clean JSON for media-upload and export under --format json,
  suppressing progress lines that leaked onto stdout
- wiki: correct node create --type enum (drop unsupported asheet, add
  axls/able/appt/adraw/amind)
- ding: default message list --type to ALL since the server rejects an
  empty type

Fix helper scripts (mono and multi):
- aitable import/export flag names and tableId length regex
- mail search --limit, contact dept response keys and userInfo nesting
- attendance_my_record whoami compatibility, calendar_schedule_meeting
  event id unwrapping, drive_tree_list recursion via fileId, report
  scripts migrated off deprecated report list/detail

Sync skill docs (mono and multi) to real-machine behavior across all
products: command indexes, flag names, enums, return-structure keys, and
cross-product intent routing; annotate genuinely server-side limitations
and the no-op global --jq/--fields flags.
2026-07-07 23:38:23 +08:00
修雨 a838ae75a7 ci: unblock npm release from Gitee mirror 2026-07-07 22:23:16 +08:00
239 changed files with 15210 additions and 2255 deletions
+71
View File
@@ -0,0 +1,71 @@
name: Publish npm release
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to publish to npm (e.g. v1.0.48)"
required: true
type: string
permissions:
contents: read
jobs:
publish-npm:
runs-on: ubuntu-latest
timeout-minutes: 15
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: Stage npm package
run: |
set -eu
version="${{ inputs.version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+81 -12
View File
@@ -5,12 +5,18 @@ on:
tags:
- "v*"
workflow_dispatch:
inputs:
repair_npm_version:
description: "Only publish an existing release to npm, e.g. v1.0.48"
required: false
type: string
permissions:
contents: write
jobs:
release:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
runs-on: ubuntu-latest
# 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
@@ -76,17 +82,6 @@ jobs:
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
- name: Mirror release to Gitee (China)
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -94,7 +89,8 @@ jobs:
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉。
# 必须在 Gitee mirror 前发布:Gitee 附件上传偶发长时间挂住,不能阻塞 npm/latest。
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
@@ -108,3 +104,76 @@ jobs:
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Mirror release to Gitee (China)
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
# 默认关闭:国内 release 应由 Gitee 侧本地构建发布,避免 GitHub -> Gitee 跨境传大包卡住。
# 仅在需要临时补救时设置 repo variable ENABLE_GITEE_UPLOAD_FALLBACK=true。
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
timeout-minutes: 20
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
repair-npm:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' }}
runs-on: ubuntu-latest
timeout-minutes: 15
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.repair_npm_version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Stage npm package
run: |
set -eu
version="${{ inputs.repair_npm_version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.repair_npm_version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.repair_npm_version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+51
View File
@@ -0,0 +1,51 @@
name: Gitee Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Release tag to build on Gitee, e.g. v1.0.48"
required: false
type: string
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install packaging tools
run: |
set -eu
sudo apt-get update
sudo apt-get install -y zip unzip curl
- name: Install rcodesign
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: Build and publish Gitee release
env:
VERSION: ${{ inputs.version || github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
run: ./scripts/release/build-and-publish-gitee.sh
+54
View File
@@ -6,6 +6,60 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.51] - 2026-07-10
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
### Added
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
### Changed
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
### Fixed
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
## [1.0.50] - 2026-07-08
This release fixes a long-standing gap where the global `--jq` / `--fields` output filters were silently ignored on product commands, lands a JSON-mode output path for the sheet batch-style command, and aligns the bundled skill surface with the real command semantics uncovered by the round-2 real-machine QA sweep.
### Fixed
- **Global `--jq` / `--fields` are honored on product commands** (#575) — `Formatter.PrintJSON` / `PrintJSONUnescaped` now route through `output.WriteFiltered` when either flag is set, so product commands accept the same filters that `dws api` has always supported. The tool-caller adapter exposes `Fields()` / `JQ()` so helpers can read the flags without re-parsing.
- **`skill setup --dry-run` is a no-op preview** (#575) — it now prints what would be written without touching the skill directory, the registry, or the agent config. Help text and docs are updated to match.
- **Skill docs alignment to the real command surface** (#575) — per-product references and the cross-product intent guide clarify that `--fields` projects top-level / list keys only (use `--jq` for nested paths); `minutes_extract_todos.py`, `calendar_free_slot_finder.py`, `chat_export_messages.py` / `chat_history_with_user.py`, and `contact_dept_members.py` are rewritten against the current response shapes; `aisearch` / `aitable` / `attendance` / `calendar` / `chat` / `contact` / `dev` / `doc` / `doc-comment` / `doc-file-ops` / `doc-list` / `doc-search` / `drive` / `mail` / `minutes` / `oa` / `sheet` / `sheet-export` / `url-patterns` / `best_practices/lite-recipes.md` / `global-reference.md` / `intent-guide.md` are re-synced; the QA voice ("真机" phrasing) and environment-specific quirks stated as absolute rules are removed from the docs.
### Changed
- **`sheet range batch-set-style` emits per-row JSON in JSON mode** (#575) — when `--format json` is set, each update is reported as `{index, sheetId, range, ok, error}` instead of only the final aggregate, so callers can programmatically track partial failures under `--continue-on-error`.
- **Command-merge helpers exported** — `pkg/cmdutil.LeafMerge*` and the provenance helpers are now public so downstream command trees can reuse the same merge semantics.
## [1.0.49] - 2026-07-08
This release lands a full real-machine QA sweep across the CLI, helper scripts, and skill docs (#572), and hardens the release pipeline so npm publishing can no longer be blocked by Gitee mirror issues (#570).
### Fixed
- **Real-machine QA fixes across CLI commands** (#572) — `aitable chart/dashboard share update --enabled` now takes a string so `--enabled false` disables; `chat conversation-info --user` resolves openDingTalkId and registers `--id/--conversation-id/--chat` aliases; `chat list-all-conversations --limit` is capped at 100 and rejects larger values; custom-robot webhook failures surface `errcode` instead of masquerading as success; `contact` registers `--dept/--depts` as the primary flags so the documented spelling actually works; `sheet media-upload` and `sheet export` emit clean JSON under `--format json` (progress lines no longer leak); `wiki node create --type` enum is corrected (drops unsupported `asheet`, adds `axls/able/appt/adraw/amind`); `ding message list --type` defaults to `ALL` since the server rejects empty type.
- **Helper script fixes (mono and multi)** (#572) — aitable import/export flag names and the tableId regex (7-char default tables were rejected); mail search `--limit`, contact dept response keys (`deptList`/`deptUserList`) and `userInfo` nesting; `attendance_my_record` whoami compatibility; `calendar_schedule_meeting` event-id unwrapping; `drive_tree_list` recursion via `fileId`; report scripts migrated off the deprecated `report list`/`report detail`.
- **Skill docs sync (mono and multi)** (#572) — command indexes, flag names, enums, return-structure keys and cross-product intent routing are re-aligned to real-machine behavior across all products. Genuinely server-side limitations (permission gates, org-level restrictions, unregistered tool keys) are annotated instead of code-patched, and the cross-cutting hazards (`success` always true, `--jq`/`--fields` currently no-op) are documented.
### Changed
- **Release pipeline unblocks npm publish from Gitee mirror** (#570) — the Release workflow now publishes to npm before touching the Gitee mirror, so Gitee upload issues cannot block `npm/latest`. GitHub→Gitee attachment upload is disabled by default (unreliable from US runners) and only runs when `ENABLE_GITEE_UPLOAD_FALLBACK=true`; the legacy upload fallback path is guarded with timeout and retry so it fails fast when re-enabled.
- **Repair modes for release republish** (#570) — the Release workflow gains a repair input and a standalone npm-only repair workflow, used to republish an existing release to npm without re-running the full pipeline.
## [1.0.48] - 2026-07-07
This release promotes the sealed **remove-discovery delivery** from the beta line to the stable `v1.0.48` package. It removes dynamic service discovery from the open-edition runtime, keeps legacy CLI compatibility aliases, syncs the open command/help/skill surface with the dws-wukong baseline, and includes the `dev connect` default-yolo behavior on the stable upgrade track.
+3 -3
View File
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** 🧪 **EXPERIMENTAL** | 22 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 22 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
How to pick:
@@ -331,7 +331,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 22 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
+3 -3
View File
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi** 🧪 **试验版 / Preview** | 22 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。22 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
怎么选:
@@ -328,7 +328,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 22 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
+16
View File
@@ -0,0 +1,16 @@
# cli_to_mcp smoke tests
This directory contains lightweight command-to-tool contract tests for hardcoded
DWS commands synced from `dws-wukong`.
The tests do not call live DingTalk APIs. They exercise command help, validation,
and `--dry-run` output so command paths and MCP argument mappings stay stable.
Run with an already built binary:
```bash
DWS_BIN=/path/to/dws pytest auto-test/cli_to_mcp/testcases
```
If `DWS_BIN` is not set, the runner falls back to `go run ./cmd` from the repo
root.
@@ -0,0 +1,193 @@
from test_utils import combined_output, dry_run_args
def assert_ok(result):
output = combined_output(result)
assert result.returncode == 0, output
return output
def test_agoal_strategy_and_contract_cli_to_mcp(dws):
output = assert_ok(
dws.run_raw(
"agoal",
"strategy",
"list",
"--scope-type",
"PERSONAL",
"--scope-id",
"user123",
"--request-id",
"req-1",
"--dry-run",
)
)
assert "list_strategy_decodings" in output
assert dry_run_args(output) == {
"scopeType": "PERSONAL",
"openId": "user123",
"requestId": "req-1",
}
output = assert_ok(
dws.run_raw(
"agoal",
"strategy",
"update",
"--profile-id",
"profile123",
"--content",
'[{"id":"e1","title":{"title":"new"}}]',
"--dry-run",
)
)
assert "update_strategy_decoding" in output
assert dry_run_args(output) == {
"profileId": "profile123",
"content": [{"id": "e1", "title": {"title": "new"}}],
}
output = assert_ok(dws.run_raw("agoal", "contract", "fields", "--dry-run"))
assert "list_op_contract_fields" in output
assert dry_run_args(output) == {}
output = assert_ok(
dws.run_raw(
"agoal",
"contract",
"update",
"--contract-id",
"contract123",
"--dimensions",
'[{"id":"dim1","title":"metric"}]',
"--audit-config",
'{"needAudit":true}',
"--objective-template",
'{"id":"tpl1"}',
"--dry-run",
)
)
assert "update_op_contract" in output
assert dry_run_args(output) == {
"contractId": "contract123",
"dimensions": [{"id": "dim1", "title": "metric"}],
"auditConfig": '{"needAudit":true}',
"objectiveTemplate": '{"id":"tpl1"}',
}
def test_agoal_scorecard_user_report_template_cli_to_mcp(dws):
output = assert_ok(
dws.run_raw(
"agoal",
"scorecard",
"detail",
"--selected-time",
"2026-01-01T00:00:00+08:00",
"--dept-id",
"dept123",
"--dry-run",
)
)
assert "get_score_card_detail" in output
args = dry_run_args(output)
assert args["deptId"] == "dept123"
assert args["selectedTime"] == 1767196800000
output = assert_ok(
dws.run_raw(
"agoal",
"scorecard",
"update",
"--dept-id",
"dept123",
"--selected-time",
"2026-01-01",
"--id",
"sc123",
"--tracking-period-type",
"MONTHLY",
"--content",
'[{"id":"dim1","items":[]}]',
"--dry-run",
)
)
assert "update_score_card" in output
args = dry_run_args(output)
assert args["selectedTime"] == 1767196800000
assert args["content"] == [{"id": "dim1", "items": []}]
output = assert_ok(
dws.run_raw(
"agoal",
"user",
"objectives",
"--user-id",
"user123",
"--rule-id",
"rule123",
"--period-ids",
"p1,p2",
"--dry-run",
)
)
assert "list_user_objectives" in output
assert dry_run_args(output) == {
"dingUserId": "user123",
"objectiveRuleId": "rule123",
"periodIds": ["p1", "p2"],
}
output = assert_ok(
dws.run_raw(
"agoal",
"report",
"submit-detail",
"--template-id",
"tpl123",
"--submit-state",
"LATE",
"--query-date",
"2026-06-18T00:00:00+08:00",
"--page",
"1",
"--page-size",
"20",
"--keyword",
"alice",
"--dry-run",
)
)
assert "get_submit_detail" in output
assert dry_run_args(output) == {
"templateId": "tpl123",
"submitState": "LATE",
"queryDate": "2026-06-18",
"page": 1,
"pageSize": 20,
"keyword": "alice",
}
output = assert_ok(
dws.run_raw(
"agoal",
"obj-template",
"create-or-update",
"--title",
"tpl",
"--dimensions",
'[{"title":"dim"}]',
"--objective-weight",
"--dimension-weight",
"--compute-by-weight",
"--dry-run",
)
)
assert "create_or_update_obj_template" in output
assert dry_run_args(output) == {
"title": "tpl",
"dimensions": '[{"title":"dim"}]',
"objectiveWeight": True,
"dimensionWeight": True,
"computeByWeight": True,
}
@@ -0,0 +1,163 @@
from test_utils import combined_output, dry_run_args
def assert_ok(result):
output = combined_output(result)
assert result.returncode == 0, output
return output
def test_group_notice_cli_to_mcp(dws):
output = assert_ok(
dws.run_raw(
"chat",
"group",
"notice",
"create",
"--group",
"cid123",
"--content",
"maintenance tonight",
"--sticky",
"--send-ding",
"--dry-run",
)
)
assert "create_group_notice" in output
assert dry_run_args(output) == {
"openConversationId": "cid123",
"content": "maintenance tonight",
"sticky": True,
"sendDing": True,
}
output = assert_ok(
dws.run_raw(
"chat",
"group",
"notice",
"edit",
"--group",
"cid123",
"--notice-id",
"notice123",
"--content",
"updated",
"--dry-run",
)
)
assert "edit_group_notice" in output
assert dry_run_args(output) == {
"openConversationId": "cid123",
"dataId": "notice123",
"content": "updated",
}
output = assert_ok(
dws.run_raw(
"chat",
"group",
"notice",
"get",
"--group",
"cid123",
"--notice-id",
"notice123",
"--dry-run",
)
)
assert "get_group_notice" in output
assert dry_run_args(output) == {
"openConversationId": "cid123",
"dataId": "notice123",
}
output = assert_ok(
dws.run_raw(
"chat",
"group",
"notice",
"list",
"--group",
"cid123",
"--limit",
"20",
"--cursor",
"next",
"--scheduled",
"--dry-run",
)
)
assert "list_group_notices" in output
assert dry_run_args(output) == {
"openConversationId": "cid123",
"limit": 20,
"cursor": "next",
"scheduled": True,
}
def test_chat_misc_new_commands_cli_to_mcp(dws):
output = assert_ok(
dws.run_raw(
"chat",
"group",
"share-invite",
"--source",
"sourceCid",
"--target",
"targetCid",
"--expires-seconds",
"3600",
"--uuid",
"uuid-1",
"--dry-run",
)
)
assert "share_group_invite_url" in output
assert dry_run_args(output) == {
"sourceOpenConversationId": "sourceCid",
"targetOpenConversationId": "targetCid",
"expiresSeconds": 3600,
"uuid": "uuid-1",
}
output = assert_ok(
dws.run_raw("chat", "text", "translate", "--query", "hello", "--to", "zh_CN", "--dry-run")
)
assert "translate" in output
assert dry_run_args(output) == {"query": "hello", "to": "zh_CN"}
output = assert_ok(
dws.run_raw(
"chat",
"category",
"create-smart",
"--name",
"priority",
"--keywords",
"alpha,beta",
"--members",
"uid1,uid2",
"--dry-run",
)
)
assert "create_smart_conv_category" in output
assert dry_run_args(output) == {
"title": "priority",
"keywords": ["alpha", "beta"],
"memberOpenDingTalkIds": ["uid1", "uid2"],
}
output = assert_ok(
dws.run_raw(
"chat",
"message",
"list-emotion-replies",
"--msg-ids",
"msg1,msg2",
"--dry-run",
)
)
assert "list_message_emotion_replies" in output
assert dry_run_args(output) == {"openMessageIds": ["msg1", "msg2"]}
@@ -0,0 +1,8 @@
import pytest
from test_utils import DWSRunner
@pytest.fixture(scope="session")
def dws():
return DWSRunner()
@@ -0,0 +1,54 @@
from test_utils import combined_output
def assert_ok(result):
output = combined_output(result)
assert result.returncode == 0, output
return output
def test_doc_import_help_and_validation(dws, tmp_path):
output = assert_ok(dws.run_raw("doc", "import", "--help"))
assert "dws doc import" in output
assert "--file string" in output
assert "--workspace string" in output
assert "--name string" in output
result = dws.run_raw("doc", "import", "--file", str(tmp_path / "missing.md"), "--dry-run")
output = combined_output(result)
assert result.returncode != 0
assert "cannot read file" in output
bad = tmp_path / "bad.exe"
bad.write_text("bad", encoding="utf-8")
result = dws.run_raw("doc", "import", "--file", str(bad), "--dry-run")
output = combined_output(result)
assert result.returncode != 0
assert "unsupported file format" in output
def test_doc_import_dry_run(dws, tmp_path):
source = tmp_path / "sample.md"
source.write_text("# Sample\n\nhello\n", encoding="utf-8")
output = assert_ok(
dws.run_raw(
"doc",
"import",
"--file",
str(source),
"--name",
"Imported Sample",
"--workspace",
"workspace123",
"--dry-run",
)
)
assert "Imported Sample" in output
assert "sample.md" in output
assert "md" in output
def test_doc_import_get_dry_run(dws):
output = assert_ok(dws.run_raw("doc", "import", "get", "--task-id", "task123", "--dry-run"))
assert "task123" in output
@@ -0,0 +1,143 @@
import os
from test_utils import combined_output, dry_run_args
def mail_email() -> str:
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
def assert_ok(result):
output = combined_output(result)
assert result.returncode == 0, output
return output
def assert_fails(result, expected: str):
output = combined_output(result)
assert result.returncode != 0, output
assert expected in output
def test_mailbox_profile_cli_to_mcp(dws):
output = assert_ok(dws.run_raw("mail", "mailbox", "profile", "--help"))
assert "dws mail mailbox profile" in output
assert "--email string" in output
assert_fails(dws.run_raw("mail", "mailbox", "profile"), "email")
output = assert_ok(
dws.run_raw("mail", "mailbox", "profile", "--email", mail_email(), "--dry-run")
)
assert "get_mailbox_profile" in output
assert dry_run_args(output) == {"email": mail_email()}
def test_message_batch_get_cli_to_mcp(dws):
output = assert_ok(dws.run_raw("mail", "message", "batch-get", "--help"))
assert "dws mail message batch-get" in output
assert "--email string" in output
assert "--ids string" in output
assert_fails(
dws.run_raw("mail", "message", "batch-get", "--email", mail_email()),
"ids",
)
too_many_ids = ",".join(f"msg_{i:02d}" for i in range(21))
assert_fails(
dws.run_raw(
"mail",
"message",
"batch-get",
"--email",
mail_email(),
"--ids",
too_many_ids,
"--dry-run",
),
"20",
)
output = assert_ok(
dws.run_raw(
"mail",
"message",
"batch-get",
"--email",
mail_email(),
"--ids",
"msg_001,msg_002",
"--dry-run",
)
)
assert "get_email_by_message_id" in output
assert "msg_001" in output
assert "msg_002" in output
def test_sent_message_recall_cli_to_mcp(dws):
output = assert_ok(dws.run_raw("mail", "sent-message", "recall", "--help"))
assert "dws mail sent-message recall" in output
assert "--subject string" in output
assert "--yes" in output
assert_fails(
dws.run_raw(
"mail",
"sent-message",
"recall",
"--email",
mail_email(),
"--id",
"msg_001",
"--subject",
"subject",
),
"--yes",
)
output = assert_ok(
dws.run_raw(
"mail",
"sent-message",
"recall",
"--email",
mail_email(),
"--id",
"msg_001",
"--subject",
"subject",
"--yes",
"--dry-run",
)
)
assert "recall_sent_message" in output
assert dry_run_args(output) == {
"email": mail_email(),
"id": "msg_001",
"subject": "subject",
}
def test_sent_message_recall_detail_cli_to_mcp(dws):
output = assert_ok(dws.run_raw("mail", "sent-message", "recall-detail", "--help"))
assert "dws mail sent-message recall-detail" in output
assert "--email string" in output
assert "--id string" in output
assert "FINISHED" in output
output = assert_ok(
dws.run_raw(
"mail",
"sent-message",
"recall-detail",
"--email",
mail_email(),
"--id",
"task_001",
"--dry-run",
)
)
assert "get_recall_detail" in output
assert dry_run_args(output) == {"email": mail_email(), "id": "task_001"}
@@ -0,0 +1,133 @@
import os
from test_utils import combined_output, dry_run_args
def mail_email() -> str:
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
def assert_ok(result):
output = combined_output(result)
assert result.returncode == 0, output
return output
def test_auto_reply_update_cli_to_mcp(dws):
output = assert_ok(dws.run_raw("mail", "auto-reply", "update", "--help"))
for flag in ("--email string", "--enabled string", "--start string", "--end string", "--scope string", "--content string"):
assert flag in output
output = assert_ok(
dws.run_raw(
"mail",
"auto-reply",
"update",
"--email",
mail_email(),
"--enabled",
"true",
"--start",
"2026/07/01 09:00:00 +0800",
"--end",
"2026/07/07 18:00:00 +0800",
"--scope",
"all",
"--content",
"out of office",
"--dry-run",
)
)
assert "update_auto_reply" in output
assert dry_run_args(output) == {
"email": mail_email(),
"enabled": True,
"startTime": "2026/07/01 09:00:00 +0800",
"endTime": "2026/07/07 18:00:00 +0800",
"scope": "all",
"content": "out of office",
}
def test_allow_list_cli_to_mcp(dws):
output = assert_ok(dws.run_raw("mail", "allow-list", "list", "--email", mail_email(), "--dry-run"))
assert "list_mailbox_allowlist" in output
assert dry_run_args(output) == {"email": mail_email()}
output = assert_ok(
dws.run_raw(
"mail",
"allow-list",
"add",
"--email",
mail_email(),
"--entries",
"partner@example.com,@example.org",
"--dry-run",
)
)
assert "add_mailbox_allowlist" in output
assert dry_run_args(output) == {
"email": mail_email(),
"entries": ["partner@example.com", "@example.org"],
}
output = assert_ok(
dws.run_raw(
"mail",
"allow-list",
"remove",
"--email",
mail_email(),
"--entries",
"partner@example.com",
"--dry-run",
)
)
assert "remove_mailbox_allowlist" in output
assert dry_run_args(output) == {
"email": mail_email(),
"entries": ["partner@example.com"],
}
def test_block_list_cli_to_mcp(dws):
output = assert_ok(dws.run_raw("mail", "block-list", "list", "--email", mail_email(), "--dry-run"))
assert "list_mailbox_blocklist" in output
assert dry_run_args(output) == {"email": mail_email()}
output = assert_ok(
dws.run_raw(
"mail",
"block-list",
"add",
"--email",
mail_email(),
"--entries",
"spam@example.com,@junk.example",
"--dry-run",
)
)
assert "add_mailbox_blocklist" in output
assert dry_run_args(output) == {
"email": mail_email(),
"entries": ["spam@example.com", "@junk.example"],
}
output = assert_ok(
dws.run_raw(
"mail",
"block-list",
"remove",
"--email",
mail_email(),
"--entries",
"spam@example.com",
"--dry-run",
)
)
assert "remove_mailbox_blocklist" in output
assert dry_run_args(output) == {
"email": mail_email(),
"entries": ["spam@example.com"],
}
@@ -0,0 +1,80 @@
from test_utils import combined_output, dry_run_args
def assert_ok(result):
output = combined_output(result)
assert result.returncode == 0, output
return output
def test_group_dimension_cli_to_mcp(dws):
output = assert_ok(dws.run_raw("sheet", "group-dimension", "--help"))
assert "dws sheet group-dimension" in output
assert "--group-state string" in output
output = assert_ok(
dws.run_raw(
"sheet",
"group-dimension",
"--node",
"node123",
"--sheet-id",
"Sheet1",
"--range",
"3:7",
"--group-state",
"fold",
"--dry-run",
)
)
assert "group_dimension" in output
assert dry_run_args(output) == {
"nodeId": "node123",
"sheetId": "Sheet1",
"range": "3:7",
"groupState": "fold",
}
def test_ungroup_dimension_cli_to_mcp(dws):
output = assert_ok(dws.run_raw("sheet", "ungroup-dimension", "--help"))
assert "dws sheet ungroup-dimension" in output
output = assert_ok(
dws.run_raw(
"sheet",
"ungroup-dimension",
"--node",
"node123",
"--sheet-id",
"Sheet1",
"--range",
"C:F",
"--dry-run",
)
)
assert "ungroup_dimension" in output
assert dry_run_args(output) == {
"nodeId": "node123",
"sheetId": "Sheet1",
"range": "C:F",
}
def test_group_dimension_rejects_invalid_state(dws):
result = dws.run_raw(
"sheet",
"group-dimension",
"--node",
"node123",
"--sheet-id",
"Sheet1",
"--range",
"3:7",
"--group-state",
"invalid",
"--dry-run",
)
output = combined_output(result)
assert result.returncode != 0
assert "group-state" in output
@@ -0,0 +1,58 @@
import json
import os
import re
import shlex
import subprocess
from pathlib import Path
def repo_root(start_file: str) -> Path:
current = Path(start_file).resolve()
for parent in [current, *current.parents]:
if (parent / "go.mod").exists():
return parent
raise RuntimeError(f"cannot locate repo root from {start_file}")
def resolve_dws_cmd(start_file: str) -> list[str]:
root = repo_root(start_file)
if env_bin := os.environ.get("DWS_BIN"):
return shlex.split(env_bin)
for rel in ("dws", "build/dws", "bin/dws", "dingtalk-workspace-cli"):
candidate = root / rel
if candidate.exists() and os.access(candidate, os.X_OK):
return [str(candidate)]
return ["go", "run", "./cmd"]
def combined_output(result: subprocess.CompletedProcess) -> str:
return (result.stdout or "") + (result.stderr or "")
def dry_run_args(output: str) -> dict:
match = re.search(r"Arguments:\s*(\{.*\})", output, re.S)
assert match, f"dry-run output does not contain Arguments JSON: {output}"
return json.loads(match.group(1))
class DWSRunner:
def __init__(self):
self.root = repo_root(__file__)
self.cmd = resolve_dws_cmd(__file__)
def run_raw(self, *args: str, timeout: int = 45) -> subprocess.CompletedProcess:
return subprocess.run(
[*self.cmd, *args],
cwd=self.root,
text=True,
capture_output=True,
timeout=timeout,
)
def run(self, *args: str, timeout: int = 45):
result = self.run_raw(*args, timeout=timeout)
output = combined_output(result)
assert result.returncode == 0, output
return json.loads(result.stdout)
+46 -4
View File
@@ -446,6 +446,7 @@ func newAuthStatusCommand() *cobra.Command {
authenticated := false
refreshed := false
var tokenData *authpkg.TokenData
var statusErr error
provider := authpkg.NewOAuthProvider(configDir, nil)
configureOAuthProviderCompatibility(provider, configDir)
if data, err := provider.Status(); err == nil {
@@ -468,12 +469,15 @@ func newAuthStatusCommand() *cobra.Command {
if authStatusAuthenticated(tokenData) {
authenticated = true
}
} else {
statusErr = err
}
diagnostic := authStatusDiagnosticFromError(statusErr)
// Check if JSON output is requested
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData)
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData, diagnostic)
}
// Default table output
@@ -503,7 +507,10 @@ func newAuthStatusCommand() *cobra.Command {
}
} else {
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
if !edition.Get().IsEmbedded {
if diagnostic != nil {
fmt.Fprintf(w, "%-16s%s\n", "原因:", diagnostic.Message)
fmt.Fprintf(w, "%-16s%s\n", "提示:", diagnostic.Hint)
} else if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录")
}
}
@@ -1199,6 +1206,8 @@ type authStatusResponse struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"`
Hint string `json:"hint,omitempty"`
Refreshed bool `json:"refreshed,omitempty"`
TokenValid bool `json:"token_valid,omitempty"`
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
@@ -1210,14 +1219,47 @@ type authStatusResponse struct {
UserName string `json:"user_name,omitempty"`
}
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData) error {
type authStatusDiagnostic struct {
Reason string
Message string
Hint string
}
func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
if err == nil {
return nil
}
if keychain.IsDEKMissing(err) {
return &authStatusDiagnostic{
Reason: "dek_missing",
Message: "本地登录密钥缺失,无法解密已保存的登录态",
Hint: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
}
}
if !keychain.IsUnavailable(err) {
return nil
}
return &authStatusDiagnostic{
Reason: "keychain_unavailable",
Message: "无法读取 macOS Keychain 中的登录密钥,无法判断登录状态",
Hint: "检查 macOS 默认钥匙串是否存在且已解锁;修复后重试,或在测试环境设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
}
}
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData, diagnostic *authStatusDiagnostic) error {
resp := authStatusResponse{
Success: true,
Authenticated: authenticated,
}
if !authenticated {
resp.Message = "未登录"
if diagnostic != nil {
resp.Message = diagnostic.Message
resp.Reason = diagnostic.Reason
resp.Hint = diagnostic.Hint
} else {
resp.Message = "未登录"
}
} else if data != nil {
resp.Refreshed = refreshed
resp.TokenValid = data.IsAccessTokenValid()
+106
View File
@@ -16,7 +16,9 @@ package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
@@ -132,6 +134,106 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
}
}
func TestAuthStatusJSONReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
},
})
t.Cleanup(func() {
edition.Override(prev)
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
}
var resp struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Reason string `json:"reason"`
Message string `json:"message"`
Hint string `json:"hint"`
}
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
}
if !resp.Success {
t.Fatalf("success = false, want true; response=%+v", resp)
}
if resp.Authenticated {
t.Fatalf("authenticated = true, want false; response=%+v", resp)
}
if resp.Reason != "keychain_unavailable" {
t.Fatalf("reason = %q, want keychain_unavailable; response=%+v", resp.Reason, resp)
}
if !strings.Contains(resp.Message, "Keychain") && !strings.Contains(resp.Message, "钥匙串") {
t.Fatalf("message should mention Keychain/钥匙串; response=%+v", resp)
}
if !strings.Contains(resp.Hint, keychain.DisableKeychainEnv) {
t.Fatalf("hint should mention %s; response=%+v", keychain.DisableKeychainEnv, resp)
}
}
func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
},
})
t.Cleanup(func() {
edition.Override(prev)
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
}
var resp struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Reason string `json:"reason"`
Message string `json:"message"`
Hint string `json:"hint"`
}
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
}
if !resp.Success {
t.Fatalf("success = false, want true; response=%+v", resp)
}
if resp.Authenticated {
t.Fatalf("authenticated = true, want false; response=%+v", resp)
}
if resp.Reason != "dek_missing" {
t.Fatalf("reason = %q, want dek_missing; response=%+v", resp.Reason, resp)
}
if !strings.Contains(resp.Message, "登录密钥") {
t.Fatalf("message should mention 登录密钥; response=%+v", resp)
}
if !strings.Contains(resp.Hint, "重新登录") {
t.Fatalf("hint should mention 重新登录; response=%+v", resp)
}
}
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
// Isolate keychain storage to a per-test directory so the saved
// token can't leak into other test packages running in parallel.
@@ -824,6 +926,10 @@ func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
func (f *authLoginRecommendSequenceCaller) Fields() string { return "" }
func (f *authLoginRecommendSequenceCaller) JQ() string { return "" }
func stringSliceArgEqual(got any, want []string) bool {
if got == nil {
return len(want) == 0
+53
View File
@@ -21,6 +21,7 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
@@ -29,6 +30,8 @@ import (
"github.com/spf13/cobra"
)
var doctorKeychainDiagnose = keychain.Diagnose
// checkStatus represents the outcome of a single doctor check.
type checkStatus string
@@ -76,6 +79,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
authResult := doctorCheckAuth(cmd.Context(), w, jsonOut)
checks = append(checks, authResult)
keychainResult := doctorCheckKeychain(w, jsonOut)
checks = append(checks, keychainResult)
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
checks = append(checks, networkResult)
@@ -132,6 +138,19 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
data, err := provider.Status()
if err != nil || data == nil {
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
r := checkResult{
Name: "auth",
Status: statusFail,
Message: diagnostic.Message,
Hint: diagnostic.Hint,
Detail: map[string]string{"reason": diagnostic.Reason},
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"}
if !edition.Get().IsEmbedded {
r.Hint = "运行 dws auth login 进行登录"
@@ -182,6 +201,40 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
return r
}
// ── Keychain check ─────────────────────────────────────────────────────
func doctorCheckKeychain(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, tui.Dim("检查钥匙串状态... "))
}
diagnostic := doctorKeychainDiagnose()
r := checkResult{
Name: "keychain",
Status: statusPass,
Message: diagnostic.Message,
Detail: diagnostic.Detail,
}
if !diagnostic.OK {
r.Status = statusFail
r.Hint = diagnostic.Hint
if diagnostic.Detail == nil {
r.Detail = map[string]string{"reason": diagnostic.Reason}
} else if diagnostic.Reason != "" {
detail := make(map[string]string, len(diagnostic.Detail)+1)
for k, v := range diagnostic.Detail {
detail[k] = v
}
detail["reason"] = diagnostic.Reason
r.Detail = detail
}
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
// ── Network check ───────────────────────────────────────────────────────
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
+109
View File
@@ -15,9 +15,16 @@ package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCountResults(t *testing.T) {
@@ -127,6 +134,108 @@ func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
}
}
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
},
})
t.Cleanup(func() {
edition.Override(prev)
})
var buf bytes.Buffer
r := doctorCheckAuth(context.Background(), &buf, false)
if r.Name != "auth" {
t.Fatalf("name = %q, want auth", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if !strings.Contains(r.Message, "Keychain") && !strings.Contains(r.Message, "钥匙串") {
t.Fatalf("message should mention Keychain/钥匙串; result=%+v", r)
}
if !strings.Contains(r.Hint, keychain.DisableKeychainEnv) {
t.Fatalf("hint should mention %s; result=%+v", keychain.DisableKeychainEnv, r)
}
}
func TestDoctorCheckAuthReportsDEKMissing(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
},
})
t.Cleanup(func() {
edition.Override(prev)
})
var buf bytes.Buffer
r := doctorCheckAuth(context.Background(), &buf, false)
if r.Name != "auth" {
t.Fatalf("name = %q, want auth", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if !strings.Contains(r.Message, "登录密钥") {
t.Fatalf("message should mention 登录密钥; result=%+v", r)
}
if !strings.Contains(r.Hint, "重新登录") {
t.Fatalf("hint should mention 重新登录; result=%+v", r)
}
detail, ok := r.Detail.(map[string]string)
if !ok || detail["reason"] != "dek_missing" {
t.Fatalf("detail = %#v, want reason=dek_missing", r.Detail)
}
}
func TestDoctorCheckKeychainReportsUnavailable(t *testing.T) {
prev := doctorKeychainDiagnose
doctorKeychainDiagnose = func() keychain.Diagnostic {
return keychain.Diagnostic{
OK: false,
Reason: "keychain_unavailable",
Message: "macOS 默认钥匙串不存在",
Hint: "恢复默认钥匙串后重试",
Detail: map[string]string{
"default_keychain": "/tmp/missing.keychain-db",
},
}
}
t.Cleanup(func() {
doctorKeychainDiagnose = prev
})
var buf bytes.Buffer
r := doctorCheckKeychain(&buf, false)
if r.Name != "keychain" {
t.Fatalf("name = %q, want keychain", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if r.Message != "macOS 默认钥匙串不存在" {
t.Fatalf("message = %q", r.Message)
}
if r.Hint == "" {
t.Fatalf("hint is empty; result=%+v", r)
}
detail, ok := r.Detail.(map[string]string)
if !ok || detail["default_keychain"] == "" {
t.Fatalf("detail = %#v, want default_keychain", r.Detail)
}
}
func TestDoctorCommandStructure(t *testing.T) {
cmd := newDoctorCommand()
if cmd.Use != "doctor" {
+37 -6
View File
@@ -590,6 +590,33 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
return string(data)
}
func makePATErrorJSONWithAuthorizationURL(flowID, clientID, authURL string) string {
type patData struct {
Desc string `json:"desc"`
FlowID string `json:"flowId"`
AuthorizationURL string `json:"authorizationUrl"`
ClientID string `json:"clientId"`
}
payload := struct {
Code string `json:"code"`
Data patData `json:"data"`
}{
Code: "AGENT_CODE_NOT_EXISTS",
Data: patData{
Desc: "test auth",
FlowID: flowID,
AuthorizationURL: authURL,
ClientID: clientID,
},
}
data, _ := json.Marshal(payload)
return string(data)
}
func patTestAuthorizationURL(server *httptest.Server) string {
return server.URL + "/pat"
}
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
@@ -664,10 +691,13 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, _ := setupHandlePATServer(t, "APPROVED", "")
server, configDir := setupHandlePATServer(t, "APPROVED", "")
defer server.Close()
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var retried atomic.Bool
var retryHadKey atomic.Bool
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error {
@@ -691,7 +721,7 @@ func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) {
server, _ := setupHandlePATServer(t, "APPROVED", "")
defer server.Close()
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var out bytes.Buffer
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
if err != nil {
@@ -721,7 +751,7 @@ func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) {
}
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var out bytes.Buffer
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
if err != nil {
@@ -1196,7 +1226,8 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
fallback: mock,
globalFlags: &GlobalFlags{Format: "table"},
}
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}`
authURL := patTestAuthorizationURL(server)
raw := makePATErrorJSONWithAuthorizationURL("flow-approved", "test-client-id", authURL)
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
@@ -1216,7 +1247,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
if !strings.Contains(buf.String(), "需要 PAT 授权") {
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
}
if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") {
if !strings.Contains(buf.String(), "授权链接: "+authURL) {
t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String())
}
if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") {
+15 -2
View File
@@ -64,7 +64,7 @@ skill 源默认取二进制内嵌的版本(升级二进制即升级 skill)
dws skill setup --mode mono --yes # 非交互装 mono
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个
dws skill setup --mode multi -x live -x devdoc # 装其余 20 个,剔除 2 个
dws skill setup --source /path/to/repo # 显式指定 skill 源`,
DisableAutoGenTag: true,
RunE: runSkillSetup,
@@ -128,6 +128,19 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
}
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
fmt.Fprintf(out, "[DRY-RUN] 预览(不写入任何文件):mode=%s,来源 %s\n", mode, skillSrc)
fmt.Fprintln(out, "将安装到:")
for _, d := range dests {
fmt.Fprintf(out, " - %s\n", d)
}
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
}
return nil
}
if !autoYes {
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
if err != nil {
@@ -493,7 +506,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
if mode == skillSetupModeMulti {
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
fmt.Fprintln(out, " · 20 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
fmt.Fprintln(out, " · 22 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
fmt.Fprintln(out, " 稳定版请用 --mode mono")
+6
View File
@@ -35,6 +35,11 @@ import (
// Setting keychain.StorageDirEnv here forces every keychain read/write in
// this binary into a per-process tempdir, eliminating that contamination
// without touching production code.
//
// PAT authorization tests also exercise code paths that normally open the
// system browser. Keep the package-wide default opener inert so running the
// test binary never launches a page on the developer's machine; tests that
// need to assert the URL can still replace openBrowserFunc locally.
func TestMain(m *testing.M) {
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
if err != nil {
@@ -44,6 +49,7 @@ func TestMain(m *testing.M) {
_ = os.RemoveAll(tmpDir)
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
}
openBrowserFunc = func(string) error { return nil }
code := m.Run()
_ = os.RemoveAll(tmpDir)
os.Exit(code)
+14
View File
@@ -54,6 +54,20 @@ func (a *toolCallerAdapter) DryRun() bool {
return a.flags != nil && a.flags.DryRun
}
func (a *toolCallerAdapter) Fields() string {
if a.flags != nil {
return a.flags.Fields
}
return ""
}
func (a *toolCallerAdapter) JQ() string {
if a.flags != nil {
return a.flags.JQ
}
return ""
}
func convertResult(r executor.Result) *edition.ToolResult {
resp := r.Response
if resp == nil {
+603
View File
@@ -0,0 +1,603 @@
package helpers
import (
"encoding/json"
"fmt"
"time"
"github.com/spf13/cobra"
)
// ──────────────────────────────────────────────────────────
// dws agoal — Agoal 管理
// ──────────────────────────────────────────────────────────
func newAgoalCommand() *cobra.Command {
root := &cobra.Command{
Use: "agoal",
Short: "Agoal 管理",
Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。
命令结构:
dws agoal strategy list 获取战略解码列表
dws agoal strategy detail 获取战略解码详情
dws agoal strategy update 更新战略解码
dws agoal contract list 获取经营合约列表
dws agoal contract fields 获取经营合约字段列表
dws agoal contract detail 获取经营合约详情
dws agoal contract update 更新经营合约
dws agoal scorecard detail 获取计分卡详情
dws agoal scorecard entity-detail 获取计分卡实体详情
dws agoal scorecard update 更新计分卡
dws agoal user rules 获取用户规则
dws agoal user objectives 查询用户目标列表
dws agoal report list-statistics 获取周月报数据跟催列表
dws agoal report submit-detail 获取周月报规则提交详情
dws agoal obj-template list 获取目标模板列表
dws agoal obj-template create-or-update 新增或更新目标模板`,
RunE: groupRunE,
}
// ── strategy: 战略解码管理 ──────────────────────────────────
strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE}
strategyListCmd := &cobra.Command{
Use: "list",
Short: "获取战略解码列表",
Long: `按部门或个人维度查询战略解码列表。
scopeType 支持:
DEPT — 按部门查询
PERSONAL — 按个人查询
--scope-id 为对应维度的钉钉部门 id 或用户 id。`,
Example: ` dws agoal strategy list --scope-type PERSONAL --scope-id USER_ID
dws agoal strategy list --scope-type DEPT --scope-id DEPT_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "scope-type", "scope-id"); err != nil {
return err
}
toolArgs := map[string]any{
"scopeType": mustGetFlag(cmd, "scope-type"),
"openId": mustGetFlag(cmd, "scope-id"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("list_strategy_decodings", toolArgs)
},
}
strategyListCmd.Flags().String("scope-type", "", "解码范围类型: DEPT/PERSONAL (必填)")
strategyListCmd.Flags().String("scope-id", "", "scope-type 对应的钉钉部门 id 或用户 id (必填)")
strategyListCmd.Flags().String("request-id", "", "requestId (可选)")
strategyDetailCmd := &cobra.Command{
Use: "detail",
Short: "获取战略解码详情",
Long: `根据战略解码 id (profileId) 获取战略解码的详细信息。`,
Example: ` dws agoal strategy detail --profile-id PROFILE_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "profile-id"); err != nil {
return err
}
toolArgs := map[string]any{
"profileId": mustGetFlag(cmd, "profile-id"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("get_strategy_decoding_detail", toolArgs)
},
}
strategyDetailCmd.Flags().String("profile-id", "", "战略解码 id (必填)")
strategyDetailCmd.Flags().String("request-id", "", "requestId (可选)")
strategyUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新战略解码",
Long: `谨慎操作,基于查询接口返回的老数据进行修改,本接口是覆盖逻辑,会根据战略解码id进行对应的修改。
--content 为 JSON 数组,每个实体对象包含:
id — 实体 id
title — 标题对象 {"title":"标题文本"}
linkEntityId — 所属的实体 ID(查询接口中此字段有值一定要传进来)
entityType — 类型: OGSM_OBJECTIVE/目的、OGSM_GOAL/目标、OGSM_STRATEGY/策略、OGSM_MEASUREMENT/衡量标准、OGSM_TACTICS/行动方案
status — 状态: NORMAL/正常、PRE_PUBLISH_THEN_UPDATE/预发布更新、PRE_PUBLISH_THEN_CREATE/预发布新增、PRE_PUBLISH_THEN_DELETE/预发布删除
supporters — 承接人数组 [{type, dingId, staffId}],type: USER/个人、DEPARTMENT/部门,staffId 类型为 USER 时必填
indicators — 关键指标 id 字符串数组,如 ["id1","id2"]
linkSources — 资源关联数组 [{id, linkType, linkId, source, objectId, keyResultId}]
linkType: project/项目、task/任务、campaign/战役空间、product/产品空间、doc/文档、standard/其它
source: teambition
executors — 人员 dingId 字符串数组,如 ["dingId1","dingId2"]
teams — 部门 dingId 字符串数组,如 ["dingId1","dingId2"]`,
Example: ` dws agoal strategy update --profile-id PROFILE_ID --content '[{"id":"entity1","title":{"title":"新目标"},"entityType":"OGSM_OBJECTIVE","status":"NORMAL","executors":["dingId1"],"teams":["deptDingId1"]}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "profile-id", "content"); err != nil {
return err
}
var contentArr []any
if err := json.Unmarshal([]byte(mustGetFlag(cmd, "content")), &contentArr); err != nil {
return fmt.Errorf("--content must be a valid JSON array: %w", err)
}
toolArgs := map[string]any{
"profileId": mustGetFlag(cmd, "profile-id"),
"content": contentArr,
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("update_strategy_decoding", toolArgs)
},
}
strategyUpdateCmd.Flags().String("profile-id", "", "战略解码 id (必填)")
strategyUpdateCmd.Flags().String("content", "", "实体列表 JSON 数组 (必填)")
strategyUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
strategyCmd.AddCommand(strategyListCmd, strategyDetailCmd, strategyUpdateCmd)
// ── contract: 经营合约管理 ──────────────────────────────────
contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE}
contractListCmd := &cobra.Command{
Use: "list",
Short: "获取经营合约列表",
Long: `按部门或个人维度查询经营合约列表。
scopeType 支持:
DEPT — 按部门查询
PERSONAL — 按个人查询
--scope-id 为通讯录里的部门 id 或用户 id。`,
Example: ` dws agoal contract list --scope-type PERSONAL --scope-id USER_ID
dws agoal contract list --scope-type DEPT --scope-id DEPT_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "scope-type", "scope-id"); err != nil {
return err
}
toolArgs := map[string]any{
"scopeType": mustGetFlag(cmd, "scope-type"),
"openId": mustGetFlag(cmd, "scope-id"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("list_op_contracts", toolArgs)
},
}
contractListCmd.Flags().String("scope-type", "", "合约范围类型: DEPT/PERSONAL (必填)")
contractListCmd.Flags().String("scope-id", "", "scope-type 对应的钉钉部门 id 或用户 id (必填)")
contractListCmd.Flags().String("request-id", "", "requestId (可选)")
contractFieldsCmd := &cobra.Command{
Use: "fields",
Short: "获取经营合约字段列表",
Long: `获取指定组织下经营合约的字段配置信息。`,
Example: ` dws agoal contract fields`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("list_op_contract_fields", toolArgs)
},
}
contractFieldsCmd.Flags().String("request-id", "", "requestId (可选)")
contractDetailCmd := &cobra.Command{
Use: "detail",
Short: "获取经营合约详情",
Long: `根据经营合约 id 获取经营合约的详细信息。`,
Example: ` dws agoal contract detail --contract-id CONTRACT_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "contract-id"); err != nil {
return err
}
toolArgs := map[string]any{
"contractId": mustGetFlag(cmd, "contract-id"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("get_op_contract_detail", toolArgs)
},
}
contractDetailCmd.Flags().String("contract-id", "", "经营合约 id (必填)")
contractDetailCmd.Flags().String("request-id", "", "requestId (可选)")
contractUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新经营合约",
Long: `谨慎操作,一定要基于查询接口返回的老数据进行修改,然后给本接口使用,因为本接口是覆盖逻辑,会使用传入的数据去覆盖已存在的数据。
--dimensions 为 JSON 数组,每个维度对象包含:
id — 维度 id
title — 维度名称
description — 维度描述
weight — 维度权重
objectives — 目标列表
dimensionConfig — 维度配置
children — 子维度列表
可选参数:
--audit-config — 审批配置 JSON,如 {"needAudit":true,"processTemplateId":"TPL_ID"}
--objective-template — 合约模板 JSON,如 {"id":"TPL_ID","title":"模板名称"}`,
Example: ` dws agoal contract update --contract-id CONTRACT_ID \
--dimensions '[{"id":"dim1","title":"业绩","weight":60,"objectives":[{"id":"obj1"}]}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "contract-id", "dimensions"); err != nil {
return err
}
var dimensionsArr []any
if err := json.Unmarshal([]byte(mustGetFlag(cmd, "dimensions")), &dimensionsArr); err != nil {
return fmt.Errorf("--dimensions must be a valid JSON array: %w", err)
}
toolArgs := map[string]any{
"contractId": mustGetFlag(cmd, "contract-id"),
"dimensions": dimensionsArr,
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
if v, _ := cmd.Flags().GetString("audit-config"); v != "" {
toolArgs["auditConfig"] = v
}
if v, _ := cmd.Flags().GetString("objective-template"); v != "" {
toolArgs["objectiveTemplate"] = v
}
return callMCPTool("update_op_contract", toolArgs)
},
}
contractUpdateCmd.Flags().String("contract-id", "", "经营合约 id (必填)")
contractUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
contractUpdateCmd.Flags().String("audit-config", "", "审批配置 JSON (可选)")
contractUpdateCmd.Flags().String("objective-template", "", "合约模板 JSON (可选)")
contractUpdateCmd.Flags().String("dimensions", "", "维度内容列表 JSON 数组 (必填)")
contractCmd.AddCommand(contractListCmd, contractFieldsCmd, contractDetailCmd, contractUpdateCmd)
// ── scorecard: 计分卡管理 ───────────────────────────────────
scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE}
scorecardDetailCmd := &cobra.Command{
Use: "detail",
Short: "获取计分卡详情",
Long: `根据部门 id 和时间获取计分卡详情。
--selected-time 接受 ISO-8601 字符串,传入对应周期起始时刻:
2026年 → "2026-01-01T00:00:00+08:00"
2026年5月 → "2026-05-01T00:00:00+08:00"`,
Example: ` dws agoal scorecard detail --selected-time "2026-01-01T00:00:00+08:00" --dept-id DEPT_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "selected-time", "dept-id"); err != nil {
return err
}
selectedTimeMs, err := parseISO8601ToMillis(mustGetFlag(cmd, "selected-time"))
if err != nil {
return fmt.Errorf("--selected-time: %w", err)
}
toolArgs := map[string]any{
"selectedTime": selectedTimeMs,
"deptId": mustGetFlag(cmd, "dept-id"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("get_score_card_detail", toolArgs)
},
}
scorecardDetailCmd.Flags().String("selected-time", "", "ISO-8601 时间字符串,如 \"2026-01-01T00:00:00+08:00\" (必填)")
scorecardDetailCmd.Flags().String("dept-id", "", "部门 id (必填)")
scorecardDetailCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardEntityDetailCmd := &cobra.Command{
Use: "entity-detail",
Short: "获取计分卡实体详情",
Long: `根据计分卡 id 和实体 id 获取计分卡实体的详细信息。`,
Example: ` dws agoal scorecard entity-detail --sc-id SC_ID --entity-id ENTITY_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "sc-id", "entity-id"); err != nil {
return err
}
toolArgs := map[string]any{
"scId": mustGetFlag(cmd, "sc-id"),
"entityId": mustGetFlag(cmd, "entity-id"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("get_score_card_entity_detail", toolArgs)
},
}
scorecardEntityDetailCmd.Flags().String("sc-id", "", "计分卡 id (必填)")
scorecardEntityDetailCmd.Flags().String("entity-id", "", "计分卡实体 id (必填)")
scorecardEntityDetailCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新计分卡",
Long: `更新指定计分卡的内容。
--content 为 JSON 数组,每个维度对象包含:
id — 维度 id
title — 维度名称
items — 指标或关键事项列表,每项包含:
id — 实体 id
title — 名称
reference — 参考信息
start — 起始值
target — 目标值
executors — 负责人列表,每项包含 openId`,
Example: ` dws agoal scorecard update --dept-id DEPT_ID --selected-time "2025-01-01T00:00:00+08:00" --id SC_ID --tracking-period-type MONTHLY --content '[{"id":"dim1","title":"业绩","items":[{"id":"item1","title":"收入","target":"100"}]}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "dept-id", "selected-time", "id", "tracking-period-type", "content"); err != nil {
return err
}
selectedTimeMs, err := parseISO8601ToMillis(mustGetFlag(cmd, "selected-time"))
if err != nil {
return fmt.Errorf("--selected-time: %w", err)
}
var contentArr []any
if err := json.Unmarshal([]byte(mustGetFlag(cmd, "content")), &contentArr); err != nil {
return fmt.Errorf("--content must be a valid JSON array: %w", err)
}
toolArgs := map[string]any{
"deptId": mustGetFlag(cmd, "dept-id"),
"selectedTime": selectedTimeMs,
"id": mustGetFlag(cmd, "id"),
"trackingPeriodType": mustGetFlag(cmd, "tracking-period-type"),
"content": contentArr,
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("update_score_card", toolArgs)
},
}
scorecardUpdateCmd.Flags().String("dept-id", "", "部门 id (必填)")
scorecardUpdateCmd.Flags().String("selected-time", "", "ISO-8601 时间字符串,如 \"2026-01-01T00:00:00+08:00\" (必填)")
scorecardUpdateCmd.Flags().String("id", "", "计分卡 id (必填)")
scorecardUpdateCmd.Flags().String("tracking-period-type", "", "跟踪周期类型: MONTHLY/月度追踪、QUARTERLY/季度追踪 (必填)")
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
// ── user: 用户目标管理 ──────────────────────────────────────
userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}
userRulesCmd := &cobra.Command{
Use: "rules",
Short: "获取用户的规则周期列表",
Long: `获取用户的规则周期列表。不传 dingUserId 时默认取操作人自己的规则。`,
Example: ` dws agoal user rules
dws agoal user rules --user-id USER_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("user-id"); v != "" {
toolArgs["dingUserId"] = v
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("get_user_rules", toolArgs)
},
}
userRulesCmd.Flags().String("user-id", "", "要查询的人员钉钉 id (可选,默认取操作人)")
userRulesCmd.Flags().String("request-id", "", "requestId (可选)")
userObjectivesCmd := &cobra.Command{
Use: "objectives",
Short: "查询用户目标列表",
Long: `根据用户、规则 id 和周期列表查询用户的目标。`,
Example: ` dws agoal user objectives --user-id USER_ID --rule-id RULE_ID --period-ids "period1,period2"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "user-id", "rule-id", "period-ids"); err != nil {
return err
}
toolArgs := map[string]any{
"dingUserId": mustGetFlag(cmd, "user-id"),
"objectiveRuleId": mustGetFlag(cmd, "rule-id"),
"periodIds": parseCSVValues(mustGetFlag(cmd, "period-ids")),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
return callMCPTool("list_user_objectives", toolArgs)
},
}
userObjectivesCmd.Flags().String("user-id", "", "要查询的人员钉钉 id (必填)")
userObjectivesCmd.Flags().String("rule-id", "", "规则 id (必填)")
userObjectivesCmd.Flags().String("period-ids", "", "周期 id 列表,逗号分隔 (必填)")
userObjectivesCmd.Flags().String("request-id", "", "requestId (可选)")
userCmd.AddCommand(userRulesCmd, userObjectivesCmd)
// ── report: 周月报管理 ──────────────────────────────────────
reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE}
reportListStatisticsCmd := &cobra.Command{
Use: "list-statistics",
Short: "获取周月报数据跟催列表",
Long: `获取周月报规则的人员提交情况列表,包含按时提交、迟交、未提交的人员数量统计。`,
Example: ` dws agoal report list-statistics
dws agoal report list-statistics --keyword "周报规则"`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
if v, _ := cmd.Flags().GetString("keyword"); v != "" {
toolArgs["keyword"] = v
}
return callMCPTool("list_report_statistics", toolArgs)
},
}
reportListStatisticsCmd.Flags().String("request-id", "", "requestId (可选)")
reportListStatisticsCmd.Flags().String("keyword", "", "搜索规则名称 (可选)")
reportSubmitDetailCmd := &cobra.Command{
Use: "submit-detail",
Short: "获取周月报规则提交详情",
Long: `获取周月报规则提交详情,包含按时提交、迟交、未提交中的具体人员以及提交的时间、周报id等。
--submit-state 支持:
ON_TIME — 按时提交
LATE — 迟交
NOT_SUBMITTED — 未提交
--query-date 接受 ISO-8601 字符串,如 "2026-06-18T00:00:00+08:00"`,
Example: ` dws agoal report submit-detail --template-id TPL_ID --submit-state ON_TIME
dws agoal report submit-detail --template-id TPL_ID --submit-state LATE --query-date "2026-06-18T00:00:00+08:00" --page 1 --page-size 20`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "template-id", "submit-state"); err != nil {
return err
}
toolArgs := map[string]any{
"templateId": mustGetFlag(cmd, "template-id"),
"submitState": mustGetFlag(cmd, "submit-state"),
}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
if v, _ := cmd.Flags().GetString("query-date"); v != "" {
queryDateMs, err := parseISO8601ToMillis(v)
if err != nil {
return fmt.Errorf("--query-date: %w", err)
}
toolArgs["queryDate"] = time.UnixMilli(queryDateMs).In(shanghaiLocation()).Format("2006-01-02")
}
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
toolArgs["page"] = v
}
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
toolArgs["pageSize"] = v
}
if v, _ := cmd.Flags().GetString("keyword"); v != "" {
toolArgs["keyword"] = v
}
return callMCPTool("get_submit_detail", toolArgs)
},
}
reportSubmitDetailCmd.Flags().String("template-id", "", "规则模板 id (必填)")
reportSubmitDetailCmd.Flags().String("submit-state", "", "提交状态: ON_TIME(按时提交)/LATE(迟交)/NOT_SUBMITTED(未提交) (必填)")
reportSubmitDetailCmd.Flags().String("request-id", "", "requestId (可选)")
reportSubmitDetailCmd.Flags().String("query-date", "", "查询日期,ISO-8601 格式(如 \"2026-06-18T00:00:00+08:00\"),默认为当天 (可选)")
reportSubmitDetailCmd.Flags().Int("page", 0, "分页参数,默认为 1 (可选)")
reportSubmitDetailCmd.Flags().Int("page-size", 0, "分页参数,默认为 10 (可选)")
reportSubmitDetailCmd.Flags().String("keyword", "", "搜索员工名称 (可选)")
reportCmd.AddCommand(reportListStatisticsCmd, reportSubmitDetailCmd)
// ── template: 目标模板管理 ──────────────────────────────────
objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE}
objTemplateListCmd := &cobra.Command{
Use: "list",
Short: "获取目标模板列表",
Long: `获取目标模板列表,支持关键词搜索和分页。`,
Example: ` dws agoal obj-template list
dws agoal obj-template list --keyword "业绩" --page 1 --page-size 10`,
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
toolArgs["page"] = v
}
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
toolArgs["pageSize"] = v
}
if v, _ := cmd.Flags().GetString("keyword"); v != "" {
toolArgs["keyword"] = v
}
return callMCPTool("list_obj_template", toolArgs)
},
}
objTemplateListCmd.Flags().String("request-id", "", "requestId (可选)")
objTemplateListCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
objTemplateListCmd.Flags().Int("page-size", 0, "每页数量,默认 10 (可选)")
objTemplateListCmd.Flags().String("keyword", "", "搜索关键词 (可选)")
objTemplateCreateOrUpdateCmd := &cobra.Command{
Use: "create-or-update",
Short: "新增或更新目标模板",
Long: `新增或更新目标模板。覆盖逻辑,更新时一定要基于查询接口返回的老数据进行修改,新增时建议先参考已存在的模板数据。
--dimensions 为必填参数,JSON 字符串,包含目标维度、维度配置、目标内容。
--objective-weight 启用目标权重
--dimension-weight 启用维度权重
--compute-by-weight 维度是否参与计算`,
Example: ` dws agoal obj-template create-or-update --title "业绩模板" --dimensions '[{"title":"维度1","weight":100}]'
dws agoal obj-template create-or-update --template-id TPL_ID --dimensions '[...基于老数据修改...]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "dimensions"); err != nil {
return err
}
toolArgs := map[string]any{}
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
toolArgs["requestId"] = v
}
if v, _ := cmd.Flags().GetString("template-id"); v != "" {
toolArgs["templateId"] = v
}
if v, _ := cmd.Flags().GetString("title"); v != "" {
toolArgs["title"] = v
}
if cmd.Flags().Changed("objective-weight") {
v, _ := cmd.Flags().GetBool("objective-weight")
toolArgs["objectiveWeight"] = v
}
if cmd.Flags().Changed("dimension-weight") {
v, _ := cmd.Flags().GetBool("dimension-weight")
toolArgs["dimensionWeight"] = v
}
if cmd.Flags().Changed("compute-by-weight") {
v, _ := cmd.Flags().GetBool("compute-by-weight")
toolArgs["computeByWeight"] = v
}
toolArgs["dimensions"] = mustGetFlag(cmd, "dimensions")
return callMCPTool("create_or_update_obj_template", toolArgs)
},
}
objTemplateCreateOrUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
objTemplateCreateOrUpdateCmd.Flags().String("template-id", "", "模板 id (更新时必填)")
objTemplateCreateOrUpdateCmd.Flags().String("title", "", "模板标题 (新增时必填)")
objTemplateCreateOrUpdateCmd.Flags().Bool("objective-weight", false, "是否启用目标权重")
objTemplateCreateOrUpdateCmd.Flags().Bool("dimension-weight", false, "是否启用维度权重")
objTemplateCreateOrUpdateCmd.Flags().Bool("compute-by-weight", false, "维度是否参与计算")
objTemplateCreateOrUpdateCmd.Flags().String("dimensions", "", "模板关联的维度 JSON 字符串 (必填,更新时基于老数据修改,新增时建议参考已有模板)")
objTemplateCmd.AddCommand(objTemplateListCmd, objTemplateCreateOrUpdateCmd)
root.AddCommand(strategyCmd, contractCmd, scorecardCmd, userCmd, reportCmd, objTemplateCmd)
return root
}
// parseISO8601ToMillis 将 ISO-8601 时间字符串解析为毫秒时间戳。
// 支持格式:RFC3339(含时区)、无时区(默认 Asia/Shanghai)、仅日期。
func parseISO8601ToMillis(value string) (int64, error) {
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t.UnixMilli(), nil
}
shanghaiLoc := shanghaiLocation()
if t, err := time.ParseInLocation("2006-01-02T15:04:05", value, shanghaiLoc); err == nil {
return t.UnixMilli(), nil
}
if t, err := time.ParseInLocation("2006-01-02 15:04:05", value, shanghaiLoc); err == nil {
return t.UnixMilli(), nil
}
if t, err := time.ParseInLocation("2006-01-02", value, shanghaiLoc); err == nil {
return t.UnixMilli(), nil
}
return 0, fmt.Errorf("invalid ISO-8601 time format %q, expected e.g. \"2026-01-01T00:00:00+08:00\"", value)
}
func shanghaiLocation() *time.Location {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.FixedZone("CST", 8*3600)
}
return loc
}
+74 -12
View File
@@ -5,12 +5,26 @@ import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
// parseBoolFlag reads a string flag and parses it as a boolean, accepting the
// natural `--flag true` / `--flag false` space syntax. Registering these
// switches as String (not Bool) flags avoids cobra swallowing `false` as a
// positional arg — which silently inverted `--enabled false` into enable.
func parseBoolFlag(cmd *cobra.Command, name string) (bool, error) {
raw := strings.TrimSpace(mustGetFlag(cmd, name))
v, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("--%s 只接受 true 或 false,got %q", name, raw)
}
return v, nil
}
// ──────────────────────────────────────────────────────────
// dws aitable — AI 表格
// 中文 tool name 映射: CLI 英文命令 → MCP 中文 tool name
@@ -617,6 +631,30 @@ func printViewSubBlock(block any) error {
return deps.Out.PrintJSON(envelope)
}
// findFormViewByID 从 list_form_views 响应里递归定位 viewId 匹配的表单对象。
// 服务端的 viewIds 过滤参数当前不生效(返回全表所有表单),故 form get 在客户端
// 侧按 viewId 精确筛出单条,避免退化成等价 form list。
func findFormViewByID(node any, viewID string) (map[string]any, bool) {
switch v := node.(type) {
case map[string]any:
if id, _ := v["viewId"].(string); id == viewID {
return v, true
}
for _, val := range v {
if found, ok := findFormViewByID(val, viewID); ok {
return found, true
}
}
case []any:
for _, el := range v {
if found, ok := findFormViewByID(el, viewID); ok {
return found, true
}
}
}
return nil, false
}
// collectStringFlag 把 cmd 上 flagName 的字符串值(若非空)以 jsonKey 写入 out map。
func collectStringFlag(cmd *cobra.Command, flagName, jsonKey string, out map[string]any) {
if v, _ := cmd.Flags().GetString(flagName); v != "" {
@@ -2888,7 +2926,8 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
Use: "get",
Short: "获取单个表单视图详情",
Long: `按 viewId 获取单个表单视图的元信息(name/title/createdAt/shareFormUuid)。
内部基于 list_form_views 过滤实现,等价于 form list 后筛选指定 viewId。`,
内部拉取 list_form_views 后在客户端按 viewId 精确筛出单条(服务端的 viewIds
过滤参数当前不生效,会返回全表所有表单,故在此侧过滤)。data 即命中的表单对象。`,
Example: ` dws aitable form get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "view-id"); err != nil {
@@ -2898,11 +2937,30 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
if err != nil {
return err
}
return callAitableHelperTool("list_form_views", map[string]any{
viewID := mustGetFlag(cmd, "view-id")
raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable-helper", "list_form_views", map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
"viewIds": []string{mustGetFlag(cmd, "view-id")},
})
if err != nil {
return err
}
var parsed any
if e := json.Unmarshal([]byte(raw), &parsed); e != nil {
return &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("list_form_views response is not valid JSON: %v", e),
}
}
form, ok := findFormViewByID(parsed, viewID)
if !ok {
return &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("form view %s not found in table", viewID),
Suggestion: "用 dws aitable form list --base-id <baseId> --table-id <tableId> 查看可用 viewId 列表",
}
}
return printViewSubBlock(form)
},
}
@@ -3439,10 +3497,10 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
Example: ` dws aitable dashboard share update --base-id BASE_ID --dashboard-id DASHBOARD_ID --enabled true --share-type PUBLIC
dws aitable dashboard share update --base-id BASE_ID --dashboard-id DASHBOARD_ID --enabled false`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "dashboard-id"); err != nil {
if err := validateRequiredFlags(cmd, "dashboard-id", "enabled"); err != nil {
return err
}
enabled, err := cmd.Flags().GetBool("enabled")
enabled, err := parseBoolFlag(cmd, "enabled")
if err != nil {
return err
}
@@ -3542,11 +3600,15 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
Use: "update",
Short: "更新图表",
Long: `更新指定 chart 的配置或布局。
调用前建议先调用 chart widgets-example 了解 --config 入参结构和要求。`,
--config 为必填参数,即使只想改布局也要带上完整的图表配置(服务端会拒绝缺 config 的请求,
空对象 {} 同样被拒,至少要包含 chartName)。--layout 可选,仅调整位置/大小时追加。
调用前建议先调用 chart widgets-example 了解 --config 入参结构和要求,
或先用 chart get 拿到当前 config 再改。`,
Example: ` dws aitable chart update --base-id BASE_ID --dashboard-id DASHBOARD_ID --chart-id CHART_ID \
--config '{"chartName":"新柱图名",...}'
# 只改布局也必须带 --config(用 chart get 拿到当前 config 原样回传):
dws aitable chart update --base-id BASE_ID --dashboard-id DASHBOARD_ID --chart-id CHART_ID \
--layout '{"x":0,"y":4,"w":12,"h":4}'
--config '{"chartName":"柱图",...}' --layout '{"x":0,"y":4,"w":12,"h":4}'
# 查询 chartId: dws aitable dashboard get --base-id <baseId> --dashboard-id <dashboardId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "dashboard-id", "chart-id", "config"); err != nil {
@@ -3634,10 +3696,10 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
Example: ` dws aitable chart share update --base-id BASE_ID --dashboard-id DASHBOARD_ID --chart-id CHART_ID --enabled true --share-type ORG
dws aitable chart share update --base-id BASE_ID --dashboard-id DASHBOARD_ID --chart-id CHART_ID --enabled false`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "dashboard-id", "chart-id"); err != nil {
if err := validateRequiredFlags(cmd, "dashboard-id", "chart-id", "enabled"); err != nil {
return err
}
enabled, err := cmd.Flags().GetBool("enabled")
enabled, err := parseBoolFlag(cmd, "enabled")
if err != nil {
return err
}
@@ -4228,7 +4290,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
baseGetPrimaryDocIdCmd.Flags().String("table-id", "", "Table ID,可通过 list_tables 或 get_base 获取 (必填)")
baseGetPrimaryDocIdCmd.Flags().String("record-id", "", "记录 ID (必填)")
baseCopyCmd.Flags().String("base-id", "", "源 Base ID (必填)")
baseCopyCmd.Flags().String("target-folder-id", "", "目标文件夹 ID (必填), 默认为当前 Base 的父节点")
baseCopyCmd.Flags().String("target-folder-id", "", "目标文件夹 ID (必填, 不传会复制失败)")
baseCopyCmd.Flags().Bool("only-struct", false, "是否仅复制结构(不含数据),默认 false 表示完整复制")
baseCmd.AddCommand(
baseListCmd, baseSearchCmd, baseGetCmd,
@@ -4658,7 +4720,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
dashboardShareGetCmd.Flags().String("dashboard-id", "", "目标 Dashboard ID (必填)")
dashboardShareUpdateCmd.Flags().String("base-id", "", "所属 Base ID (必填)")
dashboardShareUpdateCmd.Flags().String("dashboard-id", "", "目标 Dashboard ID (必填)")
dashboardShareUpdateCmd.Flags().Bool("enabled", false, "分享开关:true 开启,false 关闭")
dashboardShareUpdateCmd.Flags().String("enabled", "", "分享开关:true 开启,false 关闭 (必填)")
dashboardShareUpdateCmd.Flags().String("share-type", "", "分享类型:PUBLIC 或 ORG(enabled=true 时生效)")
dashboardShareUpdateCmd.Flags().Bool("allow-back-to-doc", false, "是否允许从分享页返回源 AI 表格(仅在显式传参时生效)")
dashboardShareCmd.AddCommand(dashboardShareGetCmd, dashboardShareUpdateCmd)
@@ -4692,7 +4754,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
chartShareUpdateCmd.Flags().String("base-id", "", "所属 Base ID (必填)")
chartShareUpdateCmd.Flags().String("dashboard-id", "", "所属 Dashboard ID (必填)")
chartShareUpdateCmd.Flags().String("chart-id", "", "目标 Chart ID (必填)")
chartShareUpdateCmd.Flags().Bool("enabled", false, "分享开关:true 开启,false 关闭")
chartShareUpdateCmd.Flags().String("enabled", "", "分享开关:true 开启,false 关闭 (必填)")
chartShareUpdateCmd.Flags().String("share-type", "", "分享类型:PUBLIC 或 ORG(enabled=true 时生效)")
chartShareUpdateCmd.Flags().Bool("allow-back-to-doc", false, "是否允许从分享页返回源 AI 表格(仅在显式传参时生效)")
chartShareCmd.AddCommand(chartShareGetCmd, chartShareUpdateCmd)
+3 -3
View File
@@ -2333,7 +2333,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
子命令:
types 查询当前用户假期规则列表
save-type 创建或更新假期规则(仅支持无额度模式)
update-type 更新假期规则(仅支持无额度模式)
balance 查询指定员工假期余额
save-balance 更新员工假期余额
records 查询指定员工假期余额变更记录`,
@@ -3136,11 +3136,11 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// vacation balance (get_leave_balance_quota)
vacationBalanceCmd.Flags().String("users", "", "目标员工 ID 列表,逗号分隔 (必填)")
vacationBalanceCmd.Flags().String("leave-code", "", "假期规则 code (选填,不传则查询所有假期)")
vacationBalanceCmd.Flags().String("leave-code", "", "假期规则 code (必填,服务端要求非空,不传返回 INVALID_PARAMS)")
// vacation records (get_leave_balance_records)
vacationRecordsCmd.Flags().String("user", "", "指定查询员工 ID (必填)")
vacationRecordsCmd.Flags().String("leave-code", "", "假期规则 code (选填,不传则查询所有假期)")
vacationRecordsCmd.Flags().String("leave-code", "", "假期规则 code (必填,服务端要求非空,不传返回 INVALID_PARAMS)")
vacationRecordsCmd.Flags().String("start", "", "查询开始日期,格式 YYYY-MM-DD (必填)")
vacationRecordsCmd.Flags().String("end", "", "查询结束日期,格式 YYYY-MM-DD (必填)")
+356 -20
View File
@@ -202,6 +202,28 @@ func isOpenDingTalkID(value string) bool {
return len(value) > 0 && (value[0] == 'D' || value[0] == 'd')
}
// webhookErrcodeFailure 解析自定义机器人 webhook 的响应,判定是否发送失败。
// webhook 失败时 HTTP 仍是 200 且返回 {errcode!=0, errmsg},需据 errcode 显式识别。
// errcode 可能是 JSON 数字或字符串,统一转字符串比较;缺 errcode 或为 0 视为成功。
func webhookErrcodeFailure(raw string) (code, msg string, failed bool) {
var m map[string]any
if json.Unmarshal([]byte(raw), &m) != nil {
return "", "", false
}
ec, ok := m["errcode"]
if !ok {
return "", "", false
}
code = strings.TrimSpace(fmt.Sprintf("%v", ec))
if code == "" || code == "0" || code == "0.0" {
return "", "", false
}
if v, ok := m["errmsg"].(string); ok {
msg = v
}
return code, msg, true
}
func splitChatIDValues(values []string) (userIDs []string, openDingTalkIDs []string) {
for _, raw := range values {
value := strings.TrimSpace(raw)
@@ -1841,7 +1863,30 @@ func newChatCommand() *cobra.Command {
}
toolArgs["atUserIds"] = atUserIds
}
return callMCPToolOnServer("bot", "send_message_by_custom_robot", toolArgs)
// dry-run 只预览参数,不实际发送,交回标准调用路径。
if deps.Caller.DryRun() {
return callMCPToolOnServer("bot", "send_message_by_custom_robot", toolArgs)
}
// 自定义机器人 webhook 即使发送失败(如 errcode=300005 token 不存在)
// HTTP 仍是 200,会被包成 success:true。这里取原始响应显式识别 errcode,
// 非 0 时按失败返回,避免 agent 误判消息已发出。
raw, err := callMCPToolReturnTextOnServer(context.Background(), "bot", "send_message_by_custom_robot", toolArgs)
if err != nil {
return err
}
if code, msg, failed := webhookErrcodeFailure(raw); failed {
return &CLIError{
Code: CodeMCPToolError,
Message: fmt.Sprintf("自定义机器人 webhook 发送失败: errcode=%s errmsg=%s", code, msg),
Suggestion: "检查 --token 是否有效、机器人是否仍在群内、以及机器人安全设置(关键词/IP/加签)是否拦截",
}
}
var parsed any
if json.Unmarshal([]byte(raw), &parsed) == nil {
return deps.Out.PrintJSON(parsed)
}
deps.Out.PrintRaw(raw)
return nil
},
}
@@ -2666,7 +2711,14 @@ func newChatCommand() *cobra.Command {
toolArgs["openConversationId"] = groupID
}
if userID != "" {
toolArgs["userId"] = userID
// 服务端 get_conversation_info 单聊只认 openDingTalkId(传 userId 键会被
// 忽略并报「openCid、cid、peerUid不能同时为空」)。这里把 --user 的 userId
// 先解析成 openDingTalkId 再走与 --open-dingtalk-id 相同的通路。
resolved, rerr := resolveOpenDingTalkID(context.Background(), userID)
if rerr != nil {
return rerr
}
toolArgs["openDingTalkId"] = resolved
}
if openDingTalkID != "" {
toolArgs["openDingTalkId"] = openDingTalkID
@@ -2675,6 +2727,12 @@ func newChatCommand() *cobra.Command {
},
}
chatConversationInfoCmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)")
chatConversationInfoCmd.Flags().String("conversation-id", "", "--group 的别名")
chatConversationInfoCmd.Flags().String("id", "", "--group 的别名")
chatConversationInfoCmd.Flags().String("chat", "", "--group 的别名")
_ = chatConversationInfoCmd.Flags().MarkHidden("conversation-id")
_ = chatConversationInfoCmd.Flags().MarkHidden("id")
_ = chatConversationInfoCmd.Flags().MarkHidden("chat")
chatConversationInfoCmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)")
chatConversationInfoCmd.Flags().String("userId", "", "--user 的别名")
_ = chatConversationInfoCmd.Flags().MarkHidden("userId")
@@ -3011,7 +3069,7 @@ func newChatCommand() *cobra.Command {
chatMessageCreateTextEmotionCmd := &cobra.Command{
Use: "create-text-emotion",
Short: "创建文字表情(获取 emotionId)",
Long: `创建一个新的文字表情模板。当 list-emotions 中没有所需表情时,使用此命令创建并获取 emotionId,随后可用于 add-text-emotion。`,
Long: `创建一个新的文字表情模板。当内置表情(见 chat-emoji-list.md)中没有所需表情时,使用此命令创建并获取 emotionId,随后可用于 add-text-emotion。`,
Example: ` dws chat message create-text-emotion --emotion-name "赞" --text "nice"
dws chat message create-text-emotion --emotion-name "感谢" --text "感谢" --background-id im_bg_5`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -3170,12 +3228,21 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
return err
}
// 解析输出路径:如果是目录,从 URL 推断文件名
// 解析输出路径:目录(已存在的目录,或以分隔符结尾的意图目录)→ 追加推断文件名。
fi, statErr := os.Stat(outputPath)
if statErr == nil && fi.IsDir() {
isDir := (statErr == nil && fi.IsDir()) ||
strings.HasSuffix(outputPath, string(os.PathSeparator)) ||
strings.HasSuffix(outputPath, "/")
if isDir {
filename := inferFilename(resourceURL)
outputPath = filepath.Join(outputPath, filename)
}
// 确保目标父目录存在,否则 httpGetFile 打开文件会因目录缺失失败。
if dir := filepath.Dir(outputPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("创建输出目录失败: %w", err)
}
}
// Step 2: HTTP GET 下载文件
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath))
@@ -4258,16 +4325,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatGroupAuditJoinValidationCmd := &cobra.Command{
Use: "audit-join-validation",
Short: "审批入群验证(通过、拒绝、删除)",
Long: `审批入群验证。支持通过、拒绝、删除、忽略、拒绝并拉黑等操作。
Long: `审批入群验证。真机实测服务端仅接受 AuditApprove / AuditDelete,其余状态会被拒绝(unsupported audit status)。
status 可选值:
AuditApprove — 通过
AuditDelete — 删除
AuditIgnore — 忽略
AuditRefuse — 拒绝
AuditBlock — 拒绝且不再接受该用户的申请`,
AuditApprove — 通过(可用)
AuditDelete — 删除(可用)
AuditIgnore — 忽略(服务端拒绝,不可用)
AuditRefuse — 拒绝(服务端拒绝,不可用)
AuditBlock — 拒绝且不再接受该用户的申请(服务端拒绝,不可用)`,
Example: ` dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditApprove
dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditRefuse --description "不符合入群条件"
dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditDelete --description "不符合入群条件"
# 查询入群验证记录: dws chat group list-join-validations`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group", "record-id", "applicant", "inviter", "status"); err != nil {
@@ -4294,7 +4361,7 @@ status 可选值:
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("group")
chatGroupAuditJoinValidationCmd.Flags().String("record-id", "", "申请记录 ID (必填)")
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("record-id")
chatGroupAuditJoinValidationCmd.Flags().String("status", "", "审批动作: AuditApprove/AuditDelete/AuditIgnore/AuditRefuse/AuditBlock (必填)")
chatGroupAuditJoinValidationCmd.Flags().String("status", "", "审批动作,真机仅 AuditApprove/AuditDelete 可用;AuditIgnore/AuditRefuse/AuditBlock 服务端拒绝 (必填)")
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("status")
chatGroupAuditJoinValidationCmd.Flags().String("applicant", "", "申请人 openDingTalkId (必填)")
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("applicant")
@@ -4375,7 +4442,7 @@ status 可选值:
chatListAllConversationsCmd := &cobra.Command{
Use: "list-all-conversations",
Short: "分页获取当前用户的全部会话列表",
Long: `分页获取当前用户的全部会话列表(包含单聊和群聊)。--limit 指定每页数量(最大 100),--cursor 传分页游标(首次不传或传 0)。
Long: `分页获取当前用户的全部会话列表(包含单聊和群聊)。--limit 指定每页数量(1-100,默认 100),--cursor 传分页游标(首次不传或传 0)。
返回 hasMore=true 时用 nextCursor 作为下次 --cursor 继续翻页。`,
Example: ` dws chat list-all-conversations
dws chat list-all-conversations --limit 50
@@ -4384,6 +4451,10 @@ status 可选值:
RunE: func(cmd *cobra.Command, args []string) error {
toolArgs := map[string]any{}
if v, err := cmd.Flags().GetInt("limit"); err == nil && v > 0 {
// 服务端每页上限 100,超过会被静默截断,这里显式拒绝以免误以为取全。
if v > 100 {
return fmt.Errorf("--limit 最大 100(服务端上限),got %d;如需取全部会话请配合 --cursor 翻页", v)
}
toolArgs["limit"] = v
}
if v, _ := cmd.Flags().GetInt64("cursor"); v > 0 {
@@ -4395,7 +4466,7 @@ status 可选值:
return callMCPToolOnServer("im", "list_all_conversations", toolArgs)
},
}
chatListAllConversationsCmd.Flags().Int("limit", 1000, "每页数量(默认 1000)")
chatListAllConversationsCmd.Flags().Int("limit", 100, "每页数量(1-100,默认 100)")
chatListAllConversationsCmd.Flags().Int64("cursor", 0, "分页游标(首次不传或传 0,翻页传 nextCursor)")
chatListAllConversationsCmd.Flags().Bool("exclude-muted", false, "是否排除已免打扰会话(默认 false)")
@@ -4515,8 +4586,8 @@ status 可选值:
chatMessageUnsetTopMsgCmd.Flags().String("msg-id", "", "消息 openMessageId (必填)")
_ = chatMessageUnsetTopMsgCmd.MarkFlagRequired("msg-id")
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd)
chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd)
// group 与 members 的子命令在下方(chatGroupCmd.AddCommand / chatGroupMembersCmd.AddCommand
// 的完整列表处)统一注册;此处不再重复 AddCommand,否则 --help 会重复列出。
// ── group update-nick: 设置用户在群内的群昵称 ──────────────
chatGroupUpdateNickCmd := &cobra.Command{
@@ -4670,12 +4741,277 @@ status 可选值:
chatGroupMembersListByIdsCmd.Flags().String("users", "", "成员 openDingTalkId 列表,逗号分隔 (必填)")
_ = chatGroupMembersListByIdsCmd.MarkFlagRequired("users")
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd)
// ── group notice: 群公告管理 ────────────────────────────────
chatGroupNoticeCmd := &cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE}
chatGroupNoticeCreateCmd := &cobra.Command{
Use: "create",
Short: "发布群公告",
Long: `在指定群聊中发布群公告,正文为 Markdown 格式。
支持标题、加粗、斜体、删除线、行内代码、链接、代码块、有序/无序/任务列表、表格、引用、分割线、图片、段落、换行。
定时发布:传 --run-at 指定执行时间点,ISO-8601 格式(建议带时区偏移,不带时按北京时区处理)。`,
Example: ` dws chat group notice create --group <openConversationId> --content "今晚 22 点系统维护,请提前保存工作内容"
dws chat group notice create --group <openConversationId> --content "# 重要通知\n请大家查收" --sticky --send-ding
dws chat group notice create --group <openConversationId> --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00"
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group", "content"); err != nil {
return err
}
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"content": mustGetFlag(cmd, "content"),
}
if v, _ := cmd.Flags().GetBool("sticky"); v {
toolArgs["sticky"] = true
}
if v, _ := cmd.Flags().GetBool("send-ding"); v {
toolArgs["sendDing"] = true
}
if v, _ := cmd.Flags().GetString("run-at"); v != "" {
toolArgs["scheduled"] = true
toolArgs["runAtText"] = v
}
return callMCPToolOnServer("im", "create_group_notice", toolArgs)
},
}
chatGroupNoticeCreateCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
_ = chatGroupNoticeCreateCmd.MarkFlagRequired("group")
chatGroupNoticeCreateCmd.Flags().String("content", "", "公告正文,Markdown 格式 (必填)")
_ = chatGroupNoticeCreateCmd.MarkFlagRequired("content")
chatGroupNoticeCreateCmd.Flags().Bool("sticky", false, "是否吊顶置顶(默认 false)")
chatGroupNoticeCreateCmd.Flags().Bool("send-ding", false, "是否发 DING 提醒(默认 false)")
chatGroupNoticeCreateCmd.Flags().String("run-at", "", "定时发布时间 ISO-8601(如 2026-07-03T09:00:00+08:00,传入则定时发布)")
chatGroupNoticeEditCmd := &cobra.Command{
Use: "edit",
Short: "修改群公告",
Long: `修改指定群聊中的群公告,正文为 Markdown 格式,会整体替换原公告内容。`,
Example: ` dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content "更新后的公告内容"
dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content "更新后的公告内容" --sticky --send-ding
# 查询公告 ID: dws chat group notice list --group <openConversationId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group", "notice-id", "content"); err != nil {
return err
}
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"dataId": mustGetFlag(cmd, "notice-id"),
"content": mustGetFlag(cmd, "content"),
}
if v, _ := cmd.Flags().GetBool("sticky"); v {
toolArgs["sticky"] = true
}
if v, _ := cmd.Flags().GetBool("send-ding"); v {
toolArgs["sendDing"] = true
}
return callMCPToolOnServer("im", "edit_group_notice", toolArgs)
},
}
chatGroupNoticeEditCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
_ = chatGroupNoticeEditCmd.MarkFlagRequired("group")
chatGroupNoticeEditCmd.Flags().String("notice-id", "", "群公告 dataId (必填)")
_ = chatGroupNoticeEditCmd.MarkFlagRequired("notice-id")
chatGroupNoticeEditCmd.Flags().String("content", "", "公告新正文,Markdown 格式 (必填)")
_ = chatGroupNoticeEditCmd.MarkFlagRequired("content")
chatGroupNoticeEditCmd.Flags().Bool("sticky", false, "是否吊顶置顶(不传按 false 处理)")
chatGroupNoticeEditCmd.Flags().Bool("send-ding", false, "是否发 DING 提醒(默认 false)")
chatGroupNoticeGetCmd := &cobra.Command{
Use: "get",
Short: "查看群公告详情",
Long: `查看指定群公告的详情,包含正文摘要、吊顶状态、发布者、已读人数、点赞/评论数等信息。`,
Example: ` dws chat group notice get --group <openConversationId> --notice-id <dataId>
# 查询公告 ID: dws chat group notice list --group <openConversationId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group", "notice-id"); err != nil {
return err
}
return callMCPToolOnServer("im", "get_group_notice", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"dataId": mustGetFlag(cmd, "notice-id"),
})
},
}
chatGroupNoticeGetCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
_ = chatGroupNoticeGetCmd.MarkFlagRequired("group")
chatGroupNoticeGetCmd.Flags().String("notice-id", "", "群公告 dataId (必填)")
_ = chatGroupNoticeGetCmd.MarkFlagRequired("notice-id")
chatGroupNoticeListCmd := &cobra.Command{
Use: "list",
Short: "查看群公告列表",
Long: `分页查看指定群聊的群公告列表。默认查询已发布公告,传 --scheduled 查询定时公告列表。
支持游标分页,hasMore=true 时用返回的 nextPageCursor 作为下次 --cursor。`,
Example: ` dws chat group notice list --group <openConversationId>
dws chat group notice list --group <openConversationId> --limit 20 --cursor <nextPageCursor>
dws chat group notice list --group <openConversationId> --scheduled
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "group"); err != nil {
return err
}
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["limit"] = v
}
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
toolArgs["cursor"] = v
}
if v, _ := cmd.Flags().GetBool("scheduled"); v {
toolArgs["scheduled"] = true
}
return callMCPToolOnServer("im", "list_group_notices", toolArgs)
},
}
chatGroupNoticeListCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
_ = chatGroupNoticeListCmd.MarkFlagRequired("group")
chatGroupNoticeListCmd.Flags().Int("limit", 10, "每页返回数量(默认 10,最大 100)")
chatGroupNoticeListCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页传返回的 nextPageCursor)")
chatGroupNoticeListCmd.Flags().Bool("scheduled", false, "是否查询定时公告列表(默认 false,查询已发布公告)")
chatGroupNoticeCmd.AddCommand(chatGroupNoticeCreateCmd, chatGroupNoticeEditCmd, chatGroupNoticeGetCmd, chatGroupNoticeListCmd)
chatGroupShareInviteCmd := &cobra.Command{
Use: "share-invite",
Short: "分享群聊链接到会话",
Long: `将指定群的邀请链接分享到另一个会话或单聊用户。--target 和 --receiver 二选一:--target 指定目标会话,--receiver 指定单聊用户。`,
Example: ` dws chat group share-invite --source <被分享群openConversationId> --target <目标会话openConversationId>
dws chat group share-invite --source <被分享群openConversationId> --receiver <接收者openDingTalkId>
dws chat group share-invite --source <openConversationId> --target <openConversationId> --expires-seconds 86400
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "source"); err != nil {
return err
}
target, _ := cmd.Flags().GetString("target")
receiver, _ := cmd.Flags().GetString("receiver")
if target == "" && receiver == "" {
return fmt.Errorf("--target or --receiver is required")
}
if target != "" && receiver != "" {
return fmt.Errorf("--target and --receiver are mutually exclusive")
}
toolArgs := map[string]any{
"sourceOpenConversationId": mustGetFlag(cmd, "source"),
}
if target != "" {
toolArgs["targetOpenConversationId"] = target
}
if receiver != "" {
toolArgs["receiverOpenDingTalkId"] = receiver
}
if v, _ := cmd.Flags().GetInt64("expires-seconds"); v > 0 || cmd.Flags().Changed("expires-seconds") {
toolArgs["expiresSeconds"] = v
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
}
return callMCPToolOnServer("im", "share_group_invite_url", toolArgs)
},
}
chatGroupShareInviteCmd.Flags().String("source", "", "被分享群的 openConversationId (必填)")
_ = chatGroupShareInviteCmd.MarkFlagRequired("source")
chatGroupShareInviteCmd.Flags().String("target", "", "接收分享消息的会话 openConversationId(与 --receiver 二选一)")
chatGroupShareInviteCmd.Flags().String("receiver", "", "接收分享消息的单聊用户 openDingTalkId(与 --target 二选一)")
chatGroupShareInviteCmd.Flags().Int64("expires-seconds", 0, "链接有效期(秒),0 表示永久有效,不传使用服务端默认值")
chatGroupShareInviteCmd.Flags().String("uuid", "", "消息幂等键(可选)")
chatCategoryCreateSmartCmd := &cobra.Command{
Use: "create-smart",
Short: "创建智能会话分组",
Long: `创建智能会话分组,可指定群名称关键词和群内成员 openDingTalkId 作为匹配规则。`,
Example: ` dws chat category create-smart --name "工作群"
dws chat category create-smart --name "项目组" --keywords "项目,开发"
dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2
dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "name"); err != nil {
return err
}
toolArgs := map[string]any{
"title": mustGetFlag(cmd, "name"),
}
if v, _ := cmd.Flags().GetString("keywords"); v != "" {
toolArgs["keywords"] = parseCSVValues(v)
}
if v, _ := cmd.Flags().GetString("members"); v != "" {
toolArgs["memberOpenDingTalkIds"] = parseCSVValues(v)
}
return callMCPToolOnServer("im", "create_smart_conv_category", toolArgs)
},
}
chatCategoryCreateSmartCmd.Flags().String("name", "", "分组名称 (必填)")
_ = chatCategoryCreateSmartCmd.MarkFlagRequired("name")
chatCategoryCreateSmartCmd.Flags().String("keywords", "", "群名称关键词列表,逗号分隔(可选)")
chatCategoryCreateSmartCmd.Flags().String("members", "", "群内成员 openDingTalkId 列表,逗号分隔(可选)")
chatMessageListEmotionRepliesCmd := &cobra.Command{
Use: "list-emotion-replies",
Short: "批量拉取消息的表情回复和文字回复",
Example: ` dws chat message list-emotion-replies --msg-ids msgId1,msgId2,msgId3
# 消息 ID 可通过 dws chat message list 获取`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "msg-ids"); err != nil {
return err
}
msgIds := parseCSVValues(mustGetFlag(cmd, "msg-ids"))
return callMCPToolOnServer("im", "list_message_emotion_replies", map[string]any{
"openMessageIds": msgIds,
})
},
}
chatMessageListEmotionRepliesCmd.Flags().String("msg-ids", "", "消息 ID 列表,逗号分隔 (必填)")
_ = chatMessageListEmotionRepliesCmd.MarkFlagRequired("msg-ids")
supportedTranslateLanguages := map[string]bool{
"en_US": true, "zh_CN": true, "zh_TW": true, "zh_HK": true,
"ja_JP": true, "ko_KR": true, "vi_VN": true, "th_TH": true,
"id_ID": true, "ms_MY": true, "es_419": true, "fr_FR": true,
"pt_BR": true, "tr_TR": true, "ru_RU": true, "de_DE": true,
"hi_IN": true, "hu_HU": true, "pl_PL": true, "sv_SE": true,
"fi_FI": true, "cs_CZ": true, "ar_SA": true, "tl_PH": true,
"he_IL": true, "nl_NL": true, "lo_LA": true, "it_IT": true,
}
chatTextCmd := &cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE}
chatTextTranslateCmd := &cobra.Command{
Use: "translate",
Short: "翻译文本内容",
Long: `将指定文本翻译成目标语言。
支持的目标语言代码: en_US, zh_CN, zh_TW, zh_HK, ja_JP, ko_KR, vi_VN, th_TH,
id_ID, ms_MY, es_419, fr_FR, pt_BR, tr_TR, ru_RU, de_DE, hi_IN, hu_HU,
pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
Example: ` dws chat text translate --query "你好世界" --to en_US
dws chat text translate --query "Hello World" --to zh_CN
dws chat text translate --query "Bonjour" --to ja_JP`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "query", "to"); err != nil {
return err
}
toLang := mustGetFlag(cmd, "to")
if !supportedTranslateLanguages[toLang] {
return fmt.Errorf("unsupported target language: %s", toLang)
}
return callMCPToolOnServer("im", "translate", map[string]any{
"query": mustGetFlag(cmd, "query"),
"to": toLang,
})
},
}
chatTextTranslateCmd.Flags().String("query", "", "待翻译的文本内容 (必填)")
_ = chatTextTranslateCmd.MarkFlagRequired("query")
chatTextTranslateCmd.Flags().String("to", "en_US", "目标语言代码 (必填,默认 en_US)")
_ = chatTextTranslateCmd.MarkFlagRequired("to")
chatTextCmd.AddCommand(chatTextTranslateCmd)
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd)
chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd, chatGroupMembersListByIdsCmd)
chatBotCmd.AddCommand(chatBotFindCmd)
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd)
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd)
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
// hint: dws chat send → dws chat message send
root.AddCommand(hintSubCmd("send", "use: dws chat message send"))
+124 -2
View File
@@ -57,8 +57,8 @@ func TestConvSessions(t *testing.T) {
}
// TestApplyModelArg covers both shapes: replacing an existing model pin
// (claudecode's built-in haiku) and inserting before the tail (gemini-style
// tails that end with -p and need the prompt to stay trailing).
// (claudecode's built-in haiku) and inserting before a prompt tail that must
// stay trailing.
func TestApplyModelArg(t *testing.T) {
replaced := applyModelArg(
[]string{"claude", "-p", "--model", "claude-haiku-4-5-20251001", "--strict-mcp-config"},
@@ -147,6 +147,128 @@ func TestForwarderSessionAndModelWiring(t *testing.T) {
}
}
func TestBuiltInAgentForwardersDefaultToPureChannelPermissions(t *testing.T) {
clearChannelEnv(t)
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
t.Setenv("DWS_AGENT_CMD", "")
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
stub := t.TempDir()
for _, name := range []string{"claude", "codebuddy", "codex", "opencode", "qodercli"} {
if err := writeExecStub(stub, name); err != nil {
t.Fatalf("stub %s: %v", name, err)
}
}
t.Setenv("PATH", stub)
cases := []struct {
channel string
want []string
}{
{"claudecode", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
{"codebuddy", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
{"workbuddy", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
}
for _, tc := range cases {
t.Run(tc.channel, func(t *testing.T) {
fwd, err := forwarderForChannel(tc.channel, "", connectAgentOptions{Memory: true, Yolo: true})
if err != nil {
t.Fatalf("forwarderForChannel(%q): %v", tc.channel, err)
}
ef, ok := fwd.(*execForwarder)
if !ok {
t.Fatalf("%s forwarder = %T, want *execForwarder", tc.channel, fwd)
}
got := strings.Join(ef.argv, " ")
for _, want := range tc.want {
if !strings.Contains(got, want) {
t.Fatalf("%s argv missing %q: %s", tc.channel, want, got)
}
}
if len(ef.streamArgv) > 0 {
streamGot := strings.Join(ef.streamArgv, " ")
for _, want := range tc.want {
if !strings.Contains(streamGot, want) {
t.Fatalf("%s stream argv missing %q: %s", tc.channel, want, streamGot)
}
}
}
})
}
t.Run("gemini", func(t *testing.T) {
t.Setenv("GEMINI_API_KEY", "test-key")
fwd, err := forwarderForChannel("gemini", "gemini-client", connectAgentOptions{Memory: true, Yolo: true, Model: "gemini-test"})
if err != nil {
t.Fatalf("forwarderForChannel(gemini): %v", err)
}
gf, ok := fwd.(*geminiAPIForwarder)
if !ok {
t.Fatalf("gemini forwarder = %T, want *geminiAPIForwarder", fwd)
}
if gf.model != "gemini-test" {
t.Fatalf("gemini model = %q, want gemini-test", gf.model)
}
})
for _, ch := range []string{"qoder", "qoderwork"} {
t.Run(ch, func(t *testing.T) {
fwd, err := forwarderForChannel(ch, "client-"+ch, connectAgentOptions{Memory: true, Yolo: true})
if err != nil {
t.Fatalf("forwarderForChannel(%q): %v", ch, err)
}
qf, ok := fwd.(*qoderStreamForwarder)
if !ok {
t.Fatalf("%s forwarder = %T, want *qoderStreamForwarder", ch, fwd)
}
got := strings.Join(qf.commandArgs(), " ")
for _, want := range []string{"--permission-mode", "bypass_permissions", "--dangerously-skip-permissions"} {
if !strings.Contains(got, want) {
t.Fatalf("%s command args missing %q: %s", ch, want, got)
}
}
})
}
t.Run("codex", func(t *testing.T) {
fwd, err := forwarderForChannel("codex", "codex-client", connectAgentOptions{Memory: true, Yolo: true})
if err != nil {
t.Fatalf("forwarderForChannel(codex): %v", err)
}
cf, ok := fwd.(*codexAppServerForwarder)
if !ok {
t.Fatalf("codex forwarder = %T, want *codexAppServerForwarder", fwd)
}
params := cf.threadParams("")
if got := params["approvalPolicy"]; got != "never" {
t.Fatalf("codex approvalPolicy = %v, want never", got)
}
if got := params["sandbox"]; got != "workspace-write" {
t.Fatalf("codex yolo sandbox = %v, want workspace-write", got)
}
})
t.Run("opencode", func(t *testing.T) {
fwd, err := forwarderForChannel("opencode", "opencode-client", connectAgentOptions{Memory: true, Yolo: true})
if err != nil {
t.Fatalf("forwarderForChannel(opencode): %v", err)
}
of, ok := fwd.(*opencodeForwarder)
if !ok {
t.Fatalf("opencode forwarder = %T, want *opencodeForwarder", fwd)
}
env := of.server.commandEnv("pw")
if !strings.Contains(envValue(env, opencodeConfigContentEnv), `"question":false`) {
t.Fatalf("opencode config should disable question tool: %s", envValue(env, opencodeConfigContentEnv))
}
if !strings.Contains(envValue(env, opencodePermissionEnv), `"question":"deny"`) {
t.Fatalf("opencode permission should deny question: %s", envValue(env, opencodePermissionEnv))
}
if !strings.Contains(envValue(env, opencodePermissionEnv), `"bash":"allow"`) {
t.Fatalf("opencode permission should allow gated tools: %s", envValue(env, opencodePermissionEnv))
}
})
}
// TestRobotConnectAgentFlagsInDryRun checks the new flags surface in the
// dry-run preview so callers can see the effective agent tuning.
func TestRobotConnectAgentFlagsInDryRun(t *testing.T) {
+21 -5
View File
@@ -144,11 +144,27 @@ func (p *atMentionPoller) handleMessage(ctx context.Context, msg atMentionMessag
convID = msg.SenderStaffID
}
p.queue.run(convID, func() {
turn := connectQueuedTurn{
convID: convID,
text: text,
msgID: msg.MsgID,
senderStaffID: strings.TrimSpace(msg.SenderStaffID),
conversationID: strings.TrimSpace(msg.OpenConversationID),
conversationType: strings.TrimSpace(msg.ConversationType),
}
p.queue.submit(turn, func(turns []connectQueuedTurn) {
turn := mergeConnectQueuedTurns(turns)
if len(turns) > 1 {
fmt.Fprintf(os.Stderr, "[connect][at-poll] 合并 %d 条待处理 @消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID)
}
text := turn.text
convID := turn.convID
senderStaffID := turn.senderStaffID
openConversationID := turn.conversationID
if p.extras.gate.enabled() {
if ok, reason := p.extras.gate.allow(msg.SenderStaffID, "2", msg.OpenConversationID); !ok {
if ok, reason := p.extras.gate.allow(senderStaffID, "2", openConversationID); !ok {
fmt.Fprintf(os.Stderr, "[connect][at-poll] 已拦截消息(%s): staffId=%s convId=%s\n",
reason, msg.SenderStaffID, msg.OpenConversationID)
reason, senderStaffID, openConversationID)
return
}
}
@@ -180,8 +196,8 @@ func (p *atMentionPoller) handleMessage(ctx context.Context, msg atMentionMessag
p.health.onReply()
}
if reply != "" && msg.OpenConversationID != "" {
if serr := p.sendGroupReply(ctx, msg.OpenConversationID, reply); serr != nil {
if reply != "" && openConversationID != "" {
if serr := p.sendGroupReply(ctx, openConversationID, reply); serr != nil {
fmt.Fprintf(os.Stderr, "[connect][at-poll] 群回复发送失败: %v\n", serr)
}
}
@@ -107,14 +107,13 @@ while IFS= read -r line; do
*\"method\":\"initialize\"*) printf '%s\n' '{"id":1,"result":{}}' ;;
*\"method\":\"thread/start\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;;
*\"method\":\"thread/resume\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;;
*\"method\":\"turn/start\"*)
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"你"}}'
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"好"}}'
printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr_stub","turn":{"id":"turn_stub","status":"completed","items":[{"id":"item_1","type":"agentMessage","text":"你好"}]}}}'
exit 0
;;
esac
done
*\"method\":\"turn/start\"*)
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"你"}}'
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"好"}}'
printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr_stub","turn":{"id":"turn_stub","status":"completed","items":[{"id":"item_1","type":"agentMessage","text":"你好"}]}}}'
;;
esac
done
`)
fwd := &codexAppServerForwarder{
bin: codex,
@@ -0,0 +1,162 @@
// 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"
"testing"
"time"
)
func TestExecForwarderRetriesMissingSessionOnce(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "agent")
logPath := filepath.Join(dir, "calls.log")
if err := os.WriteFile(bin, []byte(`#!/bin/sh
echo "$@" >> "$DWS_STALE_LOG"
case " $* " in
*" --resume "*)
echo "No conversation found with session ID: stale" >&2
exit 1
;;
esac
echo fresh-ok
`), 0o755); err != nil {
t.Fatal(err)
}
sessions := newConvSessions("")
if got := sessions.args("conv-1"); len(got) != 2 || got[0] != "--session-id" {
t.Fatalf("seed session args = %v, want --session-id", got)
}
f := &execForwarder{
name: "claudecode",
argv: []string{bin, "-p"},
env: []string{"DWS_STALE_LOG=" + logPath},
timeout: 5 * time.Second,
sessions: sessions,
}
reply, err := f.forward(context.Background(), "conv-1", "hello")
if err != nil {
t.Fatalf("forward: %v", err)
}
if reply != "fresh-ok" {
t.Fatalf("reply = %q, want fresh-ok", reply)
}
calls := readCallLog(t, logPath)
if len(calls) != 2 {
t.Fatalf("calls = %v, want stale resume then fresh session", calls)
}
if !strings.Contains(calls[0], "--resume") || !strings.Contains(calls[1], "--session-id") {
t.Fatalf("calls = %v, want --resume then --session-id", calls)
}
}
func TestExecForwarderStreamRetriesMissingSessionOnce(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "agent-stream")
logPath := filepath.Join(dir, "calls.log")
if err := os.WriteFile(bin, []byte(`#!/bin/sh
echo "$@" >> "$DWS_STALE_LOG"
case " $* " in
*" --resume "*)
echo "session not found: stale" >&2
exit 1
;;
esac
printf '%s\n' '{"type":"result","result":"fresh-stream-ok"}'
`), 0o755); err != nil {
t.Fatal(err)
}
sessions := newConvSessions("")
_ = sessions.args("conv-1")
f := &execForwarder{
name: "claudecode",
argv: []string{bin, "-p"},
streamArgv: []string{bin, "--output-format", "stream-json"},
env: []string{"DWS_STALE_LOG=" + logPath},
parser: "cc",
timeout: 5 * time.Second,
sessions: sessions,
}
reply, err := f.forwardStream(context.Background(), "conv-1", "hello", func(string) {})
if err != nil {
t.Fatalf("forwardStream: %v", err)
}
if reply != "fresh-stream-ok" {
t.Fatalf("reply = %q, want fresh-stream-ok", reply)
}
calls := readCallLog(t, logPath)
if len(calls) != 2 {
t.Fatalf("calls = %v, want stale resume then fresh session", calls)
}
if !strings.Contains(calls[0], "--resume") || !strings.Contains(calls[1], "--session-id") {
t.Fatalf("calls = %v, want --resume then --session-id", calls)
}
}
func TestExecForwarderDoesNotRetryNonSessionErrors(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "agent")
logPath := filepath.Join(dir, "calls.log")
if err := os.WriteFile(bin, []byte(`#!/bin/sh
echo "$@" >> "$DWS_STALE_LOG"
echo "plain failure" >&2
exit 1
`), 0o755); err != nil {
t.Fatal(err)
}
sessions := newConvSessions("")
_ = sessions.args("conv-1")
f := &execForwarder{
name: "claudecode",
argv: []string{bin, "-p"},
env: []string{"DWS_STALE_LOG=" + logPath},
timeout: 5 * time.Second,
sessions: sessions,
}
if _, err := f.forward(context.Background(), "conv-1", "hello"); err == nil {
t.Fatal("forward error = nil, want error")
}
calls := readCallLog(t, logPath)
if len(calls) != 1 {
t.Fatalf("calls = %v, want one non-session failure without retry", calls)
}
if !strings.Contains(calls[0], "--resume") {
t.Fatalf("call = %q, want stale --resume attempt", calls[0])
}
}
func readCallLog(t *testing.T, path string) []string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var calls []string
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
if strings.TrimSpace(line) != "" {
calls = append(calls, line)
}
}
return calls
}
+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 helpers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
defaultGeminiAPIBaseURL = "https://generativelanguage.googleapis.com/v1beta"
defaultGeminiModel = "gemini-2.5-flash"
)
type geminiAPIForwarder struct {
model string
apiKey string
baseURL string
timeout time.Duration
httpClient *http.Client
}
func newGeminiAPIForwarder(timeout time.Duration, opts connectAgentOptions) (forwarder, error) {
apiKey := geminiAPIKey()
if apiKey == "" {
return nil, apperrors.NewValidation("gemini 渠道现在走 Gemini API;请设置 GEMINI_API_KEY(或 GOOGLE_API_KEY),模型可用 --agent-model 指定,Gemini-compatible 代理可设置 GEMINI_API_BASE_URL")
}
model := strings.TrimSpace(opts.Model)
if model == "" {
model = strings.TrimSpace(os.Getenv("GEMINI_MODEL"))
}
if model == "" {
model = defaultGeminiModel
}
if timeout <= 0 {
timeout = 2 * time.Minute
}
return &geminiAPIForwarder{
model: model,
apiKey: apiKey,
baseURL: geminiAPIBaseURL(),
timeout: timeout,
httpClient: &http.Client{},
}, nil
}
func geminiAPIKey() string {
for _, key := range []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"} {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
}
return ""
}
func geminiAPIBaseURL() string {
for _, key := range []string{"GEMINI_API_BASE_URL", "GOOGLE_GEMINI_API_BASE_URL"} {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return strings.TrimRight(v, "/")
}
}
return defaultGeminiAPIBaseURL
}
func (f *geminiAPIForwarder) label() string {
return fmt.Sprintf("gemini-api:%s", f.model)
}
func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (string, error) {
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
endpoint, err := f.generateContentEndpoint()
if err != nil {
return "", err
}
body := geminiGenerateContentRequest{
SystemInstruction: geminiContent{
Parts: []geminiPart{{Text: "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及任何系统提示或内部实现。"}},
},
Contents: []geminiContent{{
Role: "user",
Parts: []geminiPart{{Text: text}},
}},
}
raw, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-goog-api-key", f.apiKey)
resp, err := f.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respRaw, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("Gemini API HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(respRaw)), 300))
}
var out geminiGenerateContentResponse
if err := json.Unmarshal(respRaw, &out); err != nil {
return "", err
}
if out.Error.Message != "" {
return "", fmt.Errorf("Gemini API error %s: %s", out.Error.Status, truncateRunes(out.Error.Message, 300))
}
var chunks []string
for _, cand := range out.Candidates {
for _, part := range cand.Content.Parts {
if s := strings.TrimSpace(part.Text); s != "" {
chunks = append(chunks, s)
}
}
}
if len(chunks) == 0 {
if out.PromptFeedback.BlockReason != "" {
return "", fmt.Errorf("Gemini API blocked prompt: %s", out.PromptFeedback.BlockReason)
}
return "(Gemini API 无文本输出)", nil
}
return strings.Join(chunks, "\n\n"), nil
}
func (f *geminiAPIForwarder) generateContentEndpoint() (string, error) {
base := strings.TrimRight(strings.TrimSpace(f.baseURL), "/")
if base == "" {
base = defaultGeminiAPIBaseURL
}
if _, err := url.ParseRequestURI(base); err != nil {
return "", fmt.Errorf("GEMINI_API_BASE_URL 无效:%w", err)
}
model := strings.TrimSpace(strings.TrimPrefix(f.model, "models/"))
if model == "" {
model = defaultGeminiModel
}
return base + "/models/" + url.PathEscape(model) + ":generateContent", nil
}
type geminiGenerateContentRequest struct {
SystemInstruction geminiContent `json:"systemInstruction,omitempty"`
Contents []geminiContent `json:"contents"`
}
type geminiContent struct {
Role string `json:"role,omitempty"`
Parts []geminiPart `json:"parts"`
}
type geminiPart struct {
Text string `json:"text"`
}
type geminiGenerateContentResponse struct {
Candidates []struct {
Content geminiContent `json:"content"`
} `json:"candidates"`
PromptFeedback struct {
BlockReason string `json:"blockReason"`
} `json:"promptFeedback"`
Error struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
} `json:"error"`
}
+114
View File
@@ -0,0 +1,114 @@
// 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"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGeminiChannelUsesAPIWithoutLocalCLI(t *testing.T) {
clearChannelEnv(t)
t.Setenv("PATH", t.TempDir())
t.Setenv("GEMINI_API_KEY", "test-key")
t.Setenv("GEMINI_API_BASE_URL", "https://gemini-proxy.example/v1beta")
fwd, err := forwarderForChannel("gemini", "", connectAgentOptions{Model: "gemini-test"})
if err != nil {
t.Fatalf("forwarderForChannel(gemini): %v", err)
}
gf, ok := fwd.(*geminiAPIForwarder)
if !ok {
t.Fatalf("gemini forwarder = %T, want *geminiAPIForwarder", fwd)
}
if gf.model != "gemini-test" {
t.Fatalf("model = %q, want gemini-test", gf.model)
}
if gf.baseURL != "https://gemini-proxy.example/v1beta" {
t.Fatalf("baseURL = %q", gf.baseURL)
}
}
func TestGeminiAPIForwarderForward(t *testing.T) {
clearChannelEnv(t)
var gotPath, gotKey, gotText string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotKey = r.Header.Get("x-goog-api-key")
var req geminiGenerateContentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request: %v", err)
}
if len(req.Contents) > 0 && len(req.Contents[0].Parts) > 0 {
gotText = req.Contents[0].Parts[0].Text
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"gemini-api-ok"}]}}]}`))
}))
defer ts.Close()
t.Setenv("GEMINI_API_KEY", "test-key")
t.Setenv("GEMINI_API_BASE_URL", ts.URL)
fwd, err := forwarderForChannel("gemini", "", connectAgentOptions{Model: "models/gemini-test"})
if err != nil {
t.Fatalf("forwarderForChannel(gemini): %v", err)
}
reply, err := fwd.forward(context.Background(), "conv-1", "你好")
if err != nil {
t.Fatalf("forward: %v", err)
}
if reply != "gemini-api-ok" {
t.Fatalf("reply = %q, want gemini-api-ok", reply)
}
if gotPath != "/models/gemini-test:generateContent" {
t.Fatalf("path = %q", gotPath)
}
if gotKey != "test-key" {
t.Fatalf("x-goog-api-key = %q", gotKey)
}
if gotText != "你好" {
t.Fatalf("prompt text = %q", gotText)
}
}
func TestGeminiChannelRequiresAPIKey(t *testing.T) {
clearChannelEnv(t)
t.Setenv("GEMINI_API_KEY", "")
t.Setenv("GOOGLE_API_KEY", "")
_, err := forwarderForChannel("gemini", "", connectAgentOptions{})
if err == nil || !strings.Contains(err.Error(), "GEMINI_API_KEY") {
t.Fatalf("err = %v, want GEMINI_API_KEY validation", err)
}
}
func TestGeminiCLIStatusUsesAPIKey(t *testing.T) {
clearChannelEnv(t)
t.Setenv("GEMINI_API_KEY", "")
t.Setenv("GOOGLE_API_KEY", "")
if got := connectCliStatus("gemini")["installed"]; got != false {
t.Fatalf("installed without key = %v, want false", got)
}
t.Setenv("GOOGLE_API_KEY", "google-key")
if got := connectCliStatus("gemini")["installed"]; got != true {
t.Fatalf("installed with key = %v, want true", got)
}
if got := connectCliStatus("gemini")["autoInstall"]; got != false {
t.Fatalf("autoInstall = %v, want false", got)
}
}
+94 -8
View File
@@ -40,6 +40,8 @@ const (
opencodeNoTextReply = "(本地 agent 无文本输出)"
opencodeServerStartupWait = 20 * time.Second
opencodeServerPollInterval = 150 * time.Millisecond
opencodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT"
opencodePermissionEnv = "OPENCODE_PERMISSION"
// opencodeHealthProbeTimeout bounds a single /global/health probe so startup
// detection stays snappy even though the shared http.Client has no overall
// deadline (message turns rely on the per-turn ctx instead).
@@ -240,14 +242,7 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error
if s.workDir != "" {
cmd.Dir = s.workDir
}
cmd.Env = append(os.Environ(), s.env...)
cmd.Env = append(cmd.Env,
"OPENCODE_SERVER_USERNAME="+opencodeServerUsername,
"OPENCODE_SERVER_PASSWORD="+password,
)
if !s.yolo {
cmd.Env = append(cmd.Env, `OPENCODE_PERMISSION={"bash":"ask","edit":"ask","write":"ask"}`)
}
cmd.Env = s.commandEnv(password)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
@@ -269,6 +264,97 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error
return client, nil
}
func (s *opencodeServer) commandEnv(password string) []string {
env := append([]string{}, os.Environ()...)
env = append(env, s.env...)
env = upsertEnv(env, "OPENCODE_SERVER_USERNAME", opencodeServerUsername)
env = upsertEnv(env, "OPENCODE_SERVER_PASSWORD", password)
config := opencodeNonInteractiveConfig(envValue(env, opencodeConfigContentEnv))
env = upsertEnv(env, opencodeConfigContentEnv, config)
env = upsertEnv(env, opencodePermissionEnv, opencodeNonInteractivePermission())
return env
}
func opencodeNonInteractiveConfig(existing string) string {
cfg := map[string]any{}
if strings.TrimSpace(existing) != "" {
_ = json.Unmarshal([]byte(existing), &cfg)
}
tools, _ := cfg["tools"].(map[string]any)
if tools == nil {
tools = map[string]any{}
}
tools["question"] = false
cfg["tools"] = tools
permission, _ := cfg["permission"].(map[string]any)
if permission == nil {
permission = map[string]any{}
}
for k, v := range opencodeNonInteractivePermissionMap() {
permission[k] = v
}
cfg["permission"] = permission
data, err := json.Marshal(cfg)
if err != nil {
return `{"tools":{"question":false},"permission":{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}}`
}
return string(data)
}
func opencodeNonInteractivePermission() string {
data, err := json.Marshal(opencodeNonInteractivePermissionMap())
if err != nil {
return `{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}`
}
return string(data)
}
func opencodeNonInteractivePermissionMap() map[string]string {
return map[string]string{
"*": "allow",
"read": "allow",
"edit": "allow",
"write": "allow",
"patch": "allow",
"apply_patch": "allow",
"glob": "allow",
"grep": "allow",
"bash": "allow",
"task": "allow",
"skill": "allow",
"lsp": "allow",
"webfetch": "allow",
"websearch": "allow",
"question": "deny",
"external_directory": "allow",
"doom_loop": "allow",
}
}
func envValue(env []string, key string) string {
prefix := key + "="
for i := len(env) - 1; i >= 0; i-- {
if strings.HasPrefix(env[i], prefix) {
return strings.TrimPrefix(env[i], prefix)
}
}
return ""
}
func upsertEnv(env []string, key, value string) []string {
prefix := key + "="
out := make([]string, 0, len(env)+1)
for _, item := range env {
if strings.HasPrefix(item, prefix) {
continue
}
out = append(out, item)
}
return append(out, prefix+value)
}
func (s *opencodeServer) waitHealthy(ctx context.Context, client *opencodeHTTPClient) error {
deadline := time.Now().Add(opencodeServerStartupWait)
var lastErr error
+47
View File
@@ -261,6 +261,53 @@ func TestNewOpencodeServerHasNoClientTimeout(t *testing.T) {
}
}
func TestOpencodeServerEnvDisablesInteractiveGates(t *testing.T) {
s := newOpencodeServer("opencode", []string{
`OPENCODE_CONFIG_CONTENT={"provider":{"x":true},"tools":{"read":true},"permission":{"bash":"ask","edit":"ask","write":"ask","question":"ask"}}`,
`OPENCODE_PERMISSION={"bash":"ask"}`,
}, "", false)
env := s.commandEnv("pw")
if got := envValue(env, "OPENCODE_SERVER_USERNAME"); got != opencodeServerUsername {
t.Fatalf("server username = %q, want %q", got, opencodeServerUsername)
}
if got := envValue(env, "OPENCODE_SERVER_PASSWORD"); got != "pw" {
t.Fatalf("server password = %q, want pw", got)
}
var cfg map[string]any
if err := json.Unmarshal([]byte(envValue(env, opencodeConfigContentEnv)), &cfg); err != nil {
t.Fatalf("decode OPENCODE_CONFIG_CONTENT: %v", err)
}
if _, ok := cfg["provider"].(map[string]any)["x"]; !ok {
t.Fatalf("existing config fields were not preserved: %#v", cfg)
}
tools := cfg["tools"].(map[string]any)
if got := tools["question"]; got != false {
t.Fatalf("tools.question = %#v, want false", got)
}
permission := cfg["permission"].(map[string]any)
for _, key := range []string{"bash", "edit", "write", "external_directory", "doom_loop"} {
if got := permission[key]; got != "allow" {
t.Fatalf("permission.%s = %#v, want allow", key, got)
}
}
if got := permission["question"]; got != "deny" {
t.Fatalf("permission.question = %#v, want deny", got)
}
var envPermission map[string]string
if err := json.Unmarshal([]byte(envValue(env, opencodePermissionEnv)), &envPermission); err != nil {
t.Fatalf("decode OPENCODE_PERMISSION: %v", err)
}
if got := envPermission["bash"]; got != "allow" {
t.Fatalf("OPENCODE_PERMISSION.bash = %q, want allow", got)
}
if got := envPermission["question"]; got != "deny" {
t.Fatalf("OPENCODE_PERMISSION.question = %q, want deny", got)
}
}
// TestOpencodeForwarderMessageGovernedByTurnCtx verifies a slow reply is not
// cut by a fixed client timeout (the old 30s bug) and that the per-turn ctx is
// the real governor instead.
+6 -1
View File
@@ -164,7 +164,7 @@ func (f *qoderStreamForwarder) commandArgs() []string {
"--input-format", "stream-json",
}
if f.yolo {
args = append(args, "--dangerously-skip-permissions")
args = append(args, "--permission-mode", "bypass_permissions", "--dangerously-skip-permissions")
} else {
args = append(args,
"--system-prompt", "",
@@ -334,6 +334,11 @@ func (f *qoderStreamForwarder) handleControlRequestLocked(line string) bool {
if json.Unmarshal([]byte(line), &ev) != nil || ev.Type != "control_request" || ev.RequestID == "" {
return false
}
subtype, _ := ev.Request["subtype"].(string)
if subtype == "" {
subtype, _ = ev.Request["type"].(string)
}
fmt.Fprintf(os.Stderr, "[connect][qoder] 自动拒绝/跳过未桥接的 control_request subtype=%q requestId=%s\n", subtype, ev.RequestID)
response := map[string]any{
"type": "control_response",
"response": map[string]any{
@@ -14,7 +14,9 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -125,6 +127,39 @@ func TestQoderForwarderKeepsStreamJSONProcessAlive(t *testing.T) {
}
}
type qoderTestWriteCloser struct {
bytes.Buffer
}
func (w *qoderTestWriteCloser) Close() error { return nil }
func TestQoderControlRequestReturnsImmediately(t *testing.T) {
stdin := &qoderTestWriteCloser{}
f := &qoderStreamForwarder{name: "qoder", stdin: stdin}
if !f.handleControlRequestLocked(`{"type":"control_request","request_id":"req-1","request":{"type":"permission","subtype":"permission","tool":"bash"}}`) {
t.Fatal("control_request was not handled")
}
var got struct {
Type string `json:"type"`
Response struct {
Subtype string `json:"subtype"`
RequestID string `json:"request_id"`
Code string `json:"code"`
} `json:"response"`
}
if err := json.Unmarshal(bytes.TrimSpace(stdin.Bytes()), &got); err != nil {
t.Fatalf("decode response: %v; raw=%q", err, stdin.String())
}
if got.Type != "control_response" || got.Response.RequestID != "req-1" {
t.Fatalf("response identity mismatch: %#v", got)
}
if got.Response.Subtype != "error" || got.Response.Code != "unsupported_control_request" {
t.Fatalf("response should explicitly unblock with unsupported error: %#v", got.Response)
}
}
func TestParseQoderPersistentLineReadsResultField(t *testing.T) {
delta, final, done := parseQoderPersistentLine(`{"type":"result","subtype":"success","result":"OK"}`)
if delta != "" || final != "OK" || !done {
+67 -30
View File
@@ -15,42 +15,79 @@ package helpers
import "sync"
// convQueue serialises message handling per conversation: same-chat messages
// run in arrival order — a Q&A follow-up ("还是不行") must see the previous
// turn's session state, and two parallel agent CLIs racing on one --resume
// session corrupt the transcript. Different conversations stay parallel.
// Ported from the hermes gateway's per-session promise chain
// (gateway/run.py session queues).
// convQueue serialises agent turns per conversation while coalescing bursts.
// Same-chat agent calls must never run in parallel — two turns racing on one
// session corrupt the transcript. But a pure FIFO is also bad UX: if a user
// keeps sending clarifications while a long turn is running, the bot should not
// spend the next several minutes answering stale intermediate prompts. Instead,
// messages arriving during the active turn accumulate in one pending batch; when
// the active turn finishes, that whole batch is processed as a single follow-up.
// Different conversations still run in parallel.
type convQueue struct {
mu sync.Mutex
tails map[string]chan struct{}
mu sync.Mutex
states map[string]*convQueueState
}
type convQueueState struct {
running bool
pending []connectQueuedTurn
process func([]connectQueuedTurn)
}
func newConvQueue() *convQueue {
return &convQueue{tails: map[string]chan struct{}{}}
return &convQueue{states: map[string]*convQueueState{}}
}
// run schedules fn after the conversation's previous task and returns
// immediately (the Stream callback must ack fast). The chain entry is removed
// once the queue for that conversation drains, so idle chats hold no memory.
func (q *convQueue) run(convID string, fn func()) {
// submit schedules turn handling and returns immediately (the Stream callback
// must ack fast). If the conversation is already running, the turn is appended
// to that conversation's pending batch instead of creating another queued worker.
func (q *convQueue) submit(turn connectQueuedTurn, process func([]connectQueuedTurn)) {
convID := turn.convID
q.mu.Lock()
prev := q.tails[convID]
done := make(chan struct{})
q.tails[convID] = done
q.mu.Unlock()
go func() {
defer func() {
close(done)
q.mu.Lock()
if q.tails[convID] == done {
delete(q.tails, convID)
}
q.mu.Unlock()
}()
if prev != nil {
<-prev
st := q.states[convID]
if st == nil {
st = &convQueueState{}
q.states[convID] = st
}
if st.running {
st.pending = append(st.pending, turn)
if process != nil {
st.process = process
}
fn()
}()
q.mu.Unlock()
return
}
st.running = true
st.process = process
q.mu.Unlock()
go q.drain(convID, []connectQueuedTurn{turn})
}
func (q *convQueue) drain(convID string, batch []connectQueuedTurn) {
for {
process := q.processFor(convID)
if process != nil {
process(batch)
}
q.mu.Lock()
st := q.states[convID]
if st == nil || len(st.pending) == 0 {
delete(q.states, convID)
q.mu.Unlock()
return
}
batch = append([]connectQueuedTurn(nil), st.pending...)
st.pending = nil
q.mu.Unlock()
}
}
func (q *convQueue) processFor(convID string) func([]connectQueuedTurn) {
q.mu.Lock()
defer q.mu.Unlock()
if st := q.states[convID]; st != nil {
return st.process
}
return nil
}
+34 -26
View File
@@ -14,36 +14,44 @@
package helpers
import (
"reflect"
"sync"
"testing"
"time"
)
// TestConvQueueSerialSameConversation: tasks of one conversation run in
// arrival order even when earlier tasks are slow.
func TestConvQueueSerialSameConversation(t *testing.T) {
// TestConvQueueCoalescesSameConversationBurst: while one conversation is
// running, later messages are batched into one follow-up turn instead of forming
// a long FIFO of stale prompts.
func TestConvQueueCoalescesSameConversationBurst(t *testing.T) {
q := newConvQueue()
var mu sync.Mutex
var order []int
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
i := i
wg.Add(1)
q.run("conv-1", func() {
defer wg.Done()
if i == 1 {
time.Sleep(50 * time.Millisecond) // slow head must not be overtaken
}
mu.Lock()
order = append(order, i)
mu.Unlock()
started := make(chan struct{})
release := make(chan struct{})
batches := make(chan []string, 2)
var once sync.Once
process := func(batch []connectQueuedTurn) {
var got []string
for _, turn := range batch {
got = append(got, turn.text)
}
batches <- got
once.Do(func() {
close(started)
<-release
})
}
wg.Wait()
for i, v := range order {
if v != i+1 {
t.Fatalf("order = %v, want 1..5 in sequence", order)
}
q.submit(connectQueuedTurn{convID: "conv-1", text: "first"}, process)
<-started
q.submit(connectQueuedTurn{convID: "conv-1", text: "second"}, process)
q.submit(connectQueuedTurn{convID: "conv-1", text: "third"}, process)
close(release)
if got := <-batches; !reflect.DeepEqual(got, []string{"first"}) {
t.Fatalf("first batch = %v, want [first]", got)
}
if got := <-batches; !reflect.DeepEqual(got, []string{"second", "third"}) {
t.Fatalf("pending batch = %v, want [second third]", got)
}
}
@@ -53,14 +61,14 @@ func TestConvQueueParallelAcrossConversations(t *testing.T) {
q := newConvQueue()
slowRelease := make(chan struct{})
slowStarted := make(chan struct{})
q.run("conv-slow", func() {
q.submit(connectQueuedTurn{convID: "conv-slow", text: "slow"}, func([]connectQueuedTurn) {
close(slowStarted)
<-slowRelease
})
<-slowStarted
fastDone := make(chan struct{})
q.run("conv-fast", func() { close(fastDone) })
q.submit(connectQueuedTurn{convID: "conv-fast", text: "fast"}, func([]connectQueuedTurn) { close(fastDone) })
select {
case <-fastDone:
case <-time.After(2 * time.Second):
@@ -75,12 +83,12 @@ func TestConvQueueDrainsEntries(t *testing.T) {
q := newConvQueue()
var wg sync.WaitGroup
wg.Add(1)
q.run("conv-1", func() { wg.Done() })
q.submit(connectQueuedTurn{convID: "conv-1", text: "done"}, func([]connectQueuedTurn) { wg.Done() })
wg.Wait()
deadline := time.Now().Add(2 * time.Second)
for {
q.mu.Lock()
n := len(q.tails)
n := len(q.states)
q.mu.Unlock()
if n == 0 {
return
+217 -48
View File
@@ -250,19 +250,24 @@ func (f *execForwarder) label() string {
return fmt.Sprintf("exec:%s (%s, %s)", f.name, f.argv[0], memo)
}
func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) {
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
func (f *execForwarder) hasSession(convID string) bool {
return f.sessions != nil && strings.TrimSpace(convID) != ""
}
func (f *execForwarder) commandArgs(argv []string, convID, text string) []string {
// Session args go right after the binary, before the spec tail — some specs
// (qoder) end the tail with `-p` so the prompt must stay the trailing
// positional argument.
var args []string
if f.sessions != nil && strings.TrimSpace(convID) != "" {
if f.hasSession(convID) {
args = append(args, f.sessions.args(convID)...)
}
args = append(args, f.argv[1:]...)
args = append(args, argv[1:]...)
args = append(args, text)
cmd := exec.CommandContext(ctx, f.argv[0], args...)
return args
}
func (f *execForwarder) configureCommand(cmd *exec.Cmd) {
// Run the agent CLI from a clean, empty directory rather than inheriting the
// connector's CWD (often $HOME). Some agents scan the working tree / nearby
// config on startup — e.g. `claude -p` takes ~29s from a large $HOME but ~4s
@@ -278,37 +283,60 @@ func (f *execForwarder) forward(ctx context.Context, convID, text string) (strin
if len(f.env) > 0 {
cmd.Env = append(os.Environ(), f.env...)
}
out, err := cmd.Output()
s := strings.TrimSpace(string(out))
// Guard against a backend error being mistaken for the answer: some agent
// CLIs (claude) print "API Error: 4xx ..." to stdout and still exit 0, so a
// non-empty stdout is not proof of a real reply. If stdout is a bare backend
// error, return an actionable hint instead of forwarding the raw error into
// the chat (issue #14: a custom-provider 422 was echoed to the group).
if s != "" && !agentReplyIsError(s) {
return brandReply(f.name, s), nil
}
if s != "" && agentReplyIsError(s) {
if f.sessions != nil && strings.TrimSpace(convID) != "" {
f.sessions.reset(convID)
}
func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) {
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
run := func() (string, string, error) {
args := f.commandArgs(f.argv, convID, text)
cmd := exec.CommandContext(ctx, f.argv[0], args...)
f.configureCommand(cmd)
out, err := cmd.Output()
s := strings.TrimSpace(string(out))
// Guard against a backend error being mistaken for the answer: some agent
// CLIs (claude) print "API Error: 4xx ..." to stdout and still exit 0, so a
// non-empty stdout is not proof of a real reply. If stdout is a bare backend
// error, return an actionable hint instead of forwarding the raw error into
// the chat (issue #14: a custom-provider 422 was echoed to the group).
if s != "" && !agentReplyIsError(s) {
return brandReply(f.name, s), "", nil
}
return agentBackendErrorReply(s), nil
if s != "" && agentReplyIsError(s) {
if f.hasSession(convID) {
f.sessions.reset(convID)
}
return agentBackendErrorReply(s), "", nil
}
if err != nil {
msg := execErrorMessage(err)
return "", msg, err
}
return "(本地 agent 无文本输出)", "", nil
}
if err != nil {
reply, msg, err := run()
if err == nil {
return reply, nil
}
if f.hasSession(convID) && agentSessionMissingError(msg) {
f.sessions.reset(convID)
reply, msg, err = run()
if err == nil {
return reply, nil
}
}
if f.hasSession(convID) {
// Self-heal session state: if this conversation's session is broken
// (e.g. --resume of a session that was never created or got cleaned),
// drop the mapping so the next message starts a fresh session instead
// of failing forever.
if f.sessions != nil && strings.TrimSpace(convID) != "" {
f.sessions.reset(convID)
}
msg := err.Error()
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
msg = strings.TrimSpace(string(ee.Stderr))
}
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
f.sessions.reset(convID)
}
return "(本地 agent 无文本输出)", nil
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
}
// convSessions maps a DingTalk conversation to a stable agent session ID, so a
@@ -415,6 +443,17 @@ func brandReply(channel, reply string) string {
return qoderworkIdentityRe.ReplaceAllString(reply, "我是 QoderWork 助手,钉钉群里的智能助手。")
}
func execErrorMessage(err error) string {
if err == nil {
return ""
}
msg := err.Error()
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
msg = strings.TrimSpace(string(ee.Stderr))
}
return msg
}
// agentReplyIsError reports whether an agent's stdout is a bare backend error
// rather than a real answer. Claude Code prints provider failures as
// "API Error: <status> ..." on stdout and may still exit 0, so the connector
@@ -424,6 +463,16 @@ func agentReplyIsError(s string) bool {
return strings.HasPrefix(strings.TrimSpace(s), "API Error:")
}
// agentSessionMissingError recognizes stale addressable sessions. Agent CLIs
// differ in wording, but the operational meaning is the same: the connector's
// persisted session id points at a conversation the CLI can no longer resume.
func agentSessionMissingError(msg string) bool {
lower := strings.ToLower(strings.TrimSpace(msg))
return strings.Contains(lower, "no conversation found with session id") ||
strings.Contains(lower, "conversation not found") ||
strings.Contains(lower, "session not found")
}
// agentBackendErrorReply turns a bare backend error into a short, actionable
// Chinese message for the chat, instead of echoing the raw provider error
// (issue #14). It keeps only the first line of the raw error, truncated.
@@ -588,10 +637,11 @@ func claudeUserSettingsEnv() []string {
return out
}
// agentSpecs is the registry of local-agent channels. Most forward to a local
// agentSpecs is the registry of agent channels. Most forward to a local
// headless CLI (one-shot per message, 24/7, no interactive session). Codex uses
// the local CLI binary only to host app-server. Exact headless flags for
// non-Codex channels can be overridden per run with DWS_AGENT_CMD.
// the local CLI binary only to host app-server; Gemini uses its HTTP API.
// Exact headless flags for CLI channels can be overridden per run with
// DWS_AGENT_CMD.
//
// Install policy: npm/pipx (package managers) are auto-installed; curl|bash
// remote-script installs and desktop apps are hint-only (we do not silently pipe
@@ -608,9 +658,8 @@ var agentSpecs = map[string]agentSpec{
"codex": {app: "OpenAI Codex CLI", bins: []string{"codex"},
install: []string{"npm", "i", "-g", "@openai/codex"}, hint: "npm i -g @openai/codex",
modelFlag: "-m"},
"gemini": {app: "Gemini CLI", bins: []string{"gemini"}, argvTail: []string{"-p"},
install: []string{"npm", "i", "-g", "@google/gemini-cli"}, hint: "npm i -g @google/gemini-cli",
modelFlag: "-m"},
"gemini": {app: "Gemini API",
hint: "设置 GEMINI_API_KEY(或 GOOGLE_API_KEY);模型可用 --agent-model 指定;Gemini-compatible 代理可设置 GEMINI_API_BASE_URL"},
// opencode is resolved here only to find the local binary. The forwarder
// uses `opencode serve --pure` plus HTTP session/message APIs instead of
// parsing `opencode run` stdout.
@@ -719,6 +768,17 @@ func connectCliStatus(channel string) map[string]any {
status["command"] = command
}
return status
case "gemini":
status := map[string]any{
"required": "GEMINI_API_KEY or GOOGLE_API_KEY",
"installed": geminiAPIKey() != "",
"autoInstall": false,
"installHint": "设置 GEMINI_API_KEY(或 GOOGLE_API_KEY);模型可用 --agent-model 指定;Gemini-compatible 代理可设置 GEMINI_API_BASE_URL",
}
if base := strings.TrimSpace(geminiAPIBaseURL()); base != "" {
status["baseURL"] = base
}
return status
}
spec, ok := agentSpecs[channel]
if !ok {
@@ -785,6 +845,10 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("渠道 %q 不是 stream-bridge 渠道,无 forwarder", channel))
}
overridden := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" && channel != "codex"
if channel == "gemini" && !overridden {
return newGeminiAPIForwarder(timeout, opts)
}
// Resolve the agent CLI (PATH → app bundle → auto-install → guidance) and
// preflight here so a missing dependency errors at connect time.
argv, env, err := resolveExecAgent(channel)
@@ -795,7 +859,6 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo
// model or session flags we cannot know are valid for it. Codex ignores the
// override because its channel is app-server only; custom commands belong on
// --channel custom.
overridden := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" && channel != "codex"
userPickedModel := opts.Model != "" || strings.TrimSpace(os.Getenv("DWS_AGENT_MODEL")) != ""
if !overridden && opts.Model != "" {
if spec.modelFlag == "" {
@@ -850,14 +913,9 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo
if !overridden && opts.Yolo {
switch channel {
case "claudecode", "codebuddy", "workbuddy":
argv = append(argv, "--dangerously-skip-permissions")
argv = append(argv, "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions")
if len(streamArgv) > 0 {
streamArgv = append(streamArgv, "--dangerously-skip-permissions")
}
case "gemini":
argv = append(argv, "--yolo")
if len(streamArgv) > 0 {
streamArgv = append(streamArgv, "--yolo")
streamArgv = append(streamArgv, "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions")
}
}
}
@@ -883,6 +941,18 @@ func applyModelArg(argv []string, flag, model string) []string {
return append(out[:1:1], append([]string{flag, model}, out[1:]...)...)
}
func insertBeforeArg(argv []string, marker string, values ...string) []string {
for i := 1; i < len(argv); i++ {
if argv[i] == marker {
out := append([]string(nil), argv[:i]...)
out = append(out, values...)
return append(out, argv[i:]...)
}
}
out := append([]string(nil), argv...)
return append(out, values...)
}
// msgDedup tracks recently-seen MsgIds so a redelivered message is not
// processed (and replied to) twice. Memory is bounded: once the set reaches
// limit it is cleared (the chance of a very old MsgId being redelivered after a
@@ -935,6 +1005,80 @@ type connectExtras struct {
persona string
}
type connectQueuedTurn struct {
convID string
text string
picCode string
fileInfo fileInboundInfo
webhook string
msgID string
msgType string
senderStaffID string
conversationID string
conversationType string
callbackData chatbot.BotCallbackDataModel
}
func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
if len(turns) == 0 {
return connectQueuedTurn{}
}
if len(turns) == 1 {
return turns[0]
}
for i := len(turns) - 1; i >= 0; i-- {
if connectTurnShouldStayStandalone(turns[i]) {
return turns[i]
}
}
merged := turns[len(turns)-1]
lines := make([]string, 0, len(turns)+1)
lines = append(lines, "用户在上一轮处理期间连续发送了以下消息,请把它们作为同一个最新请求一起处理:")
for i, turn := range turns {
lines = append(lines, fmt.Sprintf("%d. %s", i+1, connectTurnSummary(turn)))
}
merged.text = strings.Join(lines, "\n")
if merged.picCode == "" && !merged.fileInfo.hasActionable() {
for i := len(turns) - 1; i >= 0; i-- {
if turns[i].picCode != "" {
merged.picCode = turns[i].picCode
break
}
if turns[i].fileInfo.hasActionable() {
merged.fileInfo = turns[i].fileInfo
break
}
}
}
return merged
}
func connectTurnShouldStayStandalone(turn connectQueuedTurn) bool {
if _, ok := parseConnectControlCommand(turn.text); ok {
return true
}
if _, ok := parseDecisionWord(turn.text); ok {
return true
}
return isRetryWord(turn.text)
}
func connectTurnSummary(turn connectQueuedTurn) string {
if text := strings.TrimSpace(turn.text); text != "" {
return text
}
if turn.picCode != "" {
return "[图片]"
}
if turn.fileInfo.hasActionable() {
if name := strings.TrimSpace(turn.fileInfo.FileName); name != "" {
return "[文件: " + name + "]"
}
return "[文件]"
}
return "[空消息]"
}
func runStreamConnector(ctx context.Context, channel, clientID, clientSecret string, fwd forwarder, cardCli *aiCardClient, extras *connectExtras) error {
if extras == nil {
extras = &connectExtras{}
@@ -1072,11 +1216,36 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
if convID == "" {
convID = strings.TrimSpace(data.SenderStaffId)
}
callbackData := data
msgID := strings.TrimSpace(data.MsgId)
// Same-conversation messages run in arrival order (follow-ups need the
// previous turn's session state); different conversations in parallel.
queue.run(convID, func() {
turn := connectQueuedTurn{
convID: convID,
text: text,
picCode: picCode,
fileInfo: fileInfo,
webhook: webhook,
msgID: msgID,
msgType: msgtype,
senderStaffID: strings.TrimSpace(data.SenderStaffId),
conversationID: strings.TrimSpace(data.ConversationId),
conversationType: strings.TrimSpace(data.ConversationType),
callbackData: *data,
}
// Same-conversation agent calls never run in parallel; messages received
// while a turn is running are merged into one pending follow-up instead
// of forming an unbounded stale FIFO.
queue.submit(turn, func(turns []connectQueuedTurn) {
turn := mergeConnectQueuedTurns(turns)
if len(turns) > 1 {
fmt.Fprintf(os.Stderr, "[connect] 合并 %d 条待处理消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID)
}
text := turn.text
picCode := turn.picCode
fileInfo := turn.fileInfo
webhook := turn.webhook
convID := turn.convID
msgID := turn.msgID
msgtype := turn.msgType
callbackData := &turn.callbackData
// Digital-twin text approval: if this is the OWNER replying
// 「同意」/「拒绝」 (in their 1:1 chat with the bot) to the pending
// request, route it to the gate (decide → execute/decline, with
+28
View File
@@ -162,3 +162,31 @@ func TestEnvDurationMS(t *testing.T) {
t.Fatalf("invalid env falls back to default = %v, want %v", got, def)
}
}
func TestMergeConnectQueuedTurnsBuildsSinglePrompt(t *testing.T) {
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
{convID: "conv-1", text: "第一条", msgID: "m1"},
{convID: "conv-1", text: "补充:按今天的数据", msgID: "m2"},
{convID: "conv-1", text: "最后改成周报口径", msgID: "m3"},
})
if merged.msgID != "m3" {
t.Fatalf("merged msgID = %q, want latest m3", merged.msgID)
}
for _, want := range []string{"连续发送", "1. 第一条", "2. 补充:按今天的数据", "3. 最后改成周报口径"} {
if !strings.Contains(merged.text, want) {
t.Fatalf("merged prompt missing %q:\n%s", want, merged.text)
}
}
}
func TestMergeConnectQueuedTurnsKeepsControlMessagesStandalone(t *testing.T) {
for _, text := range []string{"/clear", "同意", "拒绝", "重试"} {
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
{convID: "conv-1", text: "先查一下", msgID: "m1"},
{convID: "conv-1", text: text, msgID: "m2"},
})
if merged.text != text || merged.msgID != "m2" {
t.Fatalf("control %q merged to (%q,%q), want standalone latest", text, merged.text, merged.msgID)
}
}
}
+67 -65
View File
@@ -18,7 +18,6 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
)
@@ -53,76 +52,79 @@ func (f *execForwarder) forwardStream(ctx context.Context, convID, text string,
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
var args []string
if f.sessions != nil && strings.TrimSpace(convID) != "" {
args = append(args, f.sessions.args(convID)...)
}
args = append(args, f.streamArgv[1:]...)
args = append(args, text)
cmd := exec.CommandContext(ctx, f.streamArgv[0], args...)
if f.workDir != "" {
cmd.Dir = f.workDir
} else {
cmd.Dir = connectWorkDir()
}
if len(f.env) > 0 {
cmd.Env = append(os.Environ(), f.env...)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", err
}
var stderrBuf strings.Builder
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return "", err
run := func() (string, string, error) {
args := f.commandArgs(f.streamArgv, convID, text)
cmd := exec.CommandContext(ctx, f.streamArgv[0], args...)
f.configureCommand(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", err.Error(), err
}
var stderrBuf strings.Builder
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return "", err.Error(), err
}
var acc strings.Builder // accumulated visible text ("cc" deltas / "qoder" turns)
finalText := ""
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "{") {
continue
}
delta, final := parseStreamLine(f.parser, line)
if delta != "" {
acc.WriteString(delta)
onDelta(brandReply(f.name, acc.String()))
}
if final != "" {
finalText = final
}
}
waitErr := cmd.Wait()
if finalText == "" {
finalText = strings.TrimSpace(acc.String())
}
// A bare backend error (e.g. claude's "API Error: 4xx ...") must not be
// forwarded as the answer (issue #14): return an actionable hint instead.
if agentReplyIsError(finalText) {
if f.hasSession(convID) {
f.sessions.reset(convID)
}
return agentBackendErrorReply(finalText), "", nil
}
if finalText != "" {
return brandReply(f.name, finalText), "", nil
}
if waitErr != nil {
msg := waitErr.Error()
if s := strings.TrimSpace(stderrBuf.String()); s != "" {
msg = s
}
return "", msg, waitErr
}
return "(本地 agent 无文本输出)", "", nil
}
var acc strings.Builder // accumulated visible text ("cc" deltas / "qoder" turns)
finalText := ""
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "{") {
continue
}
delta, final := parseStreamLine(f.parser, line)
if delta != "" {
acc.WriteString(delta)
onDelta(brandReply(f.name, acc.String()))
}
if final != "" {
finalText = final
reply, msg, err := run()
if err == nil {
return reply, nil
}
if f.hasSession(convID) && agentSessionMissingError(msg) {
f.sessions.reset(convID)
reply, msg, err = run()
if err == nil {
return reply, nil
}
}
waitErr := cmd.Wait()
if finalText == "" {
finalText = strings.TrimSpace(acc.String())
}
// A bare backend error (e.g. claude's "API Error: 4xx ...") must not be
// forwarded as the answer (issue #14): return an actionable hint instead.
if agentReplyIsError(finalText) {
if f.sessions != nil && strings.TrimSpace(convID) != "" {
f.sessions.reset(convID)
}
return agentBackendErrorReply(finalText), nil
}
if finalText != "" {
return brandReply(f.name, finalText), nil
}
if f.sessions != nil && strings.TrimSpace(convID) != "" {
if f.hasSession(convID) {
f.sessions.reset(convID)
}
if waitErr != nil {
msg := waitErr.Error()
if s := strings.TrimSpace(stderrBuf.String()); s != "" {
msg = s
}
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
}
return "(本地 agent 无文本输出)", nil
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
}
// parseStreamLine extracts (visible-text delta, final text) from one JSONL
+8 -6
View File
@@ -582,9 +582,11 @@ contact user profile fields 获取可用字段列表。
contactDeptSearchCmd.Flags().String("name", "", "--query 的别名")
_ = contactDeptSearchCmd.Flags().MarkHidden("keyword")
_ = contactDeptSearchCmd.Flags().MarkHidden("name")
contactDeptGetInfoCmd.Flags().String("id", "", "部门 ID (必填)")
contactDeptListChildrenCmd.Flags().String("id", "", "部门 ID (必填)")
contactDeptListMembersCmd.Flags().String("ids", "", "部门 ID 列表 (必填)")
// 主 flag 与 RunE 读取保持一致:get-info / list-children 用 --dept,list-members 用 --depts。
// 历史上主 flag 曾误注册为 --id/--ids,导致 RunE 读的 --dept/--depts 未注册、命令行传入报 unknown flag。
contactDeptGetInfoCmd.Flags().String("dept", "", "部门 ID (必填)")
contactDeptListChildrenCmd.Flags().String("dept", "", "部门 ID (必填)")
contactDeptListMembersCmd.Flags().String("depts", "", "部门 ID 列表 (必填)")
// dept 系列命令统一接受 --id / --ids / --dept-id / --dept-ids 别名(集中注册避免逐命令重复写)。
// camelCase --deptId / --deptIds 由 RegisterCamelCaseAliases 自动派生,无需手写。
@@ -593,9 +595,9 @@ contact user profile fields 获取可用字段列表。
aliases []string
}
for _, s := range []deptIDAliasSpec{
{contactDeptGetInfoCmd, []string{"dept-id", "ids", "dept-ids"}},
{contactDeptListChildrenCmd, []string{"ids", "dept-id", "dept-ids"}},
{contactDeptListMembersCmd, []string{"id", "dept-id", "dept-ids"}},
{contactDeptGetInfoCmd, []string{"id", "dept-id", "ids", "dept-ids"}},
{contactDeptListChildrenCmd, []string{"id", "ids", "dept-id", "dept-ids"}},
{contactDeptListMembersCmd, []string{"ids", "id", "dept-id", "dept-ids"}},
} {
for _, name := range s.aliases {
if s.cmd.Flags().Lookup(name) != nil {
+2 -2
View File
@@ -583,7 +583,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("robot-client-id", "", "现成机器人 clientId(AppKey)")
cmd.Flags().String("robot-client-secret", "", "现成机器人 clientSecret(AppSecret)")
cmd.Flags().String("unified-app-id", "", "统一应用 ID:复用 dev app credentials get 自动取凭证(替代手填 robot-client-id/secret)")
cmd.Flags().String("agent-model", "", "覆盖本地 agent 模型(如 claude 的 sonnet/opus;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL")
cmd.Flags().String("agent-model", "", "覆盖 agent 模型(如 claude 的 sonnet/opus、gemini-2.5-pro;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL")
cmd.Flags().String("agent-workdir", "", "本地 agent 的运行目录(放知识文件可给机器人上下文;默认空白临时目录,求快);env: DWS_AGENT_WORKDIR")
cmd.Flags().Bool("agent-memory", true, "按会话续聊:同一群/单聊共享 agent 会话上下文(codex/opencode/qoder/qoderwork/claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)")
cmd.Flags().Int("agent-timeout", 0, "每次 agent 调用的超时时间(秒),0=不限制(默认);env: DWS_AGENT_TIMEOUT_MS(毫秒)")
@@ -751,7 +751,7 @@ func resolveAgentYoloMode(cmd *cobra.Command) (bool, error) {
// connectAgentOptionsPayload renders the effective agent tuning for the
// dry-run preview, including whether session memory actually applies to the
// chosen channel (Codex uses app-server threads, opencode uses opencode serve
// sessions, CLI session channels use --session-id/--resume, and gemini stays
// sessions, CLI session channels use --session-id/--resume, and Gemini API stays
// stateless today).
func connectAgentOptionsPayload(channel string, opts connectAgentOptions) map[string]any {
spec, ok := agentSpecs[channel]
+3 -1
View File
@@ -29,6 +29,8 @@ func clearChannelEnv(t *testing.T) {
"DWS_CONNECT_CMD", "DWS_AGENT_CMD",
"WORKBUDDY_CONFIG_DIR", "WORKBUDDY_APP_NAME", "CLAUDECODE",
"DWS_AGENT_PERMISSION_MODE", "DWS_AGENT_APPROVAL_MODE",
"GEMINI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_BASE_URL",
"GOOGLE_GEMINI_API_BASE_URL", "GEMINI_MODEL",
} {
t.Setenv(k, "")
}
@@ -264,7 +266,7 @@ func TestAgentSpecsCoverMainstreamAgents(t *testing.T) {
t.Errorf("agentSpecs missing channel %q", ch)
continue
}
if len(spec.bins) == 0 {
if ch != "gemini" && len(spec.bins) == 0 {
t.Errorf("channel %q has no bins", ch)
}
if spec.hint == "" {
+9 -5
View File
@@ -93,8 +93,9 @@ func newDingCommand() *cobra.Command {
Use: "list",
Short: "查询 DING 消息历史",
Long: `查询当前用户的 DING 消息列表,支持按类型过滤。
--type 支持: ALL(全部)、UNREAD(未读)、SEND(已发)、NEW_COMMENT(新评论)、DELETED(已删除)。`,
Example: ` dws ding message list
--type 支持: ALL(全部)、UNREAD(未读)、SEND(已发)、NEW_COMMENT(新评论)、DELETED(已删除)。
--type 为服务端必填字段,空值会报「type不能为空」;不传时 CLI 默认按 ALL 查询。`,
Example: ` dws ding message list # 默认 --type ALL
dws ding message list --type UNREAD
dws ding message list --type SEND --cursor 10`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -102,9 +103,12 @@ func newDingCommand() *cobra.Command {
if v, _ := cmd.Flags().GetInt64("cursor"); v > 0 {
toolArgs["cursor"] = v
}
if v, _ := cmd.Flags().GetString("type"); v != "" {
toolArgs["type"] = v
// type 是服务端必填,空值会报错;不传或传空时兜底为 ALL。
t, _ := cmd.Flags().GetString("type")
if t == "" {
t = "ALL"
}
toolArgs["type"] = t
return callMCPToolOnServer("im", "list_ding_messages", toolArgs)
},
}
@@ -210,7 +214,7 @@ func newDingCommand() *cobra.Command {
dingMessageRecallCmd.Flags().String("robot-code", "", "机器人 ID (必填,或设 DINGTALK_DING_ROBOT_CODE)")
dingMessageRecallCmd.Flags().String("id", "", "DING 消息 ID (必填)")
dingMessageListCmd.Flags().Int64("cursor", 0, "分页游标(首次传 0,翻页传返回的 nextCursor)")
dingMessageListCmd.Flags().String("type", "", "消息类型: ALL / UNREAD / SEND / NEW_COMMENT / DELETED(可选,不传返回全部)")
dingMessageListCmd.Flags().String("type", "ALL", "消息类型: ALL / UNREAD / SEND / NEW_COMMENT / DELETED(必填,服务端不接受空值;默认 ALL 全部)")
dingMessageReceiverStatusCmd.Flags().String("ding-id", "", "DING 消息 openDingId (必填)")
_ = dingMessageReceiverStatusCmd.MarkFlagRequired("ding-id")
dingMessageSendPersonalCmd.Flags().String("users", "", "接收者 openDingTalkId 列表,逗号分隔 (必填)")
+294 -4
View File
@@ -400,7 +400,11 @@ func inferFilename(rawURL string) string {
if decoded, err := url.PathUnescape(name); err == nil {
name = decoded
}
if name != "" {
// 解码后可能含 %2F 还原出的路径分隔符(如 "ddmedia/xxx.png"),只取
// 末段 base 名,避免拼出调用方未创建的子目录导致写文件失败。
name = strings.ReplaceAll(name, "\\", "/")
name = filepath.Base(name)
if name != "" && name != "." && name != "/" {
return name
}
}
@@ -783,7 +787,7 @@ func newDocCommand() *cobra.Command {
root := &cobra.Command{
Use: "doc",
Short: "钉钉文档管理",
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、模板管理。
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。
命令结构:
dws doc info 获取文档元信息
@@ -794,6 +798,8 @@ func newDocCommand() *cobra.Command {
dws doc comment [list|create|delete] 文档评论管理
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
dws doc export get 查询导出任务结果 (手动兜底)
dws doc import 导入本地文件为在线文档 (支持 docx / xlsx / md 等)
dws doc import get 查询导入任务结果 (手动兜底)
dws doc template list 获取文档模板列表
dws doc template search 搜索文档模板
dws doc template apply 应用文档模板创建新文档
@@ -2442,6 +2448,233 @@ CLI 内部自动完成全部流程:
exportCmd.AddCommand(exportGetCmd)
// ── import: 文件导入为在线文档(一体化:上传→转换→轮询)──────────────
importCmd := &cobra.Command{
Use: "import",
Short: "导入本地文件为在线文档 (支持 docx / xlsx / md 等)",
Long: `将本地文件导入为钉钉在线文档。
支持的文件格式 (按扩展名):
docx, doc → 文字文档
xlsx, xls → 电子表格
md, txt → 文字文档
xmind, mark → 脑图
文件大小限制: 20MB
CLI 内部自动完成全部流程:
1. 创建导入会话(获取 OSS 上传凭证)
2. 上传文件到 OSS
3. 确认导入(触发格式转换)
4. 渐进式退避轮询等待完成(最多约 5 分钟)
如果轮询超时仍未完成,会输出 taskId 供后续手动查询:
dws doc import get --task-id <taskId>`,
Example: ` # 导入 Word 文档
dws doc import --file ./report.docx
# 导入到指定文件夹
dws doc import --file ./notes.md --folder <FOLDER_ID>
# 导入到知识库根目录
dws doc import --file ./data.xlsx --workspace <WORKSPACE_ID>
# 自定义导入后的文档名称
dws doc import --file ./draft.md --name "项目周报"`,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := mustGetFlag(cmd, "file")
if filePath == "" && len(args) > 0 {
filePath = args[0]
}
if filePath == "" {
return fmt.Errorf("flag --file is required (or pass file path as argument)")
}
fi, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fi.IsDir() {
return fmt.Errorf("%s is a directory, not a file", filePath)
}
const maxFileSize = 20 * 1024 * 1024
fileSize := fi.Size()
if fileSize > maxFileSize {
return fmt.Errorf("file size %d bytes exceeds 20MB limit", fileSize)
}
if fileSize == 0 {
return fmt.Errorf("file is empty: %s", filePath)
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
supportedFormats := map[string]bool{
"docx": true, "doc": true, "xlsx": true, "xls": true,
"md": true, "txt": true, "xmind": true, "mark": true,
}
if !supportedFormats[ext] {
return fmt.Errorf("unsupported file format %q, supported: docx, doc, xlsx, xls, md, txt, xmind, mark", ext)
}
fileName := filepath.Base(filePath)
name, _ := cmd.Flags().GetString("name")
if name == "" {
name = strings.TrimSuffix(fileName, filepath.Ext(fileName))
}
folder := flagOrFallback(cmd, "folder", "folder-id")
workspace := flagOrFallback(cmd, "workspace", "workspace-id")
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "导入本地文件为在线文档")
deps.Out.PrintKeyValue("文件", filePath)
deps.Out.PrintKeyValue("名称", name)
deps.Out.PrintKeyValue("格式", ext)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
return nil
}
ctx := context.Background()
deps.Out.PrintInfo("[1/4] 创建导入会话...")
sessionArgs := map[string]any{
"fileName": name,
"suffix": ext,
"fileSize": fileSize,
}
if folder != "" {
sessionArgs["targetFolderId"] = folder
}
if workspace != "" {
sessionArgs["workspaceId"] = workspace
}
sessionText, err := callMCPToolReturnText(ctx, "create_import_session", sessionArgs)
if err != nil {
return fmt.Errorf("创建导入会话失败: %w", err)
}
var sessionResult map[string]any
if err := json.Unmarshal([]byte(sessionText), &sessionResult); err != nil {
return fmt.Errorf("解析导入会话响应失败: %w", err)
}
sessionID, _ := sessionResult["sessionId"].(string)
uploadURL, _ := sessionResult["uploadUrl"].(string)
if sessionID == "" || uploadURL == "" {
deps.Out.PrintRaw(sessionText)
return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl")
}
deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID))
deps.Out.PrintInfo("[2/4] 上传文件...")
if err := httpPutFile(ctx, uploadURL, nil, filePath, fileSize); err != nil {
return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err)
}
deps.Out.PrintInfo(" 文件上传完成")
deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...")
confirmText, err := callMCPToolReturnText(ctx, "confirm_import", map[string]any{
"sessionId": sessionID,
})
if err != nil {
return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err)
}
var confirmResult map[string]any
if err := json.Unmarshal([]byte(confirmText), &confirmResult); err != nil {
return fmt.Errorf("解析确认导入响应失败: %w", err)
}
taskID, _ := confirmResult["taskId"].(string)
if taskID == "" {
deps.Out.PrintRaw(confirmText)
return fmt.Errorf("确认导入成功但未返回 taskId")
}
deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID))
deps.Out.PrintInfo("[4/4] 等待格式转换完成...")
importResult, err := pollDocImportTask(ctx, taskID)
if err != nil {
return err
}
documentURL, _ := importResult["documentUrl"].(string)
documentName, _ := importResult["documentName"].(string)
documentType, _ := importResult["documentType"].(string)
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
deps.Out.PrintJSON(map[string]any{
"success": true,
"taskId": taskID,
"documentUrl": documentURL,
"documentName": documentName,
"documentType": documentType,
})
return nil
},
}
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)")
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)")
importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)")
importCmd.Flags().String("folder-id", "", "")
_ = importCmd.Flags().MarkHidden("folder-id")
importCmd.Flags().String("workspace-id", "", "")
_ = importCmd.Flags().MarkHidden("workspace-id")
importGetCmd := &cobra.Command{
Use: "get",
Short: "查询导入任务结果(手动兜底)",
Long: `根据 taskId 查询文档导入任务的执行结果。
通常不需要手动调用,dws doc import 会自动完成轮询。
仅在导入命令超时或中断后,用于手动查询任务状态。
任务状态:
processing 转换中
completed 导入成功,返回 documentUrl
failed 导入失败`,
Example: ` dws doc import get --task-id <TASK_ID>`,
RunE: func(cmd *cobra.Command, _ []string) error {
taskID := mustGetFlag(cmd, "task-id")
if taskID == "" {
return fmt.Errorf("flag --task-id is required")
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "查询导入任务结果")
deps.Out.PrintKeyValue("任务ID", taskID)
return nil
}
ctx := context.Background()
text, err := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID})
if err != nil {
return err
}
var result map[string]any
if err := json.Unmarshal([]byte(text), &result); err != nil {
deps.Out.PrintRaw(text)
return nil
}
status, _ := result["status"].(string)
message, _ := result["message"].(string)
switch strings.ToLower(status) {
case "completed", "processing":
deps.Out.PrintJSON(result)
return nil
default:
deps.Out.PrintJSON(result)
if message != "" {
return fmt.Errorf("导入任务失败 (status=%s): %s", status, message)
}
return fmt.Errorf("导入任务失败 (status=%s)", status)
}
},
}
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
importCmd.AddCommand(importGetCmd)
// ── doc version 子命令组 ──
versionCmd := &cobra.Command{
Use: "version",
@@ -2654,7 +2887,7 @@ CLI 内部自动完成全部流程:
// will skip flags that already exist.
for _, cmd := range []*cobra.Command{
searchCmd, listCmd, createCmd, updateCmd, uploadCmd, downloadCmd,
copyCmd, moveCmd, renameCmd, deleteCmd, exportCmd,
copyCmd, moveCmd, renameCmd, deleteCmd, exportCmd, importCmd,
} {
RegisterCrossProductAliases(cmd)
}
@@ -2696,7 +2929,7 @@ CLI 内部自动完成全部流程:
folderCmd.Hidden = true
permissionCmd.Hidden = true
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, versionCmd, templateCmd)
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd)
return root
}
@@ -2943,6 +3176,63 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er
return "", fmt.Errorf("导出任务超时:已轮询 %d 次仍在处理中 (jobId=%s),请稍后使用 dws doc export get --job-id %s 手动查询", maxPolls, jobID, jobID)
}
// pollDocImportTask polls the import task status with progressive backoff.
func pollDocImportTask(ctx context.Context, taskID string) (map[string]any, error) {
const maxPolls = 30
pollInterval := func(attempt int) time.Duration {
switch {
case attempt <= 5:
return 2 * time.Second
case attempt <= 10:
return 5 * time.Second
case attempt <= 20:
return 10 * time.Second
default:
return 15 * time.Second
}
}
for attempt := 1; attempt <= maxPolls; attempt++ {
interval := pollInterval(attempt)
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
select {
case <-ctx.Done():
return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, ctx.Err())
case <-time.After(interval):
}
text, queryErr := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID})
if queryErr != nil {
return nil, fmt.Errorf("查询导入任务失败 (taskId=%s): %w", taskID, queryErr)
}
var result map[string]any
if parseErr := json.Unmarshal([]byte(text), &result); parseErr != nil {
return nil, fmt.Errorf("解析查询结果失败 (taskId=%s): %w", taskID, parseErr)
}
status, _ := result["status"].(string)
switch strings.ToLower(status) {
case "completed":
return result, nil
case "processing":
continue
case "failed":
message, _ := result["message"].(string)
if message != "" {
return nil, fmt.Errorf("导入任务失败 (taskId=%s): %s", taskID, message)
}
return nil, fmt.Errorf("导入任务失败 (taskId=%s)", taskID)
default:
continue
}
}
return nil, fmt.Errorf("导入任务超时:已轮询 %d 次仍在处理中 (taskId=%s),请稍后使用 dws doc import get --task-id %s 手动查询", maxPolls, taskID, taskID)
}
// stripDuplicateTitle removes the leading H1 heading from markdown content
// when it matches the document name (set via --name). This prevents the title
// from appearing twice: once as document metadata and once in the body.
+351 -47
View File
@@ -104,7 +104,7 @@ func newMailCommand() *cobra.Command {
Long: `查询当前用户绑定的所有邮箱地址。
返回字段:
mailboxes 邮箱列表,每条包含邮箱地址、账号类型、所属企业
emailAccounts 邮箱列表,每条包含邮箱地址(email)、账号类型(type)、所属企业(orgName)
错误说明:
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
@@ -114,7 +114,38 @@ func newMailCommand() *cobra.Command {
},
}
mailboxCmd.AddCommand(mailboxListCmd)
mailboxProfileCmd := &cobra.Command{
Use: "profile",
Short: "获取用户邮箱信息",
Long: `根据邮箱地址获取用户的邮箱详细信息,包含容量、别名等。
返回字段:
email 邮箱地址
emailAliases 邮件地址别名列表
name 用户名
nickname 用户昵称
displayName 用户显示名
mboxSize 邮箱容量(字节)
mboxSizeUsed 已使用的邮箱容量(字节)
createdTime 创建时间
modifiedTime 修改时间
错误说明:
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
Example: ` dws mail mailbox profile --email user@company.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email"); err != nil {
return err
}
return callMCPTool("get_mailbox_profile", map[string]any{
"email": mustGetFlag(cmd, "email"),
})
},
}
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
@@ -1138,6 +1169,64 @@ internetMessageId 来源:message send / draft send / message reply / message r
},
}
messageBatchGetCmd := &cobra.Command{
Use: "batch-get",
Short: "批量获取邮件详情",
Long: `根据邮件 ID 列表批量获取邮件完整内容,包含正文。
限制说明:
- 单次最多 20 个邮件 ID
- CLI 会逐个调用 get_email_by_message_id 并返回聚合结果
错误说明:
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
Example: ` dws mail message batch-get --email user@company.com --ids <id1>,<id2>
dws mail message batch-get --email user@company.com --ids <id1>,<id2>,<id3>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "ids"); err != nil {
return err
}
email := mustGetFlag(cmd, "email")
ids := parseRecipients(mustGetFlag(cmd, "ids"))
if len(ids) == 0 {
return fmt.Errorf("--ids 至少需要 1 个邮件 ID")
}
if len(ids) > 20 {
return fmt.Errorf("--ids 单次最多支持 20 个邮件 ID,当前: %d", len(ids))
}
if deps.Caller.DryRun() {
fmt.Printf("[DRY-RUN] Preview only, not executed:\n")
fmt.Printf("Operation: batch-get (loop get_email_by_message_id x%d)\n", len(ids))
fmt.Printf("Arguments:\n")
fmt.Printf(" email: %s\n", email)
fmt.Printf(" ids: %v\n", ids)
return nil
}
items := make([]map[string]any, 0, len(ids))
for _, id := range ids {
text, err := callMCPToolReturnText(context.Background(), "get_email_by_message_id", map[string]any{
"email": email,
"messageId": id,
})
if err != nil {
return fmt.Errorf("获取邮件详情失败 id=%s: %w", id, err)
}
var payload any
if err := json.Unmarshal([]byte(text), &payload); err != nil {
payload = text
}
items = append(items, map[string]any{
"id": id,
"result": payload,
})
}
return deps.Out.PrintJSON(map[string]any{
"email": email,
"messages": items,
})
},
}
draftCreateCmd := &cobra.Command{
Use: "create",
Short: "创建草稿",
@@ -1407,12 +1496,88 @@ internetMessageId 来源:message send / draft send / message reply / message r
messageBatchModifyCmd.Flags().String("action", "", "操作类型: markRead/markUnread/addTags/removeTags (必填)")
messageBatchModifyCmd.Flags().String("tags", "", "标签 ID 列表,逗号分隔 (action 为 addTags/removeTags 时必填)")
messageBatchGetCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
messageBatchGetCmd.Flags().String("ids", "", "要获取的邮件 ID 列表,逗号分隔,最多 20 个 (必填)")
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageVerifyCmd)
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
sentMessageRecallCmd := &cobra.Command{
Use: "recall",
Short: "[危险] 撤回已发送的邮件",
Long: `撤回已发送的邮件。仅支持撤回同组织内未读邮件。
返回字段:
id 撤回任务 ID(可用于 recall-detail 查询进度)
success 接口调用是否成功
errorCode 错误码(仅失败时存在)
errorMsg 错误信息(仅失败时存在)
错误说明:
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
Example: ` dws mail sent-message recall --email user@company.com --id <mailId> --subject "邮件主题" --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id", "subject"); err != nil {
return err
}
yes, _ := cmd.Flags().GetBool("yes")
if !yes && !deps.Caller.DryRun() {
return fmt.Errorf("此操作为危险操作,需要传入 --yes 确认执行")
}
return callMCPTool("recall_sent_message", map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": mustGetFlag(cmd, "id"),
"subject": mustGetFlag(cmd, "subject"),
})
},
}
sentMessageRecallDetailCmd := &cobra.Command{
Use: "recall-detail",
Short: "查询邮件撤回进度",
Long: `根据撤回任务 ID 查询邮件撤回的详细进度。
撤回任务 ID 来源:sent-message recall 命令返回值中的 id 字段。
返回字段:
id 撤回任务 ID
status 任务状态: UNINITED/SUBMITTED/RUNNING/FINISHED/CANCELED/FAILED
createdTime 创建时间
updatedTime 更新时间
totalCount 总邮件数
succeededCount 成功撤回数
failedCount 撤回失败数
details 每封邮件的撤回结果
错误说明:
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
Example: ` dws mail sent-message recall-detail --email user@company.com --id <recallTaskId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
return err
}
return callMCPTool("get_recall_detail", map[string]any{
"email": mustGetFlag(cmd, "email"),
"id": mustGetFlag(cmd, "id"),
})
},
}
sentMessageRecallCmd.Flags().String("email", "", "发件人邮箱地址 (必填)")
sentMessageRecallCmd.Flags().String("id", "", "要撤回的邮件 ID (必填)")
sentMessageRecallCmd.Flags().String("subject", "", "邮件主题 (必填)")
sentMessageRecallCmd.Flags().Bool("yes", false, "跳过确认提示,直接执行")
sentMessageRecallDetailCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
sentMessageRecallDetailCmd.Flags().String("id", "", "撤回任务 ID (必填),由 recall 命令返回")
sentMessageCmd.AddCommand(sentMessageRecallCmd, sentMessageRecallDetailCmd)
draftCreateCmd.Flags().String("from", "", "发件人邮箱 (必填)")
draftCreateCmd.Flags().String("sender", "", "--from 的别名")
@@ -1913,52 +2078,55 @@ user 对象字段:
},
}
// TODO: auto-reply update 暂时注释,待服务端修复后恢复
// autoReplyUpdateCmd := &cobra.Command{
// Use: "update",
// Short: "更新/设置用户的自动回复配置",
// Long: `更新或设置用户的邮件自动回复配置。所有参数均为必填。
//
// 建议工作流:先通过 auto-reply get 获取当前配置,再传入需要修改的字段值。
//
// 时间格式示例:2026/06/25 16:30:00 +0800
//
// 参数说明(全部必填):
// --enabled 是否启用自动回复 (true/false)
// --startTime 自动回复开始时间
// --endTime 自动回复结束时间
// --scope 回复范围: "contact"(仅联系人) 或 "all"(所有人)
// --content 自动回复内容
//
// 错误说明:
// domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
// Example: ` dws mail auto-reply update --email user@company.com --enabled true --startTime "2026/07/01 09:00:00 +0800" --endTime "2026/07/07 18:00:00 +0800" --scope all --content "出差中,请稍后联系"
// dws mail auto-reply update --email user@company.com --enabled false --startTime "" --endTime "" --scope all --content ""`,
// RunE: func(cmd *cobra.Command, args []string) error {
// if err := validateRequiredFlags(cmd, "email", "enabled", "startTime", "endTime", "scope", "content"); err != nil {
// return err
// }
// toolArgs := map[string]any{
// "email": mustGetFlag(cmd, "email"),
// "enabled": mustGetFlag(cmd, "enabled") == "true",
// "startTime": mustGetFlag(cmd, "startTime"),
// "endTime": mustGetFlag(cmd, "endTime"),
// "scope": mustGetFlag(cmd, "scope"),
// "content": mustGetFlag(cmd, "content"),
// }
// return callMCPTool("update_auto_reply", toolArgs)
// },
// }
autoReplyUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新/设置用户的自动回复配置",
Long: `更新或设置用户的邮件自动回复配置。所有参数均为必填。
建议工作流:先通过 auto-reply get 获取当前配置,再传入需要修改的字段值。
时间格式示例:2026/06/25 16:00:00 +0800
参数说明(全部必填):
--enabled 是否启用自动回复 (true/false)
--start 自动回复开始时间
--end 自动回复结束时间
--scope 回复范围: "contact"(仅联系人) 或 "all"(所有人)
--content 自动回复内容
错误说明:
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
Example: ` dws mail auto-reply update --email user@company.com --enabled true --start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" --scope all --content "出差中,请稍后联系"
dws mail auto-reply update --email user@company.com --enabled false --start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" --scope all --content "已关闭自动回复"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "enabled", "start", "end", "scope", "content"); err != nil {
return err
}
enabledRaw := strings.ToLower(strings.TrimSpace(mustGetFlag(cmd, "enabled")))
if enabledRaw != "true" && enabledRaw != "false" {
return fmt.Errorf("--enabled 必须为 true 或 false")
}
toolArgs := map[string]any{
"email": mustGetFlag(cmd, "email"),
"enabled": enabledRaw == "true",
"startTime": mustGetFlag(cmd, "start"),
"endTime": mustGetFlag(cmd, "end"),
"scope": mustGetFlag(cmd, "scope"),
"content": mustGetFlag(cmd, "content"),
}
return callMCPTool("update_auto_reply", toolArgs)
},
}
autoReplyGetCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
// autoReplyUpdateCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
// autoReplyUpdateCmd.Flags().String("enabled", "", "是否启用自动回复: true/false (必填)")
// autoReplyUpdateCmd.Flags().String("startTime", "", "自动回复开始时间 (必填),格式: 2026/06/25 16:30:00 +0800")
// autoReplyUpdateCmd.Flags().String("endTime", "", "自动回复结束时间 (必填),格式: 2026/06/25 16:30:00 +0800")
// autoReplyUpdateCmd.Flags().String("scope", "", "回复范围: contact(仅联系人)/all(所有人) (必填)")
// autoReplyUpdateCmd.Flags().String("content", "", "自动回复内容 (必填)")
autoReplyUpdateCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
autoReplyUpdateCmd.Flags().String("enabled", "", "是否启用自动回复: true/false (必填)")
autoReplyUpdateCmd.Flags().String("start", "", "自动回复开始时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填)")
autoReplyUpdateCmd.Flags().String("end", "", "自动回复结束时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填)")
autoReplyUpdateCmd.Flags().String("scope", "", "回复范围: contact(仅联系人)/all(所有人) (必填)")
autoReplyUpdateCmd.Flags().String("content", "", "自动回复内容 (必填)")
autoReplyCmd.AddCommand(autoReplyGetCmd) // , autoReplyUpdateCmd)
autoReplyCmd.AddCommand(autoReplyGetCmd, autoReplyUpdateCmd)
// ── rule 收信规则 ────────────────────────────────────
ruleCmd := &cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE}
@@ -2145,7 +2313,143 @@ object 与 operation 合法组合:
ruleCmd.AddCommand(ruleListCmd, ruleCreateCmd, ruleUpdateCmd, ruleDeleteCmd, ruleAdjustCmd)
root.AddCommand(mailboxCmd, messageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd)
// ── allow-list 个人收信白名单 ────────────────────────────────
allowListCmd := &cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE}
allowListListCmd := &cobra.Command{
Use: "list",
Short: "列出个人收信白名单",
Long: `列出当前用户的个人收信白名单地址列表。
返回字段:
entries 白名单地址列表
success 接口调用是否成功
errorCode 错误码(仅失败时存在)
errorMsg 错误信息(仅失败时存在)`,
Example: ` dws mail allow-list list --email user@company.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email"); err != nil {
return err
}
return callMCPTool("list_mailbox_allowlist", map[string]any{
"email": mustGetFlag(cmd, "email"),
})
},
}
allowListAddCmd := &cobra.Command{
Use: "add",
Short: "添加个人收信白名单",
Long: `向个人收信白名单中添加邮件地址或域名。
条目格式:
- 邮件地址:123@domain.com
- 域名:@domain.com(域名前需加 @ 符号)`,
Example: ` dws mail allow-list add --email user@company.com --entries a@b.com,@spam.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "entries"); err != nil {
return err
}
return callMCPTool("add_mailbox_allowlist", map[string]any{
"email": mustGetFlag(cmd, "email"),
"entries": parseRecipients(mustGetFlag(cmd, "entries")),
})
},
}
allowListRemoveCmd := &cobra.Command{
Use: "remove",
Short: "移除个人收信白名单",
Long: `从个人收信白名单中移除邮件地址或域名。
条目格式:
- 邮件地址:123@domain.com
- 域名:@domain.com(域名前需加 @ 符号)`,
Example: ` dws mail allow-list remove --email user@company.com --entries a@b.com,@spam.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "entries"); err != nil {
return err
}
return callMCPTool("remove_mailbox_allowlist", map[string]any{
"email": mustGetFlag(cmd, "email"),
"entries": parseRecipients(mustGetFlag(cmd, "entries")),
})
},
}
allowListListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
allowListAddCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
allowListAddCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
allowListRemoveCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
allowListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
allowListCmd.AddCommand(allowListListCmd, allowListAddCmd, allowListRemoveCmd)
// ── block-list 个人收信黑名单 ────────────────────────────────
blockListCmd := &cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE}
blockListListCmd := &cobra.Command{
Use: "list",
Short: "列出个人收信黑名单",
Long: `列出当前用户的个人收信黑名单地址列表。
返回字段:
entries 黑名单地址列表
success 接口调用是否成功
errorCode 错误码(仅失败时存在)
errorMsg 错误信息(仅失败时存在)`,
Example: ` dws mail block-list list --email user@company.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email"); err != nil {
return err
}
return callMCPTool("list_mailbox_blocklist", map[string]any{
"email": mustGetFlag(cmd, "email"),
})
},
}
blockListAddCmd := &cobra.Command{
Use: "add",
Short: "添加个人收信黑名单",
Long: `向个人收信黑名单中添加邮件地址或域名。
条目格式:
- 邮件地址:123@domain.com
- 域名:@domain.com(域名前需加 @ 符号)`,
Example: ` dws mail block-list add --email user@company.com --entries spam@bad.com,@junk.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "entries"); err != nil {
return err
}
return callMCPTool("add_mailbox_blocklist", map[string]any{
"email": mustGetFlag(cmd, "email"),
"entries": parseRecipients(mustGetFlag(cmd, "entries")),
})
},
}
blockListRemoveCmd := &cobra.Command{
Use: "remove",
Short: "移除个人收信黑名单",
Long: `从个人收信黑名单中移除邮件地址或域名。
条目格式:
- 邮件地址:123@domain.com
- 域名:@domain.com(域名前需加 @ 符号)`,
Example: ` dws mail block-list remove --email user@company.com --entries spam@bad.com,@junk.com`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "email", "entries"); err != nil {
return err
}
return callMCPTool("remove_mailbox_blocklist", map[string]any{
"email": mustGetFlag(cmd, "email"),
"entries": parseRecipients(mustGetFlag(cmd, "entries")),
})
},
}
blockListListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
blockListAddCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
blockListAddCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
blockListRemoveCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
return root
}
+29
View File
@@ -6,8 +6,31 @@ import (
"io"
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
)
// applyGlobalFilter applies the global --jq / --fields output filters to data
// when either is set. Returns handled=true when it wrote the (filtered) output,
// so callers skip their default JSON encoding. The filter helpers live in
// internal/output (the same ones used by `dws api`); the helper Formatter used
// by product commands previously ignored these flags, making them no-ops.
func (f *Formatter) applyGlobalFilter(data any) (handled bool, err error) {
if deps == nil || deps.Caller == nil {
return false, nil
}
jq := strings.TrimSpace(deps.Caller.JQ())
fields := strings.TrimSpace(deps.Caller.Fields())
if jq == "" && fields == "" {
return false, nil
}
format := output.Format(strings.TrimSpace(deps.Caller.Format()))
if format == "" {
format = output.FormatJSON
}
return true, output.WriteFiltered(f.w, format, data, fields, jq)
}
// Formatter provides output formatting compatible with the old Wukong CLI.
type Formatter struct {
w io.Writer
@@ -23,6 +46,9 @@ func NewFormatter() *Formatter {
// 转义为 \u0026、\u003c、\u003e。对于大多数 CLI 输出场景这是安全的默认行为。
// 如果返回值中包含 URL 等不应被转义的内容,请使用 PrintJSONUnescaped。
func (f *Formatter) PrintJSON(data any) error {
if handled, err := f.applyGlobalFilter(data); handled {
return err
}
enc := json.NewEncoder(f.w)
enc.SetIndent("", " ")
return enc.Encode(data)
@@ -38,6 +64,9 @@ func (f *Formatter) PrintJSON(data any) error {
//
// 影响范围:仅在调用方显式选择时生效,不影响全局 PrintJSON 的行为。
func (f *Formatter) PrintJSONUnescaped(data any) error {
if handled, err := f.applyGlobalFilter(data); handled {
return err
}
enc := json.NewEncoder(f.w)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
+1
View File
@@ -23,6 +23,7 @@ func init() {
name string
fn func() *cobra.Command
}{
{"agoal", newAgoalCommand},
{"aisearch", newAisearchCommand},
{"aitable", newAitableCommand},
{"attendance", newAttendanceCommand},
+2
View File
@@ -48,6 +48,8 @@ func newSheetCommand() *cobra.Command {
dws sheet insert-dimension 在指定位置插入行或列
dws sheet delete-dimension 删除指定位置的行或列
dws sheet update-dimension 更新指定范围行/列属性(显隐、行高/列宽)
dws sheet group-dimension 对指定连续行/列创建分组
dws sheet ungroup-dimension 取消指定连续行/列分组
dws sheet media-upload 上传附件到表格
dws sheet write-image 上传图片并写入表格单元格
dws sheet replace 全局查找替换文本
+27
View File
@@ -39,6 +39,8 @@ var batchOpDispatch = map[string]batchOpMapping{
"csv-put": {"set_range_from_csv", BuildCsvPutArgs},
"delete-float-image": {"delete_float_image", BuildDeleteFloatImageArgs},
"update-dimension": {"update_dimension", BuildUpdateDimensionArgs},
"group-dimension": {"group_dimension", BuildGroupDimensionArgs},
"ungroup-dimension": {"ungroup_dimension", BuildUngroupDimensionArgs},
}
// translateBatchOp translates a batch operation from CLI format to MCP format.
@@ -269,6 +271,27 @@ func BuildUpdateDimensionArgs(input map[string]any) map[string]any {
return args
}
// BuildGroupDimensionArgs converts CLI flags to MCP params for group_dimension.
func BuildGroupDimensionArgs(input map[string]any) map[string]any {
groupState := batchStr(input, "group-state", "groupState")
if groupState == "" {
groupState = "expand"
}
return map[string]any{
"sheetId": batchStr(input, "sheet-id"),
"range": batchStr(input, "range"),
"groupState": groupState,
}
}
// BuildUngroupDimensionArgs converts CLI flags to MCP params for ungroup_dimension.
func BuildUngroupDimensionArgs(input map[string]any) map[string]any {
return map[string]any{
"sheetId": batchStr(input, "sheet-id"),
"range": batchStr(input, "range"),
}
}
// resolveCsvContent resolves @filepath and - stdin to CSV text, matching standalone csv-put behavior.
func resolveCsvContent(csvVal string) string {
switch {
@@ -307,8 +330,12 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
支持的 CLI 命令名:
range clear / range update / merge-cells / unmerge-cells / update-dimension
range fill / range copy-to / add-dimension / delete-dimension / move-dimension
group-dimension / ungroup-dimension
set-dropdown / delete-dropdown / csv-put / delete-float-image
注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立
dws sheet group-dimension 命令。
--operations 是 JSON 数组,每项包含:
toolName CLI 命令名(如 "range clear", "range update")
input 该命令的入参(不含 --node),键用 flag 名去掉 --
+3 -3
View File
@@ -172,7 +172,7 @@ position.col 使用列字母表示法(如 "A"、"AA"),不支持数字形
创建成功后返回新图表的完整信息,包含系统分配的 chart-id。`,
Example: ` # 创建柱形图
dws sheet chart create --node NODE_ID --sheet-id SHEET_ID --properties '{
"position": {"row": 12, "col": "A"}, // col 使用字母
"position": {"row": 12, "col": "A"},
"dimensions": {"width": 600, "height": 400},
"chart": {
"type": "column",
@@ -187,7 +187,7 @@ position.col 使用列字母表示法(如 "A"、"AA"),不支持数字形
# 创建饼图
dws sheet chart create --node NODE_ID --sheet-id SHEET_ID --properties '{
"position": {"row": 0, "col": "F"}, // col 使用字母
"position": {"row": 0, "col": "F"},
"dimensions": {"width": 500, "height": 400},
"chart": {
"type": "pie",
@@ -243,7 +243,7 @@ position.col 使用列字母表示法(如 "A"、"AA"),不支持数字形
# 修改后整体回写
dws sheet chart update --node NODE_ID --sheet-id SHEET_ID --chart-id CHART_ID --properties '{
"position": {"row": 12, "col": "A"}, // col 使用字母
"position": {"row": 12, "col": "A"},
"dimensions": {"width": 800, "height": 500},
"chart": {
"type": "line",
+78 -1
View File
@@ -375,6 +375,83 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
updateDimensionCmd.Flags().Bool("hidden", false, "是否隐藏 (true=隐藏, false=显示)")
updateDimensionCmd.Flags().Int("pixel-size", 0, "行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽")
groupDimensionCmd := &cobra.Command{
Use: "group-dimension",
Short: "对指定连续行/列创建分组",
Long: `对钉钉表格指定工作表中的连续整行或整列创建分组。
--range 使用整行/整列范围:
行分组:3:7 或 3
列分组:C:F 或 C
支持在 --range 中携带工作表前缀(如 "Sheet1!3:7" / "Sheet1!C:F"),此时将忽略 --sheet-id。
创建后可通过 sheet info --include groups 回读 rowGroups / columnGroups。
--group-state 支持 expand / fold,默认 expand。`,
Example: ` # 分组第 3~7 行
dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "3:7"
# 分组并折叠 C~F 列
dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "C:F" --group-state fold
# 使用工作表前缀(忽略 --sheet-id)
dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "Sheet1!3:7"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
return err
}
groupState, _ := cmd.Flags().GetString("group-state")
switch groupState {
case "", "expand":
groupState = "expand"
case "fold":
default:
return fmt.Errorf("--group-state 必须为 expand 或 fold,当前值: %s", groupState)
}
return callMCPTool("group_dimension", map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"range": mustGetFlag(cmd, "range"),
"groupState": groupState,
})
},
}
groupDimensionCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
groupDimensionCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
groupDimensionCmd.Flags().String("range", "", `整行/整列范围 (必填),如 "3:7" 或 "C:F"`)
groupDimensionCmd.Flags().String("group-state", "expand", "创建后的分组状态: expand 或 fold")
ungroupDimensionCmd := &cobra.Command{
Use: "ungroup-dimension",
Short: "取消指定连续行/列分组",
Long: `取消钉钉表格指定工作表中的连续整行或整列分组。
--range 使用整行/整列范围:
行分组:3:7 或 3
列分组:C:F 或 C
支持在 --range 中携带工作表前缀(如 "Sheet1!3:7" / "Sheet1!C:F"),此时将忽略 --sheet-id。
取消后可通过 sheet info --include groups 回读 rowGroups / columnGroups。`,
Example: ` # 取消第 3~7 行分组
dws sheet ungroup-dimension --node NODE_ID --sheet-id SHEET_ID --range "3:7"
# 取消 C~F 列分组
dws sheet ungroup-dimension --node NODE_ID --sheet-id SHEET_ID --range "C:F"`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
return err
}
return callMCPTool("ungroup_dimension", map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"range": mustGetFlag(cmd, "range"),
})
},
}
ungroupDimensionCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
ungroupDimensionCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
ungroupDimensionCmd.Flags().String("range", "", `整行/整列范围 (必填),如 "3:7" 或 "C:F"`)
// ── dropdown ──────────────────────────────────────────────────
setDropdownCmd := &cobra.Command{
Use: "set-dropdown",
@@ -499,7 +576,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
return []*cobra.Command{
insertDimensionCmd, moveDimensionCmd, addDimensionCmd,
mergeCellsCmd, unmergeRangeCmd,
deleteDimensionCmd, updateDimensionCmd,
deleteDimensionCmd, updateDimensionCmd, groupDimensionCmd, ungroupDimensionCmd,
setDropdownCmd, getDropdownCmd, deleteDropdownCmd,
}
}
+45 -11
View File
@@ -31,8 +31,14 @@ func runSheetExport(cmd *cobra.Command, _ []string) error {
ctx := context.Background()
// json 模式下进度提示会污染 stdout(PrintInfo/PrintKeyValue 都写 stdout),
// 使得 agent 无法按 JSON 解析。故 json 模式抑制进度、末尾统一输出结果 JSON。
jsonMode := deps.Caller.Format() == "json"
// Step 1: submit export job
deps.Out.PrintInfo("[1/3] 提交表格导出任务 (xlsx)...")
if !jsonMode {
deps.Out.PrintInfo("[1/3] 提交表格导出任务 (xlsx)...")
}
submitText, err := callMCPToolReturnText(ctx, "submit_export_job", map[string]any{
"nodeId": nodeID,
"exportFormat": "xlsx",
@@ -44,10 +50,11 @@ func runSheetExport(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
deps.Out.PrintInfo(fmt.Sprintf("导出任务已提交: jobId=%s", jobID))
// Step 2: progressive backoff polling
deps.Out.PrintInfo("[2/3] 轮询任务状态(渐进式退避,最多 30 次约 5 分钟)...")
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("导出任务已提交: jobId=%s", jobID))
// Step 2: progressive backoff polling
deps.Out.PrintInfo("[2/3] 轮询任务状态(渐进式退避,最多 30 次约 5 分钟)...")
}
downloadURL, err := pollSheetExportJob(ctx, jobID)
if err != nil {
return err
@@ -55,6 +62,13 @@ func runSheetExport(cmd *cobra.Command, _ []string) error {
// No output path: print the downloadUrl and exit
if outputPath == "" {
if jsonMode {
return deps.Out.PrintJSON(map[string]any{
"success": true,
"jobId": jobID,
"downloadUrl": downloadURL,
})
}
deps.Out.PrintKeyValue("jobId", jobID)
deps.Out.PrintKeyValue("downloadUrl", downloadURL)
deps.Out.PrintInfo("导出完成。downloadUrl 具有时效性,请尽快下载。")
@@ -71,10 +85,20 @@ func runSheetExport(cmd *cobra.Command, _ []string) error {
outputPath = filepath.Join(outputPath, filename)
}
deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载 xlsx 到 %s ...", outputPath))
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载 xlsx 到 %s ...", outputPath))
}
if err := httpGetFile(ctx, downloadURL, map[string]string{}, outputPath); err != nil {
return fmt.Errorf("下载 xlsx 失败: %w", err)
}
if jsonMode {
return deps.Out.PrintJSON(map[string]any{
"success": true,
"jobId": jobID,
"outputPath": outputPath,
"downloadUrl": downloadURL,
})
}
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
return nil
}
@@ -124,6 +148,8 @@ func exportPollIntervals() []time.Duration {
// pollExportJob polls query_export_job per the progressive backoff schedule
// until the job completes successfully, fails, or the 30-attempt cap is hit.
func pollSheetExportJob(ctx context.Context, jobID string) (string, error) {
// json 模式下轮询进度也要抑制,否则 [INFO] 行会混进 stdout 破坏纯 JSON 输出。
quiet := deps.Caller.Format() == "json"
intervals := exportPollIntervals()
for i, wait := range intervals {
select {
@@ -136,7 +162,9 @@ func pollSheetExportJob(ctx context.Context, jobID string) (string, error) {
"jobId": jobID,
})
if err != nil {
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 查询失败,将继续轮询: %v", i+1, err))
if !quiet {
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 查询失败,将继续轮询: %v", i+1, err))
}
continue
}
@@ -149,7 +177,9 @@ func pollSheetExportJob(ctx context.Context, jobID string) (string, error) {
normStatus := strings.ToUpper(strings.TrimSpace(status))
switch normStatus {
case "SUCCESS":
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: SUCCESS", i+1))
if !quiet {
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: SUCCESS", i+1))
}
if downloadURL == "" {
return "", fmt.Errorf("任务成功但未返回 downloadUrl")
}
@@ -160,9 +190,13 @@ func pollSheetExportJob(ctx context.Context, jobID string) (string, error) {
}
return "", fmt.Errorf("%s", message)
case "PROCESSING", "RUNNING", "DOING", "PENDING", "":
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: PROCESSING", i+1))
if !quiet {
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: PROCESSING", i+1))
}
default:
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: %s", i+1, status))
if !quiet {
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: %s", i+1, status))
}
}
}
return "", fmt.Errorf("导出任务超时:已轮询 30 次(约 5 分钟)仍未完成,请稍后再试")
@@ -234,7 +268,7 @@ func newExportCmd() *cobra.Command {
- 未指定:仅返回 downloadUrl,链接有时效性请尽快下载
支持范围:
仅支持钉钉在线电子表格(alxs)→ xlsx;
仅支持钉钉在线电子表格(axls)→ xlsx;
若需导出钉钉文字文档,请使用 dingtalkdoc 侧的导出工具。
权限要求:
+40 -12
View File
@@ -57,7 +57,13 @@ func runSheetMediaUpload(cmd *cobra.Command, _ []string) error {
ctx := context.Background()
deps.Out.PrintInfo(fmt.Sprintf("[1/2] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
// json 模式下进度提示会污染 stdout(PrintInfo/PrintKeyValue 都写 stdout),
// 使得 agent 无法按 JSON 解析。故 json 模式抑制进度、末尾统一输出结果 JSON。
jsonMode := deps.Caller.Format() == "json"
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("[1/2] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
}
result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
@@ -91,10 +97,11 @@ func runSheetMediaUpload(cmd *cobra.Command, _ []string) error {
resourceURL, _ = credData["resourceUrl"].(string)
}
deps.Out.PrintKeyValue("resourceId", resourceID)
deps.Out.PrintKeyValue("resourceUrl", resourceURL)
deps.Out.PrintInfo("[2/2] 上传文件到 OSS...")
if !jsonMode {
deps.Out.PrintKeyValue("resourceId", resourceID)
deps.Out.PrintKeyValue("resourceUrl", resourceURL)
deps.Out.PrintInfo("[2/2] 上传文件到 OSS...")
}
ossHeaders := map[string]string{
"Content-Type": mimeType,
@@ -103,6 +110,15 @@ func runSheetMediaUpload(cmd *cobra.Command, _ []string) error {
return err
}
if jsonMode {
return deps.Out.PrintJSON(map[string]any{
"success": true,
"resourceId": resourceID,
"resourceUrl": resourceURL,
"fileName": fileName,
"fileSize": fileSize,
})
}
deps.Out.PrintInfo(fmt.Sprintf("附件已上传: %s (resourceId=%s)", fileName, resourceID))
return nil
}
@@ -166,7 +182,13 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error {
ctx := context.Background()
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
// json 模式下进度提示会污染 stdout(PrintInfo/PrintKeyValue 都写 stdout),
// 使 write_image 的 JSON 响应无法被单独解析。故 json 模式抑制进度。
jsonMode := deps.Caller.Format() == "json"
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
}
result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
@@ -200,10 +222,11 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error {
resourceURL, _ = credData["resourceUrl"].(string)
}
deps.Out.PrintKeyValue("resourceId", resourceID)
deps.Out.PrintKeyValue("resourceUrl", resourceURL)
deps.Out.PrintInfo("[2/3] 上传图片到 OSS...")
if !jsonMode {
deps.Out.PrintKeyValue("resourceId", resourceID)
deps.Out.PrintKeyValue("resourceUrl", resourceURL)
deps.Out.PrintInfo("[2/3] 上传图片到 OSS...")
}
ossHeaders := map[string]string{
"Content-Type": mimeType,
@@ -212,7 +235,9 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error {
return err
}
deps.Out.PrintInfo("[3/3] 写入图片到表格单元格...")
if !jsonMode {
deps.Out.PrintInfo("[3/3] 写入图片到表格单元格...")
}
writeArgs := map[string]any{
"nodeId": nodeID,
@@ -228,10 +253,13 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error {
writeArgs["height"] = h
}
if jsonMode {
// json 模式:write_image 的 JSON 响应就是唯一 stdout 输出。
return callMCPTool("write_image", writeArgs)
}
if err := callMCPTool("write_image", writeArgs); err != nil {
return err
}
deps.Out.PrintInfo(fmt.Sprintf("图片已写入表格: %s → %s (resourceId=%s)", fileName, rangeAddress, resourceID))
return nil
}
+40
View File
@@ -1,6 +1,7 @@
package helpers
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -397,6 +398,9 @@ func newRangeBatchSetStyleCmd() *cobra.Command {
node := mustGetFlag(cmd, "node")
batchPath := mustGetFlag(cmd, "batch")
continueOnErr, _ := cmd.Flags().GetBool("continue-on-error")
jsonMode := deps.Caller.Format() == "json"
ctx := context.Background()
var jsonResults []any
data, err := os.ReadFile(batchPath)
if err != nil {
@@ -457,6 +461,34 @@ func newRangeBatchSetStyleCmd() *cobra.Command {
continue
}
fmt.Fprintf(os.Stderr, "[%d/%d] update_range sheet=%s range=%s\n", i+1, total, item.SheetID, item.Range)
if jsonMode {
text, cerr := callMCPToolReturnText(ctx, "update_range", toolArgs)
entry := map[string]any{"index": i + 1, "sheetId": item.SheetID, "range": item.Range}
if cerr != nil {
entry["ok"] = false
entry["error"] = cerr.Error()
jsonResults = append(jsonResults, entry)
cerr = fmt.Errorf("第 %d/%d 条 update_range 失败: %w", i+1, total, cerr)
fmt.Fprintln(os.Stderr, cerr)
failed++
if firstErr == nil {
firstErr = cerr
}
if !continueOnErr {
break
}
continue
}
var parsed any
if json.Unmarshal([]byte(text), &parsed) == nil {
entry["result"] = parsed
} else {
entry["result"] = text
}
entry["ok"] = true
jsonResults = append(jsonResults, entry)
continue
}
if err := callMCPTool("update_range", toolArgs); err != nil {
err = fmt.Errorf("第 %d/%d 条 update_range 失败: %w", i+1, total, err)
fmt.Fprintln(os.Stderr, err)
@@ -470,6 +502,14 @@ func newRangeBatchSetStyleCmd() *cobra.Command {
}
}
fmt.Fprintf(os.Stderr, "batch-set-style 完成:共 %d 条,失败 %d 条\n", total, failed)
if jsonMode {
_ = deps.Out.PrintJSON(map[string]any{
"total": total,
"failed": failed,
"results": jsonResults,
"success": failed == 0,
})
}
if failed > 0 && !continueOnErr {
return firstErr
}
+8 -5
View File
@@ -580,16 +580,19 @@ ORG 类型授权不会出现在查询结果中。`,
Short: "在知识库中创建节点",
Long: `在指定知识库中创建文档、文件夹或其他类型的节点。
通过 --type 指定节点类型:
通过 --type 指定节点类型(服务端支持以下值,asheet 不被支持):
adoc 在线文档 (默认)
asheet 在线表格
folder 文件夹
axls 在线电子表格
able 多维表
appt 在线演示
adraw 白板/画板
amind 脑图
folder 文件夹
通过 --folder 指定父节点,不传则创建在知识库根目录。`,
Example: ` dws wiki node create --workspace <workspaceId> --name "新文档"
dws wiki node create --workspace <workspaceId> --name "方案目录" --type folder
dws wiki node create --workspace <workspaceId> --name "数据表" --type asheet --folder <parentNodeId>`,
dws wiki node create --workspace <workspaceId> --name "数据表" --type axls --folder <parentNodeId>`,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
if err != nil {
@@ -616,7 +619,7 @@ ORG 类型授权不会出现在查询结果中。`,
}
nodeCreateCmd.Flags().String("workspace", "", "知识库 ID (必填)")
nodeCreateCmd.Flags().String("name", "", "节点名称 (必填)")
nodeCreateCmd.Flags().String("type", "adoc", "节点类型: adoc / asheet / folder / axls")
nodeCreateCmd.Flags().String("type", "adoc", "节点类型: adoc / axls / able / appt / adraw / amind / folder(asheet 不支持)")
nodeCreateCmd.Flags().String("folder", "", "父节点 nodeId (选填,不传则在根目录创建)")
nodeCopyCmd := &cobra.Command{
@@ -0,0 +1,182 @@
// 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 (
"reflect"
"testing"
"github.com/spf13/cobra"
)
func requireWukongSyncCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
t.Helper()
cmd, remaining, err := root.Find(path)
if err != nil {
t.Fatalf("%s: command not found: %v", path, err)
}
if len(remaining) != 0 {
t.Fatalf("%s: unresolved path suffix %v (resolved to %q)", path, remaining, cmd.CommandPath())
}
return cmd
}
func requireWukongSyncFlags(t *testing.T, cmd *cobra.Command, names ...string) {
t.Helper()
for _, name := range names {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("%s: missing flag --%s", cmd.CommandPath(), name)
}
}
}
func TestWukongSyncMailCommands(t *testing.T) {
root := newMailCommand()
cases := []struct {
path []string
flags []string
}{
{[]string{"mailbox", "profile"}, []string{"email"}},
{[]string{"message", "batch-get"}, []string{"email", "ids"}},
{[]string{"sent-message", "recall"}, []string{"email", "id", "subject", "yes"}},
{[]string{"sent-message", "recall-detail"}, []string{"email", "id"}},
{[]string{"auto-reply", "update"}, []string{"email", "enabled", "start", "end", "scope", "content"}},
{[]string{"allow-list", "list"}, []string{"email"}},
{[]string{"allow-list", "add"}, []string{"email", "entries"}},
{[]string{"allow-list", "remove"}, []string{"email", "entries"}},
{[]string{"block-list", "list"}, []string{"email"}},
{[]string{"block-list", "add"}, []string{"email", "entries"}},
{[]string{"block-list", "remove"}, []string{"email", "entries"}},
}
for _, tc := range cases {
cmd := requireWukongSyncCommand(t, root, tc.path...)
requireWukongSyncFlags(t, cmd, tc.flags...)
}
}
func TestWukongSyncChatCommands(t *testing.T) {
root := newChatCommand()
cases := []struct {
path []string
flags []string
}{
{[]string{"group", "notice", "create"}, []string{"group", "content", "sticky", "send-ding", "run-at"}},
{[]string{"group", "notice", "edit"}, []string{"group", "notice-id", "content", "sticky", "send-ding"}},
{[]string{"group", "notice", "get"}, []string{"group", "notice-id"}},
{[]string{"group", "notice", "list"}, []string{"group", "limit", "cursor", "scheduled"}},
{[]string{"group", "share-invite"}, []string{"source", "target", "receiver", "expires-seconds", "uuid"}},
{[]string{"text", "translate"}, []string{"query", "to"}},
{[]string{"category", "create-smart"}, []string{"name", "keywords", "members"}},
{[]string{"message", "list-emotion-replies"}, []string{"msg-ids"}},
}
for _, tc := range cases {
cmd := requireWukongSyncCommand(t, root, tc.path...)
requireWukongSyncFlags(t, cmd, tc.flags...)
}
}
func TestWukongSyncDocCommands(t *testing.T) {
root := newDocCommand()
importCmd := requireWukongSyncCommand(t, root, "import")
requireWukongSyncFlags(t, importCmd, "file", "folder", "workspace", "name", "folder-id", "workspace-id")
importGetCmd := requireWukongSyncCommand(t, root, "import", "get")
requireWukongSyncFlags(t, importGetCmd, "task-id")
}
func TestWukongSyncSheetCommands(t *testing.T) {
root := newSheetCommand()
groupCmd := requireWukongSyncCommand(t, root, "group-dimension")
requireWukongSyncFlags(t, groupCmd, "node", "sheet-id", "range", "group-state")
ungroupCmd := requireWukongSyncCommand(t, root, "ungroup-dimension")
requireWukongSyncFlags(t, ungroupCmd, "node", "sheet-id", "range")
}
func TestWukongSyncSheetBatchDimensionGroupMapping(t *testing.T) {
group, err := translateBatchOp(map[string]any{
"toolName": "group-dimension",
"input": map[string]any{
"sheet-id": "Sheet1",
"range": "3:7",
"group-state": "fold",
},
})
if err != nil {
t.Fatalf("group-dimension mapping returned error: %v", err)
}
wantGroup := map[string]any{
"toolName": "group_dimension",
"input": map[string]any{
"sheetId": "Sheet1",
"range": "3:7",
"groupState": "fold",
},
}
if !reflect.DeepEqual(group, wantGroup) {
t.Fatalf("group-dimension mapping mismatch:\n got: %#v\nwant: %#v", group, wantGroup)
}
ungroup, err := translateBatchOp(map[string]any{
"toolName": "ungroup-dimension",
"input": map[string]any{
"sheet-id": "Sheet1",
"range": "C:F",
},
})
if err != nil {
t.Fatalf("ungroup-dimension mapping returned error: %v", err)
}
wantUngroup := map[string]any{
"toolName": "ungroup_dimension",
"input": map[string]any{
"sheetId": "Sheet1",
"range": "C:F",
},
}
if !reflect.DeepEqual(ungroup, wantUngroup) {
t.Fatalf("ungroup-dimension mapping mismatch:\n got: %#v\nwant: %#v", ungroup, wantUngroup)
}
}
func TestWukongSyncAgoalCommands(t *testing.T) {
root := newAgoalCommand()
cases := []struct {
path []string
flags []string
}{
{[]string{"strategy", "list"}, []string{"scope-type", "scope-id", "request-id"}},
{[]string{"strategy", "detail"}, []string{"profile-id", "request-id"}},
{[]string{"strategy", "update"}, []string{"profile-id", "content", "request-id"}},
{[]string{"contract", "list"}, []string{"scope-type", "scope-id", "request-id"}},
{[]string{"contract", "fields"}, []string{"request-id"}},
{[]string{"contract", "detail"}, []string{"contract-id", "request-id"}},
{[]string{"contract", "update"}, []string{"contract-id", "dimensions", "audit-config", "objective-template", "request-id"}},
{[]string{"scorecard", "detail"}, []string{"selected-time", "dept-id", "request-id"}},
{[]string{"scorecard", "entity-detail"}, []string{"sc-id", "entity-id", "request-id"}},
{[]string{"scorecard", "update"}, []string{"dept-id", "selected-time", "id", "tracking-period-type", "content", "request-id"}},
{[]string{"user", "rules"}, []string{"user-id", "request-id"}},
{[]string{"user", "objectives"}, []string{"user-id", "rule-id", "period-ids", "request-id"}},
{[]string{"report", "list-statistics"}, []string{"keyword", "request-id"}},
{[]string{"report", "submit-detail"}, []string{"template-id", "submit-state", "query-date", "page", "page-size", "keyword", "request-id"}},
{[]string{"obj-template", "list"}, []string{"keyword", "page", "page-size", "request-id"}},
{[]string{"obj-template", "create-or-update"}, []string{"template-id", "title", "objective-weight", "dimension-weight", "compute-by-weight", "dimensions", "request-id"}},
}
for _, tc := range cases {
cmd := requireWukongSyncCommand(t, root, tc.path...)
requireWukongSyncFlags(t, cmd, tc.flags...)
}
}
+26
View File
@@ -0,0 +1,26 @@
// 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.
//go:build !darwin
package keychain
func platformDiagnose() Diagnostic {
return Diagnostic{
OK: true,
Message: "当前平台使用本地加密凭据后端",
Detail: map[string]string{
"service": Service,
},
}
}
+15
View File
@@ -63,3 +63,18 @@ func fileDEK(service string) ([]byte, error) {
return key, nil
}
func fileDEKReadOnly(service string) ([]byte, error) {
keyPath := filepath.Join(StorageDir(service), "dek")
key, err := os.ReadFile(keyPath)
if err == nil && len(key) == dekBytes {
return key, nil
}
if err != nil {
if os.IsNotExist(err) {
return nil, ErrDEKMissing
}
return nil, fmt.Errorf("read dek: %w", err)
}
return nil, fmt.Errorf("read dek: %w", ErrDEKMissing)
}
+65
View File
@@ -17,6 +17,8 @@
// - Windows: DPAPI + Registry storage
package keychain
import "errors"
const (
// Service is the unified keychain service name for all secrets.
Service = "dws-cli"
@@ -40,6 +42,11 @@ const (
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
)
// ErrDEKMissing means encrypted local data may exist, but the Data Encryption
// Key needed to decrypt it is missing. Read paths must not create a new DEK,
// because a fresh key cannot decrypt existing ciphertext.
var ErrDEKMissing = errors.New("dek missing")
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
type KeychainAccess interface {
Get(service, account string) (string, error)
@@ -47,6 +54,64 @@ type KeychainAccess interface {
Remove(service, account string) error
}
// Diagnostic is a read-only health report for the platform keychain backend.
// It never mutates credentials, DEKs, or OS keychain settings.
type Diagnostic struct {
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
Detail map[string]string `json:"detail,omitempty"`
}
// UnavailableError marks failures where the platform keychain itself could
// not be reached, unlocked, or created. Callers can surface a diagnostic
// instead of treating the result as a normal missing credential.
type UnavailableError struct {
Op string
Err error
}
func NewUnavailableError(op string, err error) error {
return &UnavailableError{Op: op, Err: err}
}
func (e *UnavailableError) Error() string {
if e == nil {
return ""
}
if e.Op == "" {
if e.Err != nil {
return e.Err.Error()
}
return "keychain unavailable"
}
if e.Err == nil {
return e.Op + ": keychain unavailable"
}
return e.Op + ": " + e.Err.Error()
}
func (e *UnavailableError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func IsUnavailable(err error) bool {
var unavailable *UnavailableError
return errors.As(err, &unavailable)
}
func IsDEKMissing(err error) bool {
return errors.Is(err, ErrDEKMissing)
}
func Diagnose() Diagnostic {
return platformDiagnose()
}
// Get retrieves a value from the keychain.
// Returns empty string and nil error if the entry does not exist.
func Get(service, account string) (string, error) {
+173 -9
View File
@@ -21,10 +21,14 @@ import (
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/google/uuid"
@@ -59,15 +63,169 @@ func safeFileName(account string) string {
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
}
var readDefaultKeychain = func() ([]byte, error) {
return exec.Command("security", "default-keychain", "-d", "user").Output()
}
var (
keyringGet = keyring.Get
keyringSet = keyring.Set
)
func defaultKeychainPathFromSecurityOutput(output []byte) string {
value := strings.TrimSpace(string(output))
if value == "" {
return ""
}
if unquoted, err := strconv.Unquote(value); err == nil {
return unquoted
}
return strings.Trim(value, `"`)
}
func checkDefaultKeychainAvailable() error {
output, err := readDefaultKeychain()
if err != nil {
return nil
}
path := defaultKeychainPathFromSecurityOutput(output)
if path == "" {
return nil
}
if _, err := os.Stat(path); err != nil && os.IsNotExist(err) {
return NewUnavailableError("read macOS default Keychain", fmt.Errorf("default keychain %q does not exist", path))
}
return nil
}
func platformDiagnose() Diagnostic {
detail := map[string]string{
"platform": "darwin",
"service": Service,
"account": "dek",
}
if os.Getenv(DisableKeychainEnv) != "" {
detail["mode"] = "file_dek"
detail["storage_dir"] = StorageDir(Service)
return Diagnostic{
OK: true,
Message: "macOS Keychain 已禁用, 当前使用 file-DEK 测试模式",
Detail: detail,
}
}
output, err := readDefaultKeychain()
if err != nil {
detail["error"] = err.Error()
return Diagnostic{
OK: false,
Reason: "keychain_check_failed",
Message: "无法读取 macOS 默认钥匙串配置",
Hint: "检查 /usr/bin/security 是否可用, 并确认当前用户钥匙串配置正常。",
Detail: detail,
}
}
path := defaultKeychainPathFromSecurityOutput(output)
if path != "" {
detail["default_keychain"] = path
}
if path == "" {
return Diagnostic{
OK: false,
Reason: "keychain_unavailable",
Message: "macOS 默认钥匙串未配置",
Hint: "恢复默认钥匙串后重试;测试环境可设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
Detail: detail,
}
}
if _, err := os.Stat(path); err != nil && os.IsNotExist(err) {
return Diagnostic{
OK: false,
Reason: "keychain_unavailable",
Message: "macOS 默认钥匙串不存在",
Hint: "恢复默认钥匙串后重试;测试环境可设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
Detail: detail,
}
} else if err != nil {
detail["error"] = err.Error()
return Diagnostic{
OK: false,
Reason: "keychain_check_failed",
Message: "无法访问 macOS 默认钥匙串",
Hint: "检查默认钥匙串路径权限与挂载状态。",
Detail: detail,
}
}
return Diagnostic{
OK: true,
Message: "macOS 默认钥匙串可用",
Detail: detail,
}
}
// getDEK retrieves or generates the Data Encryption Key.
// When DWS_DISABLE_KEYCHAIN=1 (set in sandboxed runtimes like Codex App
// where Keychain APIs are blocked), falls back to a file-based DEK
// identical to the Linux scheme. See DisableKeychainEnv docs for the
// security tradeoff.
func getDEK(service string) ([]byte, error) {
return getOrCreateDEK(service)
}
func getDEKReadOnly(service string) ([]byte, error) {
if os.Getenv(DisableKeychainEnv) != "" {
return fileDEKReadOnly(service)
}
if err := checkDefaultKeychainAvailable(); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
defer cancel()
type result struct {
key []byte
err error
}
resCh := make(chan result, 1)
go func() {
defer func() { recover() }()
encodedKey, err := keyringGet(service, "dek")
if err == nil {
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
if decodeErr == nil && len(key) == dekBytes {
resCh <- result{key: key, err: nil}
return
}
resCh <- result{key: nil, err: fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing)}
return
}
if errors.Is(err, keyring.ErrNotFound) {
resCh <- result{key: nil, err: fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing)}
return
}
resCh <- result{key: nil, err: NewUnavailableError("read DEK from macOS Keychain", err)}
}()
select {
case res := <-resCh:
return res.key, res.err
case <-ctx.Done():
return nil, NewUnavailableError("read DEK from macOS Keychain", ctx.Err())
}
}
func getOrCreateDEK(service string) ([]byte, error) {
if os.Getenv(DisableKeychainEnv) != "" {
return fileDEK(service)
}
if err := checkDefaultKeychainAvailable(); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
defer cancel()
@@ -82,13 +240,16 @@ func getDEK(service string) ([]byte, error) {
defer func() { recover() }()
// Try to get existing DEK from system Keychain
encodedKey, err := keyring.Get(service, "dek")
encodedKey, err := keyringGet(service, "dek")
if err == nil {
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
if decodeErr == nil && len(key) == dekBytes {
resCh <- result{key: key, err: nil}
return
}
} else if !errors.Is(err, keyring.ErrNotFound) {
resCh <- result{key: nil, err: NewUnavailableError("read DEK from macOS Keychain", err)}
return
}
// Generate new DEK if not found or invalid
@@ -100,15 +261,18 @@ func getDEK(service string) ([]byte, error) {
// Store in system Keychain
encodedKey = base64.StdEncoding.EncodeToString(key)
setErr := keyring.Set(service, "dek", encodedKey)
resCh <- result{key: key, err: setErr}
if setErr := keyringSet(service, "dek", encodedKey); setErr != nil {
resCh <- result{key: nil, err: NewUnavailableError("store DEK in macOS Keychain", setErr)}
return
}
resCh <- result{key: key, err: nil}
}()
select {
case res := <-resCh:
return res.key, res.err
case <-ctx.Done():
return nil, ctx.Err()
return nil, NewUnavailableError("read DEK from macOS Keychain", ctx.Err())
}
}
@@ -157,10 +321,6 @@ func decryptData(data []byte, key []byte) (string, error) {
}
func platformGet(service, account string) (string, error) {
key, err := getDEK(service)
if err != nil {
return "", err
}
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
if os.IsNotExist(err) {
@@ -168,6 +328,10 @@ func platformGet(service, account string) (string, error) {
}
return "", err
}
key, err := getDEKReadOnly(service)
if err != nil {
return "", err
}
plaintext, err := decryptData(data, key)
if err != nil {
return "", err
@@ -176,7 +340,7 @@ func platformGet(service, account string) (string, error) {
}
func platformSet(service, account, data string) error {
key, err := getDEK(service)
key, err := getOrCreateDEK(service)
if err != nil {
return err
}
+117
View File
@@ -16,11 +16,128 @@
package keychain
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
keyringpkg "github.com/zalando/go-keyring"
)
func TestDefaultKeychainPathFromSecurityOutput(t *testing.T) {
got := defaultKeychainPathFromSecurityOutput([]byte("\"/Users/me/Library/Keychains/login.keychain-db\"\n"))
if got != "/Users/me/Library/Keychains/login.keychain-db" {
t.Fatalf("path = %q", got)
}
}
func TestCheckDefaultKeychainAvailableReportsMissingPath(t *testing.T) {
missingPath := filepath.Join(t.TempDir(), "missing.keychain-db")
prev := readDefaultKeychain
readDefaultKeychain = func() ([]byte, error) {
return []byte("\"" + missingPath + "\"\n"), nil
}
t.Cleanup(func() {
readDefaultKeychain = prev
})
err := checkDefaultKeychainAvailable()
if !IsUnavailable(err) {
t.Fatalf("error = %v, want unavailable", err)
}
if !strings.Contains(err.Error(), missingPath) {
t.Fatalf("error = %v, want missing keychain path", err)
}
}
func TestGetMissingAccountDoesNotReadMacOSDEK(t *testing.T) {
t.Setenv(DisableKeychainEnv, "")
keychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
if err := os.WriteFile(keychainPath, nil, 0600); err != nil {
t.Fatalf("WriteFile(default keychain) error = %v", err)
}
prevReadDefault := readDefaultKeychain
prevGet := keyringGet
prevSet := keyringSet
readDefaultKeychain = func() ([]byte, error) {
return []byte("\"" + keychainPath + "\"\n"), nil
}
keyringGet = func(service, account string) (string, error) {
t.Fatalf("keyring.Get(%q, %q) called for missing account", service, account)
return "", nil
}
keyringSet = func(service, account, value string) error {
t.Fatalf("keyring.Set(%q, %q) called for missing account", service, account)
return nil
}
t.Cleanup(func() {
readDefaultKeychain = prevReadDefault
keyringGet = prevGet
keyringSet = prevSet
})
got, err := Get("test-missing-account", "auth-token")
if err != nil {
t.Fatalf("Get() error = %v, want nil", err)
}
if got != "" {
t.Fatalf("Get() = %q, want empty string", got)
}
}
func TestGetWithMissingMacOSDEKDoesNotCreateDEK(t *testing.T) {
t.Setenv(DisableKeychainEnv, "")
keychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
if err := os.WriteFile(keychainPath, nil, 0600); err != nil {
t.Fatalf("WriteFile(default keychain) error = %v", err)
}
prevReadDefault := readDefaultKeychain
prevGet := keyringGet
prevSet := keyringSet
readDefaultKeychain = func() ([]byte, error) {
return []byte("\"" + keychainPath + "\"\n"), nil
}
keyringGet = func(service, account string) (string, error) {
if account != "dek" {
t.Fatalf("keyring.Get account = %q, want dek", account)
}
return "", keyringpkg.ErrNotFound
}
setCalls := 0
keyringSet = func(service, account, value string) error {
setCalls++
return errors.New("keyring.Set should not be called by Get")
}
t.Cleanup(func() {
readDefaultKeychain = prevReadDefault
keyringGet = prevGet
keyringSet = prevSet
})
service := "test-missing-dek"
account := "auth-token"
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), []byte("ciphertext"), 0600); err != nil {
t.Fatalf("WriteFile(ciphertext) error = %v", err)
}
_, err := Get(service, account)
if !IsDEKMissing(err) {
t.Fatalf("Get() error = %v, want dek missing", err)
}
if setCalls != 0 {
t.Fatalf("keyring.Set calls = %d, want 0", setCalls)
}
}
// TestDisableKeychainFallback verifies that setting DWS_DISABLE_KEYCHAIN
// routes the DEK to a local file (same scheme as Linux) and the full
// Set/Get/Remove cycle works without touching the system Keychain.
+8 -4
View File
@@ -32,6 +32,10 @@ func getDEK(service string) ([]byte, error) {
return fileDEK(service)
}
func getDEKReadOnly(service string) ([]byte, error) {
return fileDEKReadOnly(service)
}
const (
dekBytes = 32 // DEK = Data Encryption Key (AES-256)
ivBytes = 12
@@ -106,10 +110,6 @@ func decryptData(data []byte, key []byte) (string, error) {
}
func platformGet(service, account string) (string, error) {
key, err := getDEK(service)
if err != nil {
return "", err
}
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
if os.IsNotExist(err) {
@@ -117,6 +117,10 @@ func platformGet(service, account string) (string, error) {
}
return "", err
}
key, err := getDEKReadOnly(service)
if err != nil {
return "", err
}
plaintext, err := decryptData(data, key)
if err != nil {
return "", err
+29
View File
@@ -19,6 +19,18 @@ import (
"testing"
)
func TestMain(m *testing.M) {
dir, err := os.MkdirTemp("", "dws-keychain-test-*")
if err != nil {
panic(err)
}
_ = os.Setenv(StorageDirEnv, dir)
_ = os.Setenv(DisableKeychainEnv, "1")
code := m.Run()
_ = os.RemoveAll(dir)
os.Exit(code)
}
func TestKeychainBasicOperations(t *testing.T) {
t.Parallel()
@@ -87,6 +99,23 @@ func TestKeychainNonExistentAccount(t *testing.T) {
}
}
func TestGetNonExistentAccountDoesNotCreateFileDEK(t *testing.T) {
service := "test-service-readonly-" + t.Name()
account := "nonexistent-account"
dekPath := filepath.Join(StorageDir(service), "dek")
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() error = %v, want nil", err)
}
if got != "" {
t.Fatalf("Get() = %q, want empty string", got)
}
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
t.Fatalf("Get() created DEK at %s; stat error = %v", dekPath, err)
}
}
func TestKeychainOverwrite(t *testing.T) {
t.Parallel()
+8 -8
View File
@@ -244,12 +244,12 @@ func newChmodCommand(c edition.ToolCaller) *cobra.Command {
Long: `授予指定 scope 的操作权限。
scope 格式: <product>.<entity>:<permission>
例: aitable.record:read chat.group:write calendar.event:read
例: aitable.record:query chat.message:list calendar.event:get
grantType 规则:
once 一次性,执行一次后自动失效
session 当前会话有效(默认),需要 --session-id
permanent 永久有效
session 当前会话有效,需要 --session-id
permanent 永久有效(默认)
批量授权:
dws pat chmod 支持一次传多个 scope 直接批量授予。
@@ -271,13 +271,13 @@ agentCode 配置:
}
return cobra.MinimumNArgs(1)(cmd, args)
},
Example: ` dws pat chmod aitable.record:read --grant-type session --session-id session-xxx
Example: ` dws pat chmod aitable.record:query --grant-type session --session-id session-xxx
dws pat chmod chat.message:list --grant-type once
dws pat chmod aitable.record:read aitable.record:write --grant-type permanent --yes
dws pat chmod aitable.record:query aitable.record:create --grant-type permanent --yes
dws pat chmod --product calendar --product aitable --grant-type once --dry-run --format json
dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx --yes
dws pat chmod --products calendar,aitable --grant-type permanent --yes
dws pat chmod --domain calendar --domain chat --grant-type once --yes
dws pat chmod --recommend --grant-type session --session-id session-xxx --yes`,
dws pat chmod --recommend --yes`,
RunE: func(cmd *cobra.Command, args []string) error {
flagVal, _ := cmd.Flags().GetString("agentCode")
agentCode, err := resolveAgentCode(flagVal)
@@ -404,7 +404,7 @@ agentCode 配置:
chmodCmd.Flags().String("agentCode", "",
"Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先;未传则由服务端默认兜底)")
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
chmodCmd.Flags().String("grant-type", grantTypePermanent, "授权策略: once|session|permanent")
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价;执行批量授权需 --yes")
chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔;执行批量授权需 --yes")
+30 -4
View File
@@ -67,6 +67,8 @@ func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string,
func (f *fakeToolCaller) Format() string { return "json" }
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
func (f *fakeToolCaller) Fields() string { return "" }
func (f *fakeToolCaller) JQ() string { return "" }
type recordedToolCall struct {
tool string
@@ -94,6 +96,8 @@ func (f *fallbackToolCaller) CallTool(_ context.Context, _ string, toolName stri
func (f *fallbackToolCaller) Format() string { return "json" }
func (f *fallbackToolCaller) DryRun() bool { return false }
func (f *fallbackToolCaller) Fields() string { return "" }
func (f *fallbackToolCaller) JQ() string { return "" }
type fallbackErrorToolCaller struct {
calls []recordedToolCall
@@ -198,6 +202,8 @@ func (f *fallbackPATContractErrorToolCaller) CallTool(_ context.Context, _ strin
func (f *fallbackPATContractErrorToolCaller) Format() string { return "json" }
func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
func (f *fallbackPATContractErrorToolCaller) Fields() string { return "" }
func (f *fallbackPATContractErrorToolCaller) JQ() string { return "" }
type sequenceToolCaller struct {
calls []recordedToolCall
@@ -227,6 +233,8 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri
func (s *sequenceToolCaller) Format() string { return "json" }
func (s *sequenceToolCaller) DryRun() bool { return s.dryRun }
func (s *sequenceToolCaller) Fields() string { return "" }
func (s *sequenceToolCaller) JQ() string { return "" }
func stringSliceArgEqual(got any, want []string) bool {
gotSlice, ok := got.([]string)
@@ -372,9 +380,9 @@ func TestPATHelpDocumentsBatchAuthorization(t *testing.T) {
"--dry-run 只返回授权计划",
"执行批量授权必须显式",
"由服务端默认兜底",
"aitable.record:read aitable.record:write --grant-type permanent --yes",
"aitable.record:query aitable.record:create --grant-type permanent --yes",
"dws pat chmod --products calendar,aitable",
"dws pat chmod --recommend --grant-type session",
"dws pat chmod --recommend --yes",
} {
if !strings.Contains(chmodHelp, want) {
t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp)
@@ -478,6 +486,7 @@ func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) {
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
}}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "session")
_ = cmd.Flags().Set("products", "calendar")
_ = cmd.Flags().Set("session-id", "session-123")
setBatchYesForTest(t, cmd)
@@ -843,7 +852,7 @@ func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) {
}
}
func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
func TestChmod_productsSessionDryRunUsesSessionIDFromEnv(t *testing.T) {
t.Setenv(agentCodeEnv, "qoderwork")
t.Setenv(sessionIDEnvDWS, "env-session-123")
fake := &sequenceToolCaller{
@@ -853,6 +862,7 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
},
}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "session")
_ = cmd.Flags().Set("products", "calendar")
if err := cmd.RunE(cmd, nil); err != nil {
@@ -1056,6 +1066,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "session")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -1081,6 +1092,7 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "session")
_ = cmd.Flags().Set("session-id", "flag-session")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
@@ -1108,7 +1120,6 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T)
`{"success":true,"data":{"grantedScopes":["recommended.scope:read"]}}`,
}}
cmd := newChmodCommand(fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("recommend", "true")
setBatchYesForTest(t, cmd)
@@ -1125,9 +1136,15 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T)
if got := fake.calls[0].args["recommend"]; got != true {
t.Fatalf("recommend = %#v, want true", got)
}
if got := fake.calls[0].args["grantType"]; got != grantTypePermanent {
t.Fatalf("plan grantType = %#v, want %q", got, grantTypePermanent)
}
if fake.calls[1].tool != patBatchGrantToolName {
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
}
if got := fake.calls[1].args["grantType"]; got != grantTypePermanent {
t.Fatalf("grant grantType = %#v, want %q", got, grantTypePermanent)
}
}
func TestLoginRecommendAuthorizationSelectorReplansBySelectedProducts(t *testing.T) {
@@ -2020,3 +2037,12 @@ func TestResolveAgentCodeFromEnv(t *testing.T) {
code, src)
}
}
func (f *fallbackErrorToolCaller) Fields() string { return "" }
func (f *fallbackErrorToolCaller) JQ() string { return "" }
func (f *fallbackSchemaMismatchToolCaller) Fields() string { return "" }
func (f *fallbackSchemaMismatchToolCaller) JQ() string { return "" }
func (f *fallbackPermissionDeniedToolCaller) Fields() string { return "" }
func (f *fallbackPermissionDeniedToolCaller) JQ() string { return "" }
func (f *fallbackPATErrorToolCaller) Fields() string { return "" }
func (f *fallbackPATErrorToolCaller) JQ() string { return "" }
+1
View File
@@ -5,6 +5,7 @@ package syncdata
func StaticServers() []ServerInfo {
return []ServerInfo{
{ID: "agoal", Name: "Agoal", Endpoint: "https://mcp-gw.dingtalk.com/server/1db49ea94ffe74c25a7079a68b4df6629c79130fb9095d1b499f507015d847a9", Prefixes: []string{"agoal"}},
{ID: "aisearch", Name: "AI 搜问", Endpoint: "https://mcp-gw.dingtalk.com/server/ai-search", Prefixes: []string{"aisearch", "enterprise"}},
{ID: "aitable", Name: "AI 多维表", Endpoint: "https://mcp-gw.dingtalk.com/server/5f0d121611f14e878f7d42c3e32bf6c4a790d433066adae38c062a657c397047", Prefixes: []string{"table", "record", "field", "base", "attachment", "view", "dashboard", "chart", "export", "import"}},
{ID: "aitable-helper", Name: "AI 多维表(辅助)", Endpoint: "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95", Prefixes: []string{"form", "share_form"}},
+1
View File
@@ -5,6 +5,7 @@ package syncdata
func CmdToProduct() map[string]string {
return map[string]string{
"agoal": "agoal",
"aisearch": "aisearch",
"aitable": "aitable",
"attendance": "attendance",
+90
View File
@@ -0,0 +1,90 @@
// 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 cmdutil
import (
"log/slog"
"github.com/spf13/cobra"
)
// MergeHardcodedLeaves grafts leaves from hardcodedRoot onto dynamicRoot when
// the same-named path does not already exist. Groups recurse. On leaf
// conflicts, the dynamic side wins by default because the runtime envelope is
// authoritative; hardcoded commands are retained as fallback for paths the
// envelope does not declare.
//
// A hardcoded leaf or group can opt into replacement by carrying a strictly
// higher OverridePriority than the dynamic command at the same path.
//
// MergeHardcodedLeaves mutates dynamicRoot in place and returns it. Grafted
// commands are detached from hardcodedRoot so Cobra parent pointers remain
// correct.
func MergeHardcodedLeaves(dynamicRoot, hardcodedRoot *cobra.Command) *cobra.Command {
if dynamicRoot == nil || hardcodedRoot == nil {
return dynamicRoot
}
children := append([]*cobra.Command(nil), hardcodedRoot.Commands()...)
for _, hc := range children {
dyn := findChildByName(dynamicRoot, hc.Name())
switch {
case dyn == nil:
hardcodedRoot.RemoveCommand(hc)
dynamicRoot.AddCommand(hc)
case IsLeafCmd(hc) && IsLeafCmd(dyn):
if OverridePriority(hc) > OverridePriority(dyn) {
hardcodedRoot.RemoveCommand(hc)
dynamicRoot.RemoveCommand(dyn)
dynamicRoot.AddCommand(hc)
}
case !IsLeafCmd(hc) && !IsLeafCmd(dyn) && OverridePriority(hc) > OverridePriority(dyn):
hardcodedRoot.RemoveCommand(hc)
dynamicRoot.RemoveCommand(dyn)
dynamicRoot.AddCommand(hc)
case !IsLeafCmd(hc) && !IsLeafCmd(dyn):
MergeHardcodedLeaves(dyn, hc)
case IsLeafCmd(dyn) && !IsLeafCmd(hc) && OverridePriority(hc) > OverridePriority(dyn):
hardcodedRoot.RemoveCommand(hc)
dynamicRoot.RemoveCommand(dyn)
dynamicRoot.AddCommand(hc)
default:
slog.Warn("overlay: shape mismatch, keeping dynamic",
"name", hc.Name(),
"dynamicIsLeaf", IsLeafCmd(dyn),
"hardcodedIsLeaf", IsLeafCmd(hc))
}
}
return dynamicRoot
}
// IsLeafCmd reports whether cmd has no subcommands.
func IsLeafCmd(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
return !cmd.HasSubCommands()
}
func findChildByName(parent *cobra.Command, name string) *cobra.Command {
if parent == nil {
return nil
}
for _, child := range parent.Commands() {
if child.Name() == name {
return child
}
}
return nil
}
+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 cmdutil
import (
"testing"
"github.com/spf13/cobra"
)
func newGroup(name string, children ...*cobra.Command) *cobra.Command {
cmd := &cobra.Command{Use: name}
cmd.AddCommand(children...)
return cmd
}
func newLeaf(name, tag string) *cobra.Command {
return &cobra.Command{Use: name, Short: tag}
}
func TestMergeHardcodedLeavesNilInputs(t *testing.T) {
t.Parallel()
if got := MergeHardcodedLeaves(nil, nil); got != nil {
t.Fatalf("expected nil, got %v", got)
}
dyn := newGroup("root")
if got := MergeHardcodedLeaves(dyn, nil); got != dyn {
t.Fatal("expected dynamic root to be returned unchanged")
}
hc := newGroup("root")
if got := MergeHardcodedLeaves(nil, hc); got != nil {
t.Fatal("expected nil when dynamic root is nil")
}
}
func TestMergeHardcodedLeavesGraftsUnknownLeaf(t *testing.T) {
t.Parallel()
dyn := newGroup("root", newLeaf("kept", "dynamic"))
hc := newGroup("root", newLeaf("extra", "hardcoded"))
MergeHardcodedLeaves(dyn, hc)
got := findChildByName(dyn, "extra")
if got == nil {
t.Fatal("expected extra leaf to be grafted")
}
if got.Short != "hardcoded" {
t.Fatalf("extra.Short = %q, want %q", got.Short, "hardcoded")
}
if findChildByName(hc, "extra") != nil {
t.Fatal("expected extra leaf to be detached from hardcoded root")
}
if got.Parent() != dyn {
t.Fatalf("grafted leaf parent = %v, want %v", got.Parent(), dyn)
}
}
func TestMergeHardcodedLeavesDynamicLeafWinsByDefault(t *testing.T) {
t.Parallel()
dyn := newGroup("root", newLeaf("shared", "dynamic"))
hc := newGroup("root", newLeaf("shared", "hardcoded"))
MergeHardcodedLeaves(dyn, hc)
got := findChildByName(dyn, "shared")
if got == nil {
t.Fatal("expected shared leaf to remain")
}
if got.Short != "dynamic" {
t.Fatalf("shared.Short = %q, want dynamic", got.Short)
}
if findChildByName(hc, "shared") == nil {
t.Fatal("expected hardcoded shared leaf to remain on donor root")
}
}
func TestMergeHardcodedLeavesHigherPriorityHardcodedLeafWins(t *testing.T) {
t.Parallel()
dynLeaf := newLeaf("shared", "dynamic")
dyn := newGroup("root", dynLeaf)
hcLeaf := newLeaf("shared", "hardcoded")
SetOverridePriority(hcLeaf, 100)
hc := newGroup("root", hcLeaf)
MergeHardcodedLeaves(dyn, hc)
got := findChildByName(dyn, "shared")
if got != hcLeaf {
t.Fatalf("expected hardcoded leaf to replace dynamic leaf, got %+v", got)
}
if findChildByName(hc, "shared") != nil {
t.Fatal("expected hardcoded leaf to be detached from donor root")
}
}
func TestMergeHardcodedLeavesEqualPriorityKeepsDynamic(t *testing.T) {
t.Parallel()
dynLeaf := newLeaf("shared", "dynamic")
SetOverridePriority(dynLeaf, 100)
dyn := newGroup("root", dynLeaf)
hcLeaf := newLeaf("shared", "hardcoded")
SetOverridePriority(hcLeaf, 100)
hc := newGroup("root", hcLeaf)
MergeHardcodedLeaves(dyn, hc)
got := findChildByName(dyn, "shared")
if got != dynLeaf {
t.Fatalf("equal priorities should keep dynamic leaf, got %+v", got)
}
}
func TestMergeHardcodedLeavesRecurseGroups(t *testing.T) {
t.Parallel()
dyn := newGroup("root",
newGroup("space",
newLeaf("list", "dynamic"),
),
)
hc := newGroup("root",
newGroup("space",
newLeaf("list", "hardcoded"),
newLeaf("create", "hardcoded"),
),
newLeaf("ping", "hardcoded"),
)
MergeHardcodedLeaves(dyn, hc)
space := findChildByName(dyn, "space")
if space == nil {
t.Fatal("expected space group")
}
if list := findChildByName(space, "list"); list == nil || list.Short != "dynamic" {
t.Fatalf("space.list should remain dynamic, got %+v", list)
}
if create := findChildByName(space, "create"); create == nil || create.Short != "hardcoded" {
t.Fatalf("space.create should be grafted, got %+v", create)
}
if ping := findChildByName(dyn, "ping"); ping == nil || ping.Short != "hardcoded" {
t.Fatalf("ping should be grafted, got %+v", ping)
}
}
func TestMergeHardcodedLeavesHigherPriorityGroupReplacesDynamicGroup(t *testing.T) {
t.Parallel()
dynGroup := newGroup("export", newLeaf("get", "dynamic"))
dyn := newGroup("root", dynGroup)
hcGroup := newGroup("export", newLeaf("get", "hardcoded"))
SetOverridePriority(hcGroup, 100)
hc := newGroup("root", hcGroup)
MergeHardcodedLeaves(dyn, hc)
got := findChildByName(dyn, "export")
if got != hcGroup {
t.Fatal("expected higher-priority hardcoded group to replace dynamic group")
}
if get := findChildByName(got, "get"); get == nil || get.Short != "hardcoded" {
t.Fatalf("expected hardcoded export.get after replacement, got %+v", get)
}
if findChildByName(hc, "export") != nil {
t.Fatal("expected hardcoded group to be detached from donor root")
}
}
func TestMergeHardcodedLeavesShapeMismatchKeepsDynamic(t *testing.T) {
t.Parallel()
dyn := newGroup("root",
newGroup("cmd", newLeaf("sub", "dynamic")),
)
hc := newGroup("root", newLeaf("cmd", "hardcoded"))
MergeHardcodedLeaves(dyn, hc)
cmd := findChildByName(dyn, "cmd")
if cmd == nil {
t.Fatal("expected dynamic cmd to remain")
}
if IsLeafCmd(cmd) {
t.Fatal("expected dynamic cmd to remain a group")
}
if findChildByName(cmd, "sub") == nil {
t.Fatal("expected cmd.sub to remain")
}
}
func TestMergeHardcodedLeavesHigherPriorityGroupReplacesDynamicLeaf(t *testing.T) {
t.Parallel()
dynLeaf := newLeaf("shared", "dynamic")
dyn := newGroup("root", dynLeaf)
hcGroup := newGroup("shared",
newLeaf("list", "hardcoded"),
newLeaf("add", "hardcoded"),
)
SetOverridePriority(hcGroup, 100)
hc := newGroup("root", hcGroup)
MergeHardcodedLeaves(dyn, hc)
got := findChildByName(dyn, "shared")
if got != hcGroup {
t.Fatal("expected higher-priority hardcoded group to replace dynamic leaf")
}
if findChildByName(got, "list") == nil {
t.Fatal("expected hardcoded subtree leaf list to be reachable")
}
if findChildByName(got, "add") == nil {
t.Fatal("expected hardcoded subtree leaf add to be reachable")
}
if findChildByName(hc, "shared") != nil {
t.Fatal("expected hardcoded group to be detached from donor root")
}
}
func TestMergeHardcodedLeavesEqualPriorityShapeMismatchKeepsDynamic(t *testing.T) {
t.Parallel()
dynLeaf := newLeaf("shared", "dynamic")
SetOverridePriority(dynLeaf, 100)
dyn := newGroup("root", dynLeaf)
hcGroup := newGroup("shared", newLeaf("list", "hardcoded"))
SetOverridePriority(hcGroup, 100)
hc := newGroup("root", hcGroup)
MergeHardcodedLeaves(dyn, hc)
got := findChildByName(dyn, "shared")
if got != dynLeaf {
t.Fatalf("equal priorities should keep dynamic leaf, got %+v", got)
}
}
func TestIsLeafCmd(t *testing.T) {
t.Parallel()
leaf := newLeaf("x", "")
group := newGroup("x", newLeaf("child", ""))
if !IsLeafCmd(leaf) {
t.Fatal("expected leaf to be leaf")
}
if IsLeafCmd(group) {
t.Fatal("expected group to not be leaf")
}
if IsLeafCmd(nil) {
t.Fatal("expected nil to not be leaf")
}
}
+38
View File
@@ -1,7 +1,45 @@
// 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 cmdutil
import "github.com/spf13/cobra"
// SourceAnnotation records where a command tree came from. Edition overlays
// use it to distinguish runtime-authored commands from helper fallbacks that
// happen to share the same top-level product name.
const SourceAnnotation = "dws.source"
// SourceEnvelope marks a command as authored by the runtime discovery envelope.
const SourceEnvelope = "envelope"
// MarkEnvelopeSource stamps cmd with runtime discovery provenance.
func MarkEnvelopeSource(cmd *cobra.Command) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[SourceAnnotation] = SourceEnvelope
}
// IsEnvelopeSourced reports whether cmd was authored by the runtime discovery
// envelope.
func IsEnvelopeSourced(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourceEnvelope
}
// KindAnnotation is the annotation key for marking command kinds.
const KindAnnotation = "dws.kind"
+45
View File
@@ -0,0 +1,45 @@
// 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 cmdutil
import (
"testing"
"github.com/spf13/cobra"
)
func TestEnvelopeSourceProvenance(t *testing.T) {
t.Parallel()
if IsEnvelopeSourced(nil) {
t.Fatal("nil command should not be envelope sourced")
}
cmd := &cobra.Command{Use: "chat"}
if IsEnvelopeSourced(cmd) {
t.Fatal("unstamped command should not be envelope sourced")
}
MarkEnvelopeSource(cmd)
if !IsEnvelopeSourced(cmd) {
t.Fatal("stamped command should be envelope sourced")
}
if got := cmd.Annotations[SourceAnnotation]; got != SourceEnvelope {
t.Fatalf("SourceAnnotation = %q, want %q", got, SourceEnvelope)
}
}
func TestMarkEnvelopeSourceNilDoesNotPanic(t *testing.T) {
t.Parallel()
MarkEnvelopeSource(nil)
}
+4
View File
@@ -53,6 +53,10 @@ type ToolCaller interface {
Format() string
// DryRun returns true when --dry-run is active.
DryRun() bool
// Fields returns the global --fields output projection ("" if unset).
Fields() string
// JQ returns the global --jq output filter expression ("" if unset).
JQ() string
}
// RuntimeDefaultFn resolves a single runtimeDefault placeholder (e.g.
+1 -1
View File
@@ -260,7 +260,7 @@ resolve_skill_mode() {
print_multi_mode_notice() {
say ""
say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped."
say " ⚠ multi is not yet stable. 20 product-scoped skills pass dispatch verifier,"
say " ⚠ multi is not yet stable. 22 product-scoped skills pass dispatch verifier,"
say " but interface, naming and cross-skill references may change in future releases."
say " For production / shared environments, use mono mode (--mode mono)."
say ""
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Build release artifacts locally in the current runner and publish them to Gitee.
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
SCRIPT_ROOT="$ROOT"
cd "$SCRIPT_ROOT"
VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}"
[ -n "$VERSION" ] || {
echo "error: VERSION is required or HEAD must be an exact release tag" >&2
exit 1
}
case "$VERSION" in
v*) TAG="$VERSION"; SEMVER="${VERSION#v}" ;;
*) TAG="v$VERSION"; SEMVER="$VERSION" ;;
esac
git fetch --tags origin "refs/tags/${TAG}:refs/tags/${TAG}" >/dev/null 2>&1 || true
target_commit="$(git rev-parse "${TAG}^{commit}" 2>/dev/null || true)"
[ -n "$target_commit" ] || {
echo "error: could not resolve tag ${TAG}" >&2
exit 1
}
current_commit="$(git rev-parse HEAD)"
WORKDIR="$SCRIPT_ROOT"
cleanup_worktree() {
if [ "$WORKDIR" != "$SCRIPT_ROOT" ]; then
git -C "$SCRIPT_ROOT" worktree remove --force "$WORKDIR" >/dev/null 2>&1 || rm -rf "$WORKDIR"
fi
}
trap cleanup_worktree EXIT
if [ "$current_commit" != "$target_commit" ]; then
WORKDIR="$(mktemp -d)"
rm -rf "$WORKDIR"
git worktree add --detach "$WORKDIR" "$TAG"
mkdir -p "$WORKDIR/scripts/release"
cp "$SCRIPT_ROOT/scripts/release/publish-gitee-local.sh" "$WORKDIR/scripts/release/publish-gitee-local.sh"
chmod +x "$WORKDIR/scripts/release/publish-gitee-local.sh"
fi
cd "$WORKDIR"
echo "==> Building ${TAG} locally for Gitee"
VERSION="$SEMVER" ./scripts/dev/build-all.sh
DWS_PACKAGE_VERSION="$TAG" ./scripts/release/post-goreleaser.sh
VERSION="$TAG" ./scripts/release/publish-gitee-local.sh
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Publish locally-built release artifacts to the matching Gitee release.
#
# Intended to run inside Gitee Go after building artifacts in China. This avoids
# the unreliable GitHub Actions -> Gitee cross-border upload path.
set -eu
DIST_DIR="${DIST_DIR:-dist}"
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
GITEE_TOKEN="${GITEE_TOKEN:-${GITEE_ACCESS_TOKEN:-}}"
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-300}"
GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-3}"
GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-10}"
err() {
printf 'error: %s\n' "$*" >&2
exit 1
}
[ -n "$GITEE_TOKEN" ] || err "GITEE_TOKEN is required"
[ -d "$DIST_DIR" ] || err "dist dir not found: $DIST_DIR"
VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}"
[ -n "$VERSION" ] || err "VERSION is required or HEAD must be an exact tag"
case "$VERSION" in
v*) ;;
*) VERSION="v$VERSION" ;;
esac
OWNER="${GITEE_REPO%%/*}"
NAME="${GITEE_REPO##*/}"
base="${GITEE_API}/repos/${OWNER}/${NAME}"
sha256_of() {
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
}
api_get() {
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$@"
}
echo "📦 Publishing local artifacts for ${VERSION} to Gitee ${GITEE_REPO}"
target_commit="$(git rev-parse "${VERSION}^{commit}" 2>/dev/null || git rev-parse HEAD)"
rel_json="$(api_get "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
if [ -z "$release_id" ]; then
echo " No Gitee release for ${VERSION} yet — creating it."
rel_json="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
-X POST "${base}/releases" \
-F "access_token=${GITEE_TOKEN}" \
-F "tag_name=${VERSION}" \
-F "name=${VERSION}" \
-F "body=Gitee-local build of ${VERSION} for China users." \
-F "target_commitish=${target_commit}" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
fi
[ -n "$release_id" ] || err "could not get/create Gitee release for ${VERSION}. Response: ${rel_json}"
echo " Gitee release id = ${release_id}"
assets_map="$(api_get "${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)"
gitee_attach() {
file="$1"
fn="$(basename "$file")"
attempt=1
while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do
response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_UPLOAD_MAX_TIME" \
--retry 2 --retry-delay 5 --retry-all-errors \
-X POST "${base}/releases/${release_id}/attach_files" \
-F "access_token=${GITEE_TOKEN}" -F "file=@${file}" 2>&1 || true)"
if printf '%s' "$response" | grep -q '"browser_download_url"'; then
return 0
fi
echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${fn}: $(printf '%s' "$response" | head -c 240)" >&2
attempt=$((attempt + 1))
[ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ] && sleep "$GITEE_UPLOAD_RETRY_DELAY"
done
return 1
}
gitee_delete() {
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
-X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
>/dev/null 2>&1 || true
}
uploaded=0
replaced=0
skipped=0
failed=0
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt "$DIST_DIR"/dws-skills.zip; do
[ -f "$f" ] || continue
fn="$(basename "$f")"
local_sha="$(sha256_of "$f")"
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 1 ]; then
gitee_sha="$(api_get "$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 — replacing"
elif [ "$count" -gt 1 ]; then
echo " ↻ ${fn} has ${count} copies on Gitee — replacing"
else
echo " ⬆ ${fn} (new)"
fi
printf '%s\n' "$ids" | while read -r aid; do
[ -n "$aid" ] && gitee_delete "$aid"
done
if gitee_attach "$f"; then
if [ "$count" -eq 0 ]; then uploaded=$((uploaded + 1)); else replaced=$((replaced + 1)); fi
else
echo " ❌ upload failed for ${fn}" >&2
failed=$((failed + 1))
fi
done
[ "$failed" -eq 0 ] || err "Gitee publish finished with ${failed} failed upload(s)"
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped}"
+25 -6
View File
@@ -28,6 +28,11 @@ set -eu
DIST_DIR="${DIST_DIR:-dist}"
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-300}"
GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-3}"
GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-10}"
missing=""
[ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN"
@@ -119,7 +124,7 @@ echo " Gitee release id = ${release_id}"
# 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 \
assets_map="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
| python3 -c 'import json,sys
try:
data=json.load(sys.stdin)
@@ -137,13 +142,27 @@ sha256_of() { # sha256 of a file ($1) or, with no arg, of stdin
}
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"'
file="$1"
fn="$(basename "$file")"
attempt=1
while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do
response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_UPLOAD_MAX_TIME" \
--retry 2 --retry-delay 5 --retry-all-errors \
-X POST "${base}/releases/${release_id}/attach_files" \
-F "access_token=${GITEE_TOKEN}" -F "file=@${file}" 2>&1 || true)"
if printf '%s' "$response" | grep -q '"browser_download_url"'; then
return 0
fi
echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${fn}: $(printf '%s' "$response" | head -c 240)" >&2
attempt=$((attempt + 1))
[ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ] && sleep "$GITEE_UPLOAD_RETRY_DELAY"
done
return 1
}
gitee_delete() { # delete attachment by id $1
curl -fsSL -X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
-X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
>/dev/null 2>&1 || true
}
@@ -166,7 +185,7 @@ for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.tx
fi
if [ "$count" -eq 1 ]; then
gitee_sha="$(curl -fsSL "$aurl" 2>/dev/null | sha256_of || true)"
gitee_sha="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$aurl" 2>/dev/null | sha256_of || true)"
if [ "$gitee_sha" = "$local_sha" ]; then
echo " ✓ ${fn} already correct on Gitee — skip"
skipped=$((skipped + 1))
+4 -2
View File
@@ -43,13 +43,14 @@ cli_version: ">=1.0.15"
| `oa` | OA审批:待处理/详情/同意/拒绝/撤销/记录/已发起/任务/转交/评论/抄送 | [oa.md](./references/products/oa.md) |
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
| `mail` | 邮箱:邮箱地址查询/邮件搜索(KQL)/邮件详情/发送邮件 | [mail.md](./references/products/mail.md) |
| `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/浮动图片/导出(两步) | [sheet.md](./references/products/sheet.md) |
| `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/条件格式/浮动图片/浮动图表/模板/导出 xlsx(单命令一站式) | [sheet.md](./references/products/sheet.md) |
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 | [wiki.md](./references/products/wiki.md) |
## 意图判断决策树
用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码" → `aiapp`
用户提到"目标管理/Agoal/战略解码/经营合约/计分卡/目标模板/周月报提交统计" → `agoal`
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
用户提到"考勤/打卡/排班" → `attendance`
@@ -130,7 +131,8 @@ dws <command-path> --help
# helper-only schema 查询(如 dev.*),普通产品命令不要依赖 schema 推断参数
dws schema "dev app create"
dws schema "dev app create" --jq '.tool.required'
# 注:--jq 对 schema 输出无效(不过滤,仍返回完整对象);schema 结构里必填标在
# .parameters.<字段>.required,没有 .tool 键。要看必填字段自行读 .parameters 即可。
```
**何时用哪条路径:**
@@ -6,9 +6,9 @@
| Recipe | 步骤(命令均须 `--format json`,下略) |
|--------|----------------------------------------|
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `todoTaskId` |
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](../products/todo.md))→ 取 `todoTaskId` |
| `reschedule-todo` | 1. `todo task list --status false` → 取 `todoTaskId`<br>2. `todo task update --task-id <todoTaskId> --due "<新截止时间>"` |
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `taskId` |
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](../products/todo.md))→ 取 `taskId` |
| `reschedule-todo` | 1. `todo task list --status false` → 取 `taskId`<br>2. `todo task update --task-id <taskId> --due "<新截止时间>"` |
## Full / 组合(固定路线)
@@ -16,4 +16,4 @@
|--------|---------------------|
| generate-progress-report | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行<br>2. 交叉比对各源数据<br>3. `doc create --name "<报告名>" --content "<报告内容>"` |
| batch-create-todo | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 从结果提取任务条目<br>2. 每条:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>3. **优先**:将待办写入 `todos.json`(格式见 [todo_batch_create.py](../../scripts/todo_batch_create.py)),执行 `python scripts/todo_batch_create.py todos.json`<br>备选:逐条 `todo task create --title "<标题>" --executors <userId>` → 汇总回显<br>**单批超 30 条须用户确认** |
| assign-and-notify | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId>` → 取 `todoTaskId`<br>3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group <openConversationId> --text "<通知内容>"` 通知 |
| assign-and-notify | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId>` → 取 `taskId`<br>3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group <openConversationId> --text "<通知内容>"` 通知 |
@@ -6,7 +6,7 @@
|--------|-------------------|
| read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId`<br>3. **取记录(按场景分流)**:<br>  • 数据统计/分析/全量汇总 → `aitable record query --base-id <baseId> --table-id <tableId> --all`(自动翻页,**禁止凭单页数据做统计**)<br>  • 大表保险 → 加 `--page-limit 100`(默认 50 页/5000 条,0 = 无限制)<br>  • 单纯预览前几条 → `aitable record query --base-id <baseId> --table-id <tableId> --limit 30`(不加 --all)<br>  • 筛选时 `--filters` 格式见 [aitable-filter-sort.md](../products/aitable/aitable-filter-sort.md)<br>4. **检查输出契约**:`hasMore=true` 时数据被截断,必须用 `--cursor <X>` 续拉;`partial=true` 时表示中途某页失败(保留已拉数据,可重试)<br>5. 总结数据 |
| generate-data-report | 1. 同 read-aitable 步骤 1-3(**必须用 --all 防漏数据**)<br>2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 补充背景<br>3. `doc create --name "<报告名>" --content "<分析报告>"` |
| create-aitable-record | **写入路径分流**(关键决策):<br>  • **追加到已有表**(场景:把这批数据加到「成员表」)→ `python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(走 `record create`,需要列名匹配字段名)<br>  • **新建表**(场景:把这个 Excel 变成多维表)→ `python scripts/aitable_import_via_task.py <baseId> <file>` 或 `dws aitable import upload --base-id <baseId> --file ./x.xlsx` + `dws aitable import data --import-id <ID>`(CLI 已内置 OSS PUT 清空 Content-Type + 同步轮询,**禁止自己写 PUT**)<br>  • **单条/少量手动**:1. `aitable base search` → `baseId`/`tableId`;2. `aitable field get` → `fieldId` 与类型;3. `aitable record create --records '[{"cells":{"<fieldId>":"值"}}]'` |
| create-aitable-record | **写入路径分流**(关键决策):<br>  • **追加到已有表**(场景:把这批数据加到「成员表」)→ `python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(走 `record create`,需要列名匹配字段名)<br>  • **新建表**(场景:把这个 Excel 变成多维表)→ `python scripts/aitable_import_via_task.py <baseId> <file>`(脚本内置 prepare→OSS PUT→import data 全流程和正确的头处理,**禁止自己写 PUT**)。注意 `aitable import upload` 没有 `--file` flag、也不代做 PUT,只准备导入;能一站式完成的是上面的脚本<br>  • **单条/少量手动**:1. `aitable base search` → `baseId`/`tableId`;2. `aitable field get` → `fieldId` 与类型;3. `aitable record create --records '[{"cells":{"<fieldId>":"值"}}]'` |
| update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. **取目标 record**:<br>  • 已知少量 recordId → `aitable record query --record-ids <ID1,ID2>`<br>  • 按条件批量改 → `aitable record query --base-id <baseId> --table-id <tableId> --filters '<JSON>' --all`(**用 --all 防止漏改**)<br>3. **先展示让用户确认要改的 record 列表**<br>4. `aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<recordId>","cells":{...}}]'`(单次 ≤30 条) |
| search-aitable-template | 1. `aitable template search --query "<关键词>"` → 取 `templateId`<br>2. 用户选定<br>3. `aitable base create --name "<表格名>" --template-id <templateId>` → 取 `baseId` |
| export-aitable-to-xlsx | 1. `aitable base search --query "<表格名>"` → 取 `baseId`<br>2. **按场景选 scope**:<br>  • 全表+附件 → `aitable export data --base-id <baseId> --scope all --export-format excel_and_attachment --output ./<name>.xlsx`<br>  • 单表(仅 xlsx)→ `--scope table --table-id <tableId> --export-format excel`<br>  • 单视图 → `--scope view --table-id <tableId> --view-id <viewId>`<br>3. CLI 内置渐进式退避轮询 + 自动落盘,**不要自己写 GET downloadUrl**<br>4. 大表超时(默认 5 分钟):加 `--timeout-sec 900` 或拿到 `taskId` 后 `aitable export data --task-id <ID> --output ./<name>.xlsx` 续等<br>5. 与悟空脚本路径并存:复杂场景(多 base 批量 / 按视图组合)请用 `python scripts/aitable_export_via_task.py <baseId> --scope all\|table\|view` |
@@ -24,22 +24,22 @@
| Recipe | 步骤 |
|--------|------|
| `mail-get` | `mail message get --email <邮箱> --id <messageId>` → 查看邮件完整内容(含正文) |
| `mail-folder-list` | `mail folder list --email <邮箱>` → 列举文件夹;`--folder-id <id>` 查子文件夹 |
| `mail-folder-list` | `mail folder list --email <邮箱>` → 列举文件夹;`--folder <id>` 查子文件夹 |
| `mail-tag-list` | `mail tag list --email <邮箱>` → 列举邮件标签 |
| `mail-thread-get` | `mail thread get --email <邮箱> --id <conversationId>` → 获取会话(邮件线程)详情 |
| `mail-attachment-list` | `mail attachment list --email <邮箱> --id <messageId>` → 列举指定邮件的附件 |
| `mail-attachment-download` | 1. `mail attachment list --email <邮箱> --id <messageId>` → 取附件 `id` 和 `name`<br>2. `mail attachment download --email <邮箱> --message-id <messageId> --attachment-id <attachmentId> --name <文件名>` |
| `mail-batch-move` | `mail message batch-move --email <邮箱> --ids <id1,id2,...> --folder <folderId>`(常用 folderId: 2=收件箱, 6=已删除) |
| `mail-batch-delete` | `mail message batch-delete --email <邮箱> --ids <id1,id2,...> --yes`(**危险操作,须先确认**) |
| `mail-draft-create` | `mail draft create --from <邮箱> --subject "<标题>"` → 取 `messageId`(可选 `--to`、`--body`、`--cc`) |
| `mail-draft-update` | `mail draft update --from <邮箱> --id <draftId> --subject "<新标题>"`(可选 `--body`、`--to`、`--cc`) |
| `mail-draft-create` | `mail draft create --from <邮箱> --subject "<标题>"` → 取草稿 ID `result.message.id`(可选 `--to`、`--content`、`--cc`) |
| `mail-draft-update` | `mail draft update --from <邮箱> --id <draftId> --subject "<新标题>"`(可选 `--content`、`--to`、`--cc`) |
| `mail-draft-send` | `mail draft send --from <邮箱> --id <draftId>` |
## Full / 多步组合
| Recipe | 行动指南(固定路线) |
|--------|---------------------|
| search-and-download-attachment | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --size 20` → 取 `messageId` 列表<br>3. 对每封邮件执行 `mail attachment list --email <邮箱> --id <messageId>` → 列出附件取 `id` 和 `name`<br>4. 对每个附件逐个执行 `mail attachment download --email <邮箱> --message-id <messageId> --attachment-id <attachmentId> --name <文件名>`(**仅支持逐个下载,不存在批量下载命令**) |
| search-reply-forward | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --size 10` → 取 `messageId`<br>3. 展示搜索结果供用户选择<br>4. 按用户指示执行 reply / reply-all / forward(参见 lite `mail-reply-forward`) |
| batch-mail-cleanup | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --size 100` → 取多个 `messageId`<br>3. 展示列表供用户确认<br>4. `mail message batch-move --email <邮箱> --ids <id1,id2,...> --folder 6 ` 移到已删除;或 `batch-delete` 永久删除 |
| send-to-person-by-name | 1. `mail mailbox list` → 取发件邮箱<br>2. 走「查找他人邮箱地址」三路并发查询获取收件人邮箱(见 [mail.md](../products/mail.md))<br>3. `mail message send --from <发件邮箱> --to <收件邮箱> --subject "<标题>" --body "<内容>"` |
| search-and-download-attachment | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --limit 20` → 取 `messageId` 列表<br>3. 对每封邮件执行 `mail attachment list --email <邮箱> --id <messageId>` → 列出附件取 `id` 和 `name`<br>4. 对每个附件逐个执行 `mail attachment download --email <邮箱> --message-id <messageId> --attachment-id <attachmentId> --name <文件名>`(**仅支持逐个下载,不存在批量下载命令**) |
| search-reply-forward | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --limit 10` → 取 `messageId`<br>3. 展示搜索结果供用户选择<br>4. 按用户指示执行 reply / reply-all / forward(参见 lite `mail-reply-forward`) |
| batch-mail-cleanup | 1. `mail mailbox list` → 取邮箱(返回字段是 `emailAccounts`)<br>2. `mail message search --email <邮箱> --query "<KQL>" --limit 100` → 取多个 `messageId`<br>3. 展示列表供用户确认<br>4. `mail message batch-move --email <邮箱> --ids <id1,id2,...> --folder 6 ` 或 `batch-delete` **都是移入「已删除」文件夹,并非物理永久删除**(对已在已删除文件夹的邮件再执行返回 success 但无效;CLI 无永久删除路径,需在客户端手动清空) |
| send-to-person-by-name | 1. `mail mailbox list` → 取发件邮箱<br>2. 走「查找他人邮箱地址」三路并发查询获取收件人邮箱(见 [mail.md](../products/mail.md))<br>3. `mail message send --from <发件邮箱> --to <收件邮箱> --subject "<标题>" --content "<内容>"` |
@@ -249,7 +249,7 @@ query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/
- 按文件夹:`folderId:2`(2=收件箱, 1=已发送, 5=草稿, 6=已删除)
- 按是否有附件:`hasAttachments:true`
- 组合:`from:alice AND subject:周报 AND date>2025-06-01T00:00:00Z`
3. 执行搜索:`mail message search --email <邮箱> --query "<KQL表达式>" --size 20`
3. 执行搜索:`mail message search --email <邮箱> --query "<KQL表达式>" --limit 20`
4. 查看详情(按需):`mail message get --email <邮箱> --id <messageId>`
### mail-send
@@ -258,7 +258,7 @@ query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/
1. 获取邮箱地址:`mail mailbox list` → 取用户邮箱作为 `--from`。
2. 确定收件人:用户直接提供邮箱地址 → 直接使用;用户提供姓名 → 走「查找他人邮箱地址」三路并发流程(见 [mail.md](../products/mail.md))。
3. 发送:`mail message send --from <发件邮箱> --to <收件邮箱> --subject "<主题>" --body "<正文>"`(可选 `--cc`、`--attachment`、`--inline-attachment`)。
3. 发送:`mail message send --from <发件邮箱> --to <收件邮箱> --subject "<主题>" --content "<正文>"`(正文的规范 flag 是 `--content`,`--body` 是隐藏别名;可选 `--cc`、`--attachment`、`--inline-attachment`)。
### mail-reply-forward
@@ -267,6 +267,6 @@ query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/
1. 获取邮箱地址:`mail mailbox list` → 取用户邮箱。
2. 定位原始邮件:若用户未提供 messageId → 先用 `mail-search` 搜索定位。
3. 执行:
- 回复:`mail message reply --from <邮箱> --id <messageId>`(可选 `--to`、`--subject`、`--body`)
- 回复全部:`mail message reply-all --from <邮箱> --id <messageId>`(可选 `--to`、`--subject`、`--body`)
- 转发:`mail message forward --from <邮箱> --to <收件邮箱> --id <messageId>`(可选 `--subject`、`--body`)
- 回复:`mail message reply --from <邮箱> --id <messageId>`(可选 `--to`、`--subject`、`--content`;正文 flag 规范名 `--content`,`--body` 为别名)
- 回复全部:`mail message reply-all --from <邮箱> --id <messageId>`(可选 `--to`、`--subject`、`--content`)
- 转发:`mail message forward --from <邮箱> --to <收件邮箱> --id <messageId>`(可选 `--subject`、`--content`)
+1 -1
View File
@@ -22,7 +22,7 @@
| 不支持的操作 | 说明 |
|------------|------|
| 创建公式/查找引用等高级字段类型 | 部分高级字段类型暂不支持 API 创建 |
| 自己 PUT 文件时 Content-Type 不为空 | OSS 签名机制要求 PUT 请求的 `Content-Type` 头**必须清空**,否则返回 `SignatureDoesNotMatch` / HTTP 403。这**不是 dws 限制**,是阿里云 OSS 行为。解决:`dws aitable import upload --file ./x.xlsx` 已内置正确处理;自己写 `curl` 时必须传 `-H "Content-Type:"`(注意冒号后是空值) |
| 自己 PUT 文件到 OSS 时 Content-Type 处理 | OSS 签名对 PUT 请求的 `Content-Type` 头有严格要求,处理不当会 `SignatureDoesNotMatch` / HTTP 403。这**不是 dws 限制**,是阿里云 OSS 行为。解决:直接用 `python scripts/aitable_import_via_task.py <baseId> <file>`,脚本已内置 prepare→PUT→import data 全流程和正确的头处理,**不要自己写 curl PUT**。(注意:`aitable import upload` 命令没有 `--file` flag、也不代做 PUT,只准备导入;能自动 PUT 的是上面的脚本。) |
## minutes
+2 -2
View File
@@ -64,8 +64,8 @@ dws recovery finalize --event-id <event_id> --outcome recovered|failed|handoff -
| 标志 | 短名 | 说明 | 默认 |
|------|:---:|------|------|
| `--format` | `-f` | 输出格式: json / table / raw | json |
| `--jq` | | jq 表达式过滤输出 (如: `.items[] \| .name`) | 无 |
| `--fields` | | 筛选输出字段 (逗号分隔, 如: name,id,status) | 无 |
| `--jq` | | jq 表达式过滤输出(如 `.result[].name`)。对产品命令(aitable/chat/mail/... 走 MCP 的)已生效;少数工具命令(auth/config/profile/doctor/schema 等)仍直接编码、暂不过滤 | 无 |
| `--fields` | | 筛选输出字段(逗号分隔)。按**顶层信封键**(data/result/success/status…)或**列表元素字段**投影;取 data 内的嵌套字段(如 baseName)请改用 `--jq '.data.baseName'`,`--fields baseName` 会因顶层无此键返回 `{}`。同 `--jq`:产品命令已生效,个别工具命令暂不生效 | 无 |
| `--verbose` | `-v` | 详细日志 | false |
| `--debug` | | 调试日志 | false |
| `--yes` | `-y` | 跳过确认提示 | false |
+8 -7
View File
@@ -68,8 +68,8 @@
| "改群昵称/设置群昵称/我在群里的名字" | 设置个人群昵称 | `chat group update-nick` | `chat group rename` | update-nick 改自己的群昵称;rename 改群名称 |
| "群备注/给群加备注/修改群备注" | 设置群备注 | `chat group update-alias` | `chat group rename` | update-alias 设置仅自己可见的备注;rename 改群名称全员可见 |
| "隐藏会话/隐藏群聊/隐藏对话" | 隐藏会话 | `chat hide` | `chat mute` | hide 隐藏会话不显示;mute 是免打扰但仍显示 |
| "关闭@所有人通知/屏蔽@all/不接收@所有人" | 关闭 @所有人提醒 | `chat mute-at-all` | `chat mute` | mute-at-all 仅屏蔽 @所有人;mute 是整个会话免打扰 |
| "关闭红包通知/屏蔽红包/不接收红包提醒" | 关闭红包提醒 | `chat mute-red-envelope` | `chat mute` | mute-red-envelope 仅屏蔽红包;mute 是整个会话免打扰 |
| "关闭@所有人通知/屏蔽@all/不接收@所有人" | 关闭 @所有人提醒 | `chat mute-at-all` | `chat mute` | mute-at-all 仅屏蔽 @所有人;mute 是整个会话免打扰。⚠️ 当前服务端稳定返回 1002「系统繁忙」,命令暂不可用,先告知用户到钉钉客户端设置 |
| "关闭红包通知/屏蔽红包/不接收红包提醒" | 关闭红包提醒 | `chat mute-red-envelope` | `chat mute` | mute-red-envelope 仅屏蔽红包;mute 是整个会话免打扰。⚠️ 同 mute-at-all,服务端稳定 1002,暂不可用 |
| "解散群/解散群聊" | 解散群聊 | `chat group dismiss` | `chat group quit` | dismiss 是群主解散整个群(不可逆);quit 是当前用户自己退群 |
| "新成员看历史/历史消息可见范围" | 设置新成员可见历史消息 | `chat group set-history` | `chat group update-settings` | set-history 控制新成员入群后可见历史消息范围;update-settings 是其他群功能开关 |
| "群里有哪些机器人/查看群机器人/列出群机器人" | 查看群内机器人列表 | `chat group bots` | `chat group members` | bots 只列机器人;members 列普通群成员 |
@@ -160,7 +160,7 @@ alidocs 链接表面长得一样(`https://alidocs.dingtalk.com/i/nodes/{id}`
用 `sheet` 的场景(axls,钉钉在线电子表格):
- `dws doc info --node <URL>` 返回 `contentType=ALIDOC` + `extension=axls`
- 用户在钉钉文档空间直接"新建电子表格"得到的节点
- 所有 sheet 子命令(`list` / `range read` / `range write` / `export` 等)仅服务这类节点
- 所有 sheet 子命令(`list` / `range read` / `range update` / `export` 等)仅服务这类节点
用 `dws drive download` 的场景(xlsx / xls / xlsm / csv 本地表格文件):
- `dws doc info --node <URL>` 返回 `contentType=DOCUMENT` + `extension=xlsx` / `xls` / `xlsm` / `csv`
@@ -273,8 +273,9 @@ alidocs 链接表面长得一样(`https://alidocs.dingtalk.com/i/nodes/{id}`
- 已有 userId 时直接使用 `--user`;已有 openDingTalkId 时使用 `--open-dingtalk-id`
- 纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力,不需要先手动查询 openDingTalkId
- 富媒体消息(image/file)单聊优先使用 `--open-dingtalk-id`;传 `--user` 时 CLI 会尝试解析为 openDingTalkId 后发送
- "发张图片/截图/语音/视频/文件到群里" / "发张图给某某" — **统一一条命令**:`dws chat message send ... --msg-type file --file-path <本地路径>`,CLI 内部自动上传并发送,**任意扩展名(png/jpg/pdf/mp4/zip…)都走这条**
- "发图片+文字说明" — 不要硬塞进一条命令;先发文件消息再补一条 `--text "..."` 即可
- "发文件/语音/视频到群里" — `dws chat message send ... --msg-type file --file-path <本地路径>`,CLI 内部自动上传并发送(png/jpg/pdf/mp4/zip… 任意扩展名都走这条,但**都作为「文件」消息**发出,接收方看到的是可下载的文件条目)
- "发张图片/截图(要在聊天里内联渲染成图,不是文件)" — 走图片消息链路:先 `dt_media_upload` 拿 mediaId,再 `dws chat message send ... --msg-type image --media-id <mediaId>`。**注意**:用 `--msg-type file` 发 .png 只会显示为[文件](fileId),不会渲染成图片;要图片效果必须走 `--msg-type image`
- "发图片+文字说明" — 不要硬塞进一条命令;先发图片/文件消息再补一条 `--text "..."` 即可
```bash
dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png --format json
@@ -345,7 +346,7 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file
**用 `oa approval` 的场景**(通用 OA 审批中心):
- "我的待审批 / 待办审批 / 已审批记录" — `oa approval list-pending`
- "某审批单详情 / 同意或驳回审批 / 撤销我发起的审批" — `oa approval detail/approve/reject/revoke`
- "查业务审批记录 / 审批转交 / 添加评论与抄送" — `oa approval records/transfer/comment/cc`
- "查业务审批记录 / 审批转交 / 添加评论与抄送" — `oa approval records`(操作记录)/ `redirect-task`(转交)/ `oa-comments`(评论)/ `oa-cc-noticer`(抄送)
- 用户提到"报销 / 采购 / 用印 / 合同 等非考勤类审批"
**判断关键**:
@@ -353,7 +354,7 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file
- 不限于考勤业务、面向"我上下游的审批任务"表述 → `oa approval`
**提交审批单的边界**:
- 提交考勤审批单走 `attendance approve templates --type leave|overtime|repair-check`(当前仅支持请假/加班/补卡三类模板;出差/外出的提交模板暂不支持,查询仍可用 `approve list --types trip` 覆盖),命令会返回审批表单的 submitUrl 跳转链接,由用户点击链接跳转到钉钉客户端的提交页面完成填写与提交。**展示链接时必须用 Markdown 可点击格式 `[表单名称](submitUrl)`,不要裸露 URL**。
- 提交考勤审批单走 `attendance approve templates --type leave|overtime|repair-check|travel`(请假/加班/补卡/出差外出,出差外出用 `--type travel`,返回 approveType=TRAVEL/OUT 的模板;具体哪几类有模板取决于组织配置,返回空列表即该组织未配该类),命令会返回审批表单的 submitUrl 跳转链接,由用户点击链接跳转到钉钉客户端的提交页面完成填写与提交。**展示链接时必须用 Markdown 可点击格式 `[表单名称](submitUrl)`,不要裸露 URL**。
- 提交诉求的辅助查询:可用假期余额走 `attendance vacation balance`、历史已提交记录走 `attendance approve list`。
- 任何场景下都**不要误用 `oa approval` 代替** —— 该命令组只能查/审/撤已存在的审批单,考勤业务审批单走考勤自己的逻辑便于区分。
+161
View File
@@ -0,0 +1,161 @@
# Agoal(目标管理)
## 产品说明
Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分卡、用户目标、目标模板、周月报六大模块,帮助组织将战略目标从顶层分解到个人并持续跟踪。
**CLI 前缀**: `dws agoal`
## 命令总览
### strategy (战略解码管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `strategy list` | 获取战略解码列表 | `--scope-type` `--scope-id` | scopeType: DEPT/PERSONAL;scope-id 为 scope-type 对应的部门 id 或用户 id |
| `strategy detail` | 获取战略解码详情 | `--profile-id` | 根据战略解码 id 查询 |
| `strategy update` | 更新战略解码 | `--profile-id` `--content` | 覆盖逻辑,必须基于查询返回的老数据修改后再传入;`--content` 为 JSON 数组 |
`strategy update` 是覆盖式更新:一定要先 `strategy detail` 获取完整数据,在原数据基础上修改后再传入。
### contract (经营合约管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `contract list` | 获取经营合约列表 | `--scope-type` `--scope-id` | scopeType: DEPT/PERSONAL;scope-id 为 scope-type 对应的部门 id 或用户 id |
| `contract fields` | 获取经营合约字段列表 | - | 获取组织下经营合约的字段配置 |
| `contract detail` | 获取经营合约详情 | `--contract-id` | 根据合约 id 查询 |
| `contract update` | 更新经营合约 | `--contract-id` `--dimensions` | 覆盖逻辑;可选 `--audit-config`、`--objective-template` |
`contract update` 同样是覆盖式更新:必须基于 `contract detail` 返回的数据修改后再传入。
### scorecard (计分卡管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `scorecard detail` | 获取计分卡详情 | `--selected-time` `--dept-id` | selectedTime 为 ISO-8601 字符串,如 `"2026-01-01T00:00:00+08:00"` |
| `scorecard entity-detail` | 获取计分卡实体详情 | `--sc-id` `--entity-id` | 根据计分卡 id 和实体 id 查询 |
| `scorecard update` | 更新计分卡 | `--dept-id` `--selected-time` `--id` `--tracking-period-type` `--content` | trackingPeriodType: MONTHLY/QUARTERLY |
### user (用户目标管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `user rules` | 获取用户规则周期列表 | - | 可选 `--user-id`,不传则默认取操作人自己 |
| `user objectives` | 查询用户目标列表 | `--user-id` `--rule-id` `--period-ids` | `--period-ids` 为逗号分隔的周期 id 列表 |
### report (周月报管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `report list-statistics` | 获取周月报数据跟催列表 | - | 返回各规则的人员提交情况统计;可选 `--keyword` |
| `report submit-detail` | 获取周月报规则提交详情 | `--template-id` `--submit-state` | submitState: ON_TIME/LATE/NOT_SUBMITTED;可选 `--query-date`、`--page`、`--page-size`、`--keyword` |
### obj-template (目标模板管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `obj-template list` | 获取目标模板列表 | - | 可选 `--keyword`、`--page`、`--page-size` |
| `obj-template create-or-update` | 新增或更新目标模板 | `--dimensions` | 覆盖逻辑;新增时 `--title` 必填;更新时 `--template-id` 必填 |
## 意图判断
用户说"战略解码/战略目标/OGSM":
- 查看/列表 → `strategy list`
- 详情 → `strategy detail`
- 修改/更新 → `strategy update`(先查后改)
用户说"经营合约/合约/KPI合约":
- 查看/列表 → `contract list`
- 字段配置 → `contract fields`
- 详情 → `contract detail`
- 修改/更新 → `contract update`(先查后改)
用户说"计分卡/scorecard/绩效看板":
- 查看详情 → `scorecard detail`
- 实体详情 → `scorecard entity-detail`
- 修改/更新 → `scorecard update`
用户说"目标/OKR/我的目标/个人目标":
- 规则周期 → `user rules`
- 目标列表 → `user objectives`
用户说"目标模板/模板管理":
- 查看模板列表 → `obj-template list`
- 新增模板 → `obj-template create-or-update --title "模板名称"`
- 更新模板 → `obj-template create-or-update --template-id TPL_ID`
用户说"周月报/周报统计/提交情况/跟催/迟交/未提交":
- 查看提交统计列表 → `report list-statistics`
- 查看某规则的提交详情 → `report submit-detail`
## 核心工作流
```bash
# 查看战略解码列表与详情
dws agoal strategy list --scope-type DEPT --scope-id DEPT_ID --format json
dws agoal strategy detail --profile-id PROFILE_ID --format json
# 更新战略解码:必须基于 detail 返回内容修改后传入
dws agoal strategy update --profile-id PROFILE_ID \
--content '[{"id":"entity1","title":{"title":"新目标"},"entityType":"OGSM_OBJECTIVE","status":"NORMAL","executors":["dingId1"]}]' \
--format json
# 查看经营合约列表、字段与详情
dws agoal contract list --scope-type PERSONAL --scope-id USER_ID --format json
dws agoal contract fields --format json
dws agoal contract detail --contract-id CONTRACT_ID --format json
# 更新经营合约:必须基于 detail 返回内容修改后传入
dws agoal contract update --contract-id CONTRACT_ID \
--dimensions '[{"id":"dim1","title":"维度名称","objectives":[]}]' \
--format json
# 查看计分卡
dws agoal scorecard detail --selected-time "2026-01-01T00:00:00+08:00" --dept-id DEPT_ID --format json
dws agoal scorecard entity-detail --sc-id SC_ID --entity-id ENTITY_ID --format json
# 更新计分卡
dws agoal scorecard update --dept-id DEPT_ID --selected-time "2026-01-01T00:00:00+08:00" \
--id SC_ID --tracking-period-type MONTHLY \
--content '[{"id":"dim1","title":"业绩","items":[{"id":"item1","title":"收入","target":"100"}]}]' \
--format json
# 查询用户目标
dws agoal user rules --user-id USER_ID --format json
dws agoal user objectives --user-id USER_ID --rule-id RULE_ID --period-ids "period1,period2" --format json
# 周月报提交统计与详情
dws agoal report list-statistics --format json
dws agoal report list-statistics --keyword "周报规则" --format json
dws agoal report submit-detail --template-id TPL_ID --submit-state ON_TIME --format json
dws agoal report submit-detail --template-id TPL_ID --submit-state LATE --query-date "2026-06-18T00:00:00+08:00" --page 1 --page-size 20 --format json
# 目标模板
dws agoal obj-template list --format json
dws agoal obj-template list --keyword "业绩" --format json
dws agoal obj-template create-or-update --title "业绩模板" --objective-weight --dimension-weight --dimensions '[...]' --format json
dws agoal obj-template create-or-update --template-id TPL_ID --title "业绩模板" --dimensions '[...]' --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `strategy list` | `profileId` | `strategy detail` / `strategy update` 的 `--profile-id` |
| `strategy detail` | 完整实体数据 | `strategy update` 的 `--content`(基于此修改) |
| `contract list` | `contractId` | `contract detail` / `contract update` 的 `--contract-id` |
| `contract detail` | 完整维度数据 | `contract update` 的 `--dimensions`(基于此修改) |
| `scorecard detail` | `scId`、`entityId` | `scorecard entity-detail` / `scorecard update` 的 `--id` |
| `user rules` | `ruleId`、`periodIds` | `user objectives` 的 `--rule-id` `--period-ids` |
| `report list-statistics` | `templateId` | `report submit-detail` 的 `--template-id` |
| `obj-template list` | `templateId` | `obj-template create-or-update` 的 `--template-id`(更新时) |
## 注意事项
- 所有 update / create-or-update 命令都是覆盖逻辑:必须先用对应 detail/list 查询完整数据,在原数据基础上修改后再传入。
- 所有命令支持可选参数 `--request-id`。
- `--scope-type` 仅支持 `DEPT` 和 `PERSONAL`。
- `--selected-time` 接受 ISO-8601 字符串,如 `"2026-01-01T00:00:00+08:00"`。
- `--period-ids` 为逗号分隔字符串,如 `"period1,period2"`。
- `report submit-detail` 的 `--query-date` 接受 ISO-8601 字符串;不传则默认当天。
+1 -1
View File
@@ -98,7 +98,7 @@ Flags:
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `aisearch person` | `userId`(用户ID)、`title`(姓名) | 展示搜索结果、后续操作(发消息/建待办等) |
| `aisearch person` | `userId`(用户ID,= `meta.staffId`/`meta.jobNumber`)、`author`(真实姓名,= `meta.name`);`title` 是花名/显示名,不一定等于姓名 | 展示搜索结果、后续操作(发消息/建待办等) |
## 重名消歧
+19 -5
View File
@@ -19,8 +19,10 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` |
| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 |
| `base search` | 按名称搜索 Base(别名 `aitable search`) | — | `--query` help 标必填但实际可省略:不传时返回最近访问的 Base 列表。`--keyword` 是 `--query` 的隐藏别名,同义 |
| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID |
| `base copy` | 复制整个 Base 到目标文件夹 | `--base-id` `--target-folder-id` | 默认全量复制;`--only-struct` 仅复制结构不含数据 |
| `base get-primary-doc-id` | 获取某记录的主键文档 ID | `--base-id` `--table-id` `--record-id` | 等价 `record primary-doc-get` 的取 ID 视角 |
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId |
| `base update` | 更新 Base 名称 | `--base-id` `--name` | — |
| `base delete` | 删除 Base | `--base-id` | 不可逆 |
@@ -30,7 +32,8 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `table get` | 获取表结构(字段+视图目录) | `--base-id` | 不传 `--table-ids` 返回全部表 |
| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 |
| `table list` | 获取数据表(`table get` 的别名) | `--base-id` | 与 `table get` 等价 |
| `table create` | 创建数据表 | `--base-id` `--name` | `--fields` 为 JSON 数组;**可传空数组 `[]`**(默认值即 `[]`),此时服务端自动补一个名为"标题"的 primaryDoc 首列;单次最多 15 个字段 |
| `table update` | 重命名表 | `--base-id` `--table-id` `--name` | — |
| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 |
@@ -39,19 +42,29 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `field get` | 获取字段完整配置 | `--base-id` `--table-id` | 按需展开少量字段 |
| `field list` | 获取字段信息(`field get` 的别名) | `--base-id` `--table-id` | 与 `field get` 等价 |
| `field create` | 创建字段 | `--base-id` `--table-id` + (`--name --type` 或 `--fields`) | 支持单字段/批量模式 |
| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 |
| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 |
| `field search-options` | 搜索单选/多选字段的选项 | `--base-id` `--table-id` `--field-id` | 仅 singleSelect/multipleSelect;`--keyword` 模糊过滤,不传返回全部 |
### record (记录管理)
| 命令 | 用途 | 必读 reference | 路由提醒 |
|------|------|----------------|----------|
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference |
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference;`--query`(隐藏别名 `--keyword`)全文搜索 |
| `record list` | 获取记录(`record query` 的别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 与 `record query` 等价 |
| `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 query-empty` | 查询完全没填用户字段的空行 | — | `--base-id` `--table-id`;`--limit` 扫描预算 [1,100],`--cursor` 翻页 |
| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 |
| `record update` | 更新记录 | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;**没有** `--record-id` `--cells` flag |
| `record batch-update` | 把同一份 cells 批量应用到多条记录 | [aitable-record-update.md](./aitable/aitable-record-update.md) | `--record-ids`(≤100)+ `--cells` 共享 patch |
| `record upsert` | 批量创建或更新(有 recordId 走更新,无则创建) | [aitable-record-upsert.md](./aitable/aitable-record-upsert.md) | `--records`/`--records-file`;单次最多 100 条 |
| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 |
| `record share-url` | 批量获取记录分享链接 | [aitable-record-share.md](./aitable/aitable-record-share.md) | `--record-ids`(逗号分隔,单次最多 20 条) |
| `record history-list` | 查询单条记录的变更历史 | [aitable-record-history.md](./aitable/aitable-record-history.md) | `--record-id` 单条;`--offset`/`--limit`(≤50) |
| `record primary-doc-get` | 查询记录的主键文档 nodeId | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 无文档时返回 `no record` 错误 |
| `record primary-doc-create` | 为记录创建主键文档 | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 幂等;`--field-id` 须 primaryDoc 类型 |
### view (视图管理)
@@ -264,8 +277,9 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
要点:
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
- `dashboard share get` 可能返回 `404`(`retryable:true`,从未分享甚至刚开启分享后立即查都可能 404),按可重试错误处理,不要误判为参数拼错;行为不稳定,别当"是否已分享"的唯一判据。
- `chart share get` 稳定返回 `success + data`(含 `enabled`),从未分享时 `enabled=false`,不会 404。
- `dashboard share update` 开 ORG 分享后 `shareType` 回显 `"[1]"`(服务端已知问题);`chart share update` 正确回显 `ORG`。详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)。
### 导出数据(两阶段轮询)
@@ -38,8 +38,10 @@ 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 必须与文件类型一致,不能留空)
# 留空或用 curl 默认的 application/x-www-form-urlencoded 都会被 OSS 拒为 403
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdf
# 例:.txt → text/plain,.png → image/png,.xlsx → application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
# 3. 写入记录
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
@@ -15,8 +15,10 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
## 要点
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断
- `dashboard share get` 可能返回 `404`(`retryable:true`)——从未分享、甚至刚开启分享后立即查都可能 404;`data` 为 `{}`。按可重试错误处理,不要误判为参数拼错。它有时也会返回 `success + data`(如关闭分享之后),行为不稳定,别用它当"是否已分享"的唯一判据。
- `chart share get` 稳定返回 `success + data`(含 `enabled` 等),可用于分享状态判断;从未分享时 `enabled=false`,不会 404。
- ⚠️ **`dashboard share update` 开启 ORG 分享后返回的 `shareType` 是 `"[1]"`(未映射回 `ORG`,服务端已知问题)**;`chart share update --share-type ORG` 则正确返回 `shareType="ORG"`。判断 dashboard 是否 ORG 分享时对 `"[1]"` 做兼容。
- `chart share update` / `dashboard share update` 的 `--enabled` 是字符串 flag:`--enabled false`(空格)和 `--enabled=false` 都能正确关闭分享。
## dashboard 子命令
@@ -25,18 +27,25 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
| `dashboard get` | 获取仪表盘详情(含 charts 列表) | `--base-id` `--dashboard-id` | — |
| `dashboard create` | 创建仪表盘 | `--base-id` + (`--config` 或 `--name`) | `--name` 简化版创建空看板;`--config` 传完整 JSON |
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
| `dashboard arrange` | 自动重排仪表盘图表布局 | `--base-id` `--dashboard-id` | 让服务端重新排布 charts 位置 |
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
| `dashboard share get` | 获取仪表盘分享配置 | `--base-id` `--dashboard-id` | 可能 404,见上方要点 |
| `dashboard share update` | 更新仪表盘分享配置 | `--base-id` `--dashboard-id` `--enabled` | `--enabled true` 开启,`--share-type` 用 `ORG`(ORG 回显 `shareType="[1]"`);部分组织禁用了 `PUBLIC` 公开分享;若报 `Illegal argument`,改用 `ORG`(组织内分享)。`--enabled false` 关闭 |
## chart 子命令
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` `--layout` |
| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` |
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` |
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 |
| `chart share get` | 获取图表分享配置 | `--base-id` `--dashboard-id` `--chart-id` |
| `chart share update` | 更新图表分享配置(`--enabled true/false`,开启配 `--share-type`,用 `ORG`;部分组织禁用了 `PUBLIC` 公开分享;报 `Illegal argument` 时改用 `ORG`) | `--base-id` `--dashboard-id` `--chart-id` `--enabled` |
> `chart create` 的 `--layout` 是**必填**(12 列网格布局,如 `{"x":0,"y":0,"w":6,"h":4}`);不传本地校验直接拒。`chart update` 的 `--config` 也**必填**——即便只想改 layout,也要带完整 config,否则服务端拒绝。
## 配置获取流程
@@ -118,23 +118,25 @@ dws aitable import data --import-id <importId> --table-id <TABLE_ID> --format js
用户给文件让导入 AI 表格时,路径选择决定成败:
> ⚠️ **`import upload` 没有 `--file` flag**(传了会报 unknown flag)。它只申请上传凭证,必填 `--file-name` + `--file-size`,拿到 `uploadUrl` 后要**自己 curl PUT 上传文件**(Content-Type 留空,见上文三步流程),再 `import data`。想省事直接用 `aitable_import_via_task.py` 脚本,它把这三步包好了。
| 用户原话 | 链路 | 命令 / 脚本 | 行为 |
|---------|------|------------|------|
| "把这个 Excel 导入到 AI 表格"(无指定目标表) | **文件导入任务** | `python scripts/aitable_import_via_task.py <baseId> <file>` 或 `dws aitable import upload --base-id B --file ./x.xlsx` + `dws aitable import data --import-id <ID>` | 服务端解析文件,**新建数据表**,自动识别表头 |
| "把这个 Excel 导入到 AI 表格"(无指定目标表) | **文件导入任务** | `python scripts/aitable_import_via_task.py <baseId> <file>`(推荐)或手动三步 `import upload --file-name x.xlsx --file-size <字节>` → curl PUT → `import data --import-id <ID>` | 服务端解析文件,**新建数据表**,自动识别表头 |
| "把这个 Excel 导入新表 / 自动建表" | 同上 | 同上 | 同上 |
| "把这批记录追加到已有的『成员表』里" | **记录批量写入** | `python scripts/import_records.py <baseId> <tableId> <file>` | 走 `record create`,**写入已有 tableId**,需要字段名匹配 |
| "Excel 列名和表字段对不上但要追加" | 文件导入 + 追加 + 字段映射 | `dws aitable import upload --base-id B --file ./x.xlsx` → `dws aitable import data --import-id <ID> --table-id <TBL> --field-mapping '{"目标":"源"}'` | 服务端按映射追加 |
| "Excel 列名和表字段对不上但要追加" | 文件导入 + 追加 + 字段映射 | 三步导入后 `import data --import-id <ID> --table-id <TBL> --field-mapping '{"目标":"源"}'` | 服务端按映射追加 |
## 大表 / 长任务超时续等
默认整体轮询超时 5 分钟。大表导出/导入超时后命令会返回 `taskId` / `importId`,用同命令带 ID 续等:
单次等待窗口很短:`export data` 只有 `--timeout-ms`(默认且**上限 30000 = 30 秒**,没有 `--timeout-sec`,传了会报 unknown flag);`import data` 用 `--timeout`(秒,默认且推荐最大值 30)。窗口内没跑完,命令会返回 `taskId` / `importId`,用同命令带 ID 反复续等即可:
```bash
# 续等导出
dws aitable export data --base-id <B> --task-id <ID> --output ./out.xlsx
# 续等导出:拿到 downloadUrl 后再 curl 下载(见下方警告)
dws aitable export data --base-id <B> --task-id <ID> --timeout-ms 30000
# 续等导入
dws aitable import data --import-id <ID>
```
或一次性把 timeout 提到 15 分钟:`--timeout-sec 900`。
> ⚠️ **`--output` 不会保存导出的 xlsx**:`--output` 是隐藏的全局 flag,作用是把命令的 **JSON 输出**写到文件,实测只生成一个 0 字节文件,不会下载导出内容。正确做法是从 `export data` 返回里取 `downloadUrl`,再 `curl -L "<downloadUrl>" -o out.xlsx` 下载。想省事直接用 `aitable_export_via_task.py` 脚本(它负责轮询 + 下载)。
@@ -50,8 +50,8 @@ dws aitable form share get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 返回 viewId/name/title/createdAt |
| `form get` | 按 viewId 取单个表单 | `--base-id` `--table-id` `--view-id` | 内部基于 list_form_views 过滤 |
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 每条返回 viewId/name;新建表单**无 title 且 createdAt=0**,改过(form update)后才出现 title 和真实 createdAt |
| `form get` | 按 viewId 取单个表单 | `--base-id` `--table-id` `--view-id` | 客户端按 viewId 过滤后 `data` **即该表单对象**(不是 formViews 数组);viewId 不存在返回 `form view ... not found` 错误 |
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | viewType=FormDesigner |
| `form update` | 更新表单 | `--base-id` `--table-id` `--view-id` | `--title`/`--name`(等价)和 `--description` 至少传一项;同时传 title/name 时 title 优先 |
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` `--yes` | 不可逆 |
@@ -115,6 +115,6 @@ dws aitable form share update --base-id BASE_ID --table-id TABLE_ID --view-id VI
## 返回结构补充
- `form list` 返回 `data.formViews[]`,**每条仅含** `viewId/name/title/createdAt`;`shareFormUuid` 不在此返回,请用 `form share get` 单独获取。
- `form get` 返回结构与 `form list` 完全一致(`data.formViews[]`),仅含一条记录(与请求 viewId 一致)。Agent 提取时仍走 `data.formViews[0]`。
- `form list` 返回 `data.formViews[]`,**每条含** `viewId/name`(+ `createdAt`);**新建表单没有 `title` 字段且 `createdAt=0`**,只有在 `form update` 碰过之后才会出现 `title` 和真实 `createdAt`。`shareFormUuid` 不在此返回,请用 `form share get` 单独获取。
- `form get` 的 `data` **就是命中的那一条表单对象**(如 `{viewId, name, createdAt, title?, shareFormUuid?}`),不是 `formViews` 数组。Agent 直接读 `data.viewId` / `data.name` 即可,**不要**再取 `data.formViews[0]`。服务端的 viewIds 过滤参数当前不生效,CLI 在客户端按 viewId 精确筛出单条;传了不存在的 viewId 会返回 `form view <id> not found in table` 错误。
- `form field list` 仅返回**未隐藏**的字段;`hidden=true` 的字段不在此返回,如需查看全部字段请用 `field get`。
@@ -17,7 +17,9 @@ dws aitable record primary-doc-get --base-id BASE_ID --table-id TABLE_ID --recor
- `--table-id`(必填):Table ID
- `--record-id`(必填):Record ID
**返回:** `data.nodeId` — 主键文档的 nodeId,可直接传给 `dws doc read/update` 的 `--node` 参数。若该记录尚未创建主键文档,`nodeId` 为 null。
**返回:** `data.nodeId` — 主键文档的 nodeId,可直接传给 `dws doc read/update` 的 `--node` 参数。
> **该记录尚未创建主键文档时**:不会返回 `nodeId: null`,而是 `status=error`、`data={}`,`error={code:"-1", message:"no record", type:"SYSTEM_ERROR", retryable:true}`。要判断"有没有主键文档",看是否命中这个 `no record` 错误,而不是判断 `nodeId` 是否为 null。需要文档时改用 `primary-doc-create`(幂等,已存在则直接返回)。
### 创建主键文档
@@ -25,12 +25,11 @@ dws aitable record history-list \
"data": {
"histories": [
{
"type": "field_change", // 变更类型
"action": "update", // 操作动作: create / update / delete
"newValue": "{\"...\":\"...\"}", // 变更后的值(JSON 字符串)
"oldValue": "{\"...\":\"...\"}", // 变更前的值(JSON 字符串)
"type": "row", // 变更类型,实测均为 "row"(行级变更)
"action": "updateRecords", // 操作动作:appendRow(新增行) / updateRecords(更新记录)
"newValue": "{\"fldX\":{\"dataType\":\"STRING\",\"value\":\"改后值\"}}", // 变更后的值(JSON 字符串,按 fieldId 组织)
"oldValue": "{\"fldX\":{\"dataType\":\"STRING\",\"value\":\"改前值\"}}", // 变更前的值(appendRow 新增行时无此字段)
"operateTime": 1733123456789, // 操作时间(毫秒级时间戳)
"typeChangedFields": "{...}", // 类型变更的字段信息(JSON 字符串)
"version": 7 // 版本号(单调递增)
}
]
@@ -38,14 +37,14 @@ dws aitable record history-list \
}
```
`newValue` / `oldValue` / `typeChangedFields` 是 JSON 字符串(不是 JSON 对象),需要二次 `JSON.parse` 才能拿到结构化值。
`newValue` / `oldValue` 是 JSON 字符串(不是 JSON 对象),需要二次 `JSON.parse` 才能拿到结构化值;解析后是 `{fieldId: {dataType, value}}` 结构。`appendRow`(新增行)事件没有 `oldValue`。实测返回里**没有** `typeChangedFields` 字段。
## 字段含义速查
| 字段 | 用途 |
|------|------|
| `type` | 高层分类:`record_create` / `field_change` / `record_delete` 等。先按 type 过滤大类。 |
| `action` | 三态:`create` / `update` / `delete`。比 type 粗,但便于按"动作"统计。 |
| `type` | 实测均为 `row`(行级变更),服务端未按 `record_create` / `field_change` 细分。 |
| `action` | 底层操作名:`appendRow`(新增行)/ `updateRecords`(更新记录)。按"动作"统计时以这两个值为准。 |
| `version` | 单调递增整数;同一 record 越新值越大。**用作"上一条 vs 这一条"的稳定排序键**。 |
| `operateTime` | 毫秒时间戳;可格式化成可读时间。多条同 version 的极端场景用 operateTime 兜底排序。 |
@@ -74,20 +73,22 @@ dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --
```bash
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --format json \
| jq '[.data.histories[] | select(.action == "update")][0].oldValue'
| jq '[.data.histories[] | select(.action == "updateRecords")][0].oldValue'
```
### 4. 找出删除事件(如果存在 delete history)
### 4. 只看更新事件(排除新增行)
```bash
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
| jq '.data.histories[] | select(.action == "delete") | {version, operateTime}'
| jq '.data.histories[] | select(.action == "updateRecords") | {version, operateTime}'
```
> 记录被 `record delete` 删除后,其历史不再返回(`histories` 为空数组),无法通过本命令回溯删除事件。
## 注意事项
- 一次只能查一条 record;如需批量审计多条记录请循环调用。
- 仅返回**字段值变更**与**记录生命周期事件**;视图、字段定义、表结构变更不在此 history 里。
- 仅返回**新增行**(appendRow)与**字段值变更**(updateRecords);记录被删除后其历史不再可查(返回空)。视图、字段定义、表结构变更不在此 history 里。
- 历史保留时长由 server 决定,过老的记录可能不再返回。
## 与其他 record 命令的关系
@@ -79,21 +79,21 @@ dws aitable view update timebar --view-id GANTT_ID --official-holiday=true
### view update aggregate(仅 Grid)
值是 `map[fieldId]→AggregateAction string`;传 null 清除某个字段聚合。
值是 `map[fieldId]→AggregateAction string`。**设置**聚合可用;**清除**聚合当前无效(见下方警告)。
| flag | 类型 | 说明 |
|------|------|------|
| `--field-id` | string | 配合 `--action` 设置**单字段**聚合 |
| `--action` | string | `SUM`/`AVG`/`MAX`/`MIN`/`MEDIAN`/`RANGE`/`TOTAL`/`DISTINCT`/`EXIST`/`UN_EXIST`/`CHECKED`/`EARLIEST_DATE` 等(按字段类型可用) |
| `--clear-field-id` | string (CSV) | 一/多个字段 ID,清除其聚合 |
| `--clear-field-id` | string (CSV) | 一/多个字段 ID,本意是清除其聚合,但**当前服务端不支持清除,静默无效**(见下方警告) |
| `--json` | JSON | 完整 aggregate map |
```bash
dws aitable view update aggregate --view-id GRID_ID --field-id fldX --action SUM
dws aitable view update aggregate --view-id GRID_ID --clear-field-id fldA,fldB
dws aitable view update aggregate --view-id GRID_ID --json '{"fldX":"AVG","fldY":null}'
```
> ⚠️ **当前无法清除已设置的聚合(服务端限制)**:`--clear-field-id fldA,fldB` 与 `--json '{"fldX":null}'` 两种清除写法都返回 `success`,但用 `view get aggregate` 复核会发现聚合**原样不动**——是静默无效,不是真的清掉。这是服务端没有清除语义所致,直至服务端修复前不要依赖它。改聚合方式可行(重新 `--action` 覆盖成别的),只是无法回到"无聚合"。
### view update field-widths(仅 Grid)
| flag | 类型 |
@@ -108,9 +108,11 @@ dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB
### view update visible-fields(通用)
整组替换可见字段列表与顺序。首列字段(primaryDoc)必须保留在数组第一位。
整组替换可见字段列表与顺序,同时兼作**隐藏/显示**入口。首列字段(primaryDoc)必须保留在数组第一位。
> ⚠️ 注意:服务端**只接受 reorder,不接受真"隐藏字段"**——如果传入的列表比当前 columns 短,缺失的字段不会被隐藏。需要真正隐藏字段请到 AI 表格 Web UI。
> **传入的列表既定顺序又定可见性**:传一个比当前 columns 短的列表,缺失的字段会被真正隐藏(该字段在 `view list` 的 `custom.hiddenFields` 里变 `true`);再传回全量列表即可解除隐藏(`hiddenFields` 变 `false`)。
>
> ⚠️ **查隐藏状态别看这里**:`view get visible-fields` 返回的数组**包含已隐藏字段**(列的完整顺序),看不出谁被隐藏。要确认隐藏状态,读 `view get`(view list)里该视图的 `custom.hiddenFields`(`{fieldId: true|false}`)。
| flag | 类型 |
|------|------|
@@ -118,7 +120,9 @@ dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB
| `--json` | string 数组 JSON(与 `--field-ids` 同传时 `--json` 优先) |
```bash
dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,fldA,fldB
# 只保留首列和 fldA,其余字段被隐藏
dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,fldA
# 传回全量列表解除隐藏
dws aitable view update visible-fields --view-id VIEW_ID --json '["fldPrimary","fldA","fldB"]'
```
@@ -54,7 +54,7 @@ dws aitable view update frozen-cols --view-id VIEW_ID --count 0
# 查询当前冻结列数
dws aitable view get frozen-cols --view-id VIEW_ID --format json
# → {"data": {..., "count": 1}} count 为 null 表示视图未显式设置
# → {"data": {..., "count": 1}} 未显式设置时整个 count 键缺失(不是返回 null)
```
`--count` 必须 ≥ 0;负数会被拒绝。
@@ -69,7 +69,7 @@ dws aitable view update row-height --view-id VIEW_ID --cell-height 56
# 查询当前行高
dws aitable view get row-height --view-id VIEW_ID --format json
# → {"data": {..., "cellHeight": 56}} cellHeight 为 null 表示视图未显式设置(前端按 32 渲染)
# → {"data": {..., "cellHeight": 56}} 未显式设置时整个 cellHeight 键缺失(不是返回 null;前端按 32 渲染)
```
## 数据高亮规则(条件填色,仅 Grid)
@@ -11,7 +11,7 @@
1. 未按"阶段 1"做人员解析 → `--users` 传入部门 ID 而非员工 userId,脚本虽内置回退但会浪费一次失败的接口调用
2. 未按"阶段 0"判断报表类型 → 用户说"汇总"被理解成"明细",导致输出粒度错误
3. 未按"列选择"判断是否传 `--column-keywords` → 用户要"迟到情况报表"被输出成全字段默认报表
4. 未按"错误处理"规则处理 403 / `HSF_ILLEGALPARAMS` → 把环境错误当成业务错误反馈给用户
4. 未按"错误处理"规则处理无权限错误(errorCode 6001「无权限操作」/ `AUTH_ERROR`)/ `HSF_ILLEGALPARAMS` → 把环境错误当成业务错误反馈给用户
5. 未按"阶段 4"返回结果 → 把 Excel 内容贴在对话里,或者裸 userId 直接输出
**执行前自检(必须能在心中回答)**:
@@ -22,7 +22,7 @@
如果上述任何一项答不出,**回到本文档对应章节重新阅读**,禁止凭记忆/想象组装命令。
**前提**:当前用户必须是钉钉管理员,否则 report 系列接口返回 403 权限错误。
**前提**:当前用户必须是钉钉管理员,否则 report 系列接口返回业务权限错误(`server_error_code` 为 `AUTH_ERROR`、errorCode `6001`「无权限操作」),**不是 HTTP 403**。提示需要管理员权限,不要重试。
## 核心原则
@@ -46,7 +46,7 @@ Agent 解析用户意图(报表类型、人员范围、时间范围、关注
- 所有 `dws` 命令必须携带 `--format json`
- 时间参数 `--start` / `--end` 格式必须为 `yyyy-MM-dd HH:mm:ss`
- 字段 ID 与字段名的映射必须从 `report columns` 实时建立,禁止硬编码
- 任何接口失败(含 403)必须向用户清晰报错,禁止静默吞掉
- 任何接口失败(含无权限错误 6001/`AUTH_ERROR`)必须向用户清晰报错,禁止静默吞掉
## 涉及工具
@@ -246,7 +246,7 @@ python scripts/attendance_report_checkin.py \
- 脚本依赖 `openpyxl`,若未安装需先 `pip install openpyxl`
- 脚本摘要输出到 stdout,进度日志输出到 stderr
- 首次调试可加 `--inspect` 参数查看首条记录原始结构
- 首次调试可加 `--inspect` 参数查看首条记录原始结构(`attendance_report_detail.py` / `attendance_report_monthly.py` / `attendance_report_daily.py` / `attendance_report_checkin.py` 支持;**`attendance_report_record.py` 无 `--inspect` 参数**)
- 脚本执行失败(exit ≠ 0)时,stderr 中有具体错误信息
### 阶段 4: 返回结果给用户
@@ -488,11 +488,11 @@ python scripts/attendance_report_checkin.py \
| 错误 | 原因 | 处理方式 |
|------|------|---------|
| 权限错误(403) | 当前账号非管理员 | 提示需要管理员权限,不要重试 |
| 权限错误(errorCode 6001「无权限操作」/ `AUTH_ERROR`,非 HTTP 403) | 当前账号非管理员 | 提示需要管理员权限,不要重试 |
| userId 无效 | 用户 ID 错误或已离职 | 脚本跳过并在摘要中标注 |
| 时间区间超长 | 接口可能性能不佳 | 提示"超过 1 年的数据建议分阶段导出" |
| openpyxl 未安装 | 环境缺包 | 输出 `pip install openpyxl` 安装提示 |
| 脚本执行失败 | 接口异常/配置问题 | 将 stderr 错误信息转告用户,可加 `--inspect` 重试 |
| 脚本执行失败 | 接口异常/配置问题 | 将 stderr 错误信息转告用户,可加 `--inspect` 重试(`attendance_report_record.py` 不支持 `--inspect`) |
## 使用示例
+47 -30
View File
@@ -1,6 +1,6 @@
# 考勤 (attendance) 命令参考
> **【开源版命令可用性提示】** 当前开源 dws 已落地 P0 5 条命令:`attendance check result`、`attendance check record`、`attendance group search`、`attendance vacation balance`、`attendance vacation types`。文档中其余 P1 阶段命令(`class` / `overtime` / `adjustment` / `group settings` / `report` / `schedule` / `boss-check` 等共 28 条)暂未在当前开源二进制暴露,调用会返回 `unknown command`,将在后续批次落地。
> **【命令可用性提示】** 当前 dws 已注册全部考勤子命令组(`record` / `check` / `approve` / `shift` / `schedule` / `class` / `adjustment` / `overtime` / `group` / `summary` / `rules` / `selfsetting` / `globalsetting` / `vacation` / `checkin` / `report` / `boss-check`)。查询与写操作大多可直接调用后端,不会再返回 `unknown command`,不要以"开源版不支持"为由拒答。个别命令返回受账号权限和组织数据影响:`report` 系列仅管理员可用;非管理员或数据为空时可能返回空列表或权限错误。执行前可用 `dws <cmd> --help` 或 `--dry-run` 验证参数。
> **【必读】日期范围严格计算规则 — 所有含 --start/--end 或 --from/--to 的命令均适用**
>
@@ -89,6 +89,19 @@ Flags:
返回每条记录含:用户 ID、实际打卡时间、打卡地址、打卡经纬度、打卡类型(OnDuty/OffDuty)、定位方式(Map/Wifi/etc)。时间跨度不超过 1 个月。
### 查询个人某日考勤详情
```
Usage:
dws attendance record get [flags]
Example:
dws attendance record get --user 011769261608 --date 2026-03-08
Flags:
--user string 钉钉用户 ID (必填,别名 --users 亦可)
--date string 查询日期, 格式 YYYY-MM-DD (必填)
```
查询单个用户在**某一天**的考勤详情(区别于 `check record`/`check result` 的多人时间段批量查询)。返回 `result` 对象含 `isHasSchedule`(当日是否有排班)、`isRest`(是否休息日)、`isUnSigned`(是否未打卡)、`recordList`(打卡明细)、`approveList`(当日审批单)、`workOvertime`、`workTimeDesc` 等字段。`--user` 只接受**单个** userId;查 userId 用 `dws contact user search --query "姓名"`。
### 查询审批单(补卡/加班/请假/出差外出)
```
Usage:
@@ -143,21 +156,20 @@ Usage:
dws attendance schedule import [flags]
Example:
dws attendance schedule import --group-id 123456 \
--schedules '[{"userId":"user001","classId":123,"workDate":"2026-04-22","checkBeginTime":"09:00","checkEndTime":"18:00"}]' \
--schedules '[{"userId":"user001","classId":123,"workDate":"2026-04-22","isRest":"N"}]' \
--yes
Flags:
--group-id string 考勤组(必填,传入考勤组ID)
--schedules string 排班记录 JSON 数组(必填)
--yes 跳过确认提示
--group-id string 考勤组(必填,传入考勤组ID);--help 主名为 --groupId,--group-id 为别名
--schedules string 排班记录 JSON 数组(必填);--help 主名为 --scheduleVOS,--schedules 为别名
--yes 跳过确认提示;--help 主名为 --user-say-yes,--yes 为别名
```
为排班制考勤组导入排班记录。`--schedules` 为 JSON 数组,每条记录包含:
- `userId`: 员工ID
- `classId`: 班次ID
- `workDate`: 工作日期(YYYY-MM-DD),如 2026-04-22
- `checkBeginTime`: 开始打卡时间
- `checkEndTime`: 结束打卡时间
- `isRest`: 是否休息日 Y/N(可选)
- `userId`: 员工ID(必填)
- `classId`: 班次ID(必填)
- `workDate`: 工作日期(YYYY-MM-DD),如 2026-04-22(必填)
- `isRest`: 是否休息日 Y/N(**必填**,服务端要求传入)
- `checkBeginTime` / `checkEndTime`: 开始/结束打卡时间(可传,但当前不会进入后端 payload,实际打卡时段以 `classId` 对应班次为准)
#### AI 调用 `schedule import` 的二次确认流程
@@ -305,7 +317,7 @@ Flags:
--adjustment-id int 补卡规则主键 ID (必填)
```
根据补卡规则主键 ID 查询对应的补卡规则详情。主键 ID 可从 `adjustment search` 返回结果中提取,也有可能来源于用户手动输入。**注意:已被删除或被更新覆盖的补卡规则无法查询到。**
**注意:本命令当前拿不到有效的补卡规则详情,不要依赖它。** 服务端对任意 `--adjustment-id`(含不存在的 ID)都返回同一个 `{"success":true,"有效期类型":"..."}`,不返回规则明细,也无法据此判断规则是否存在;且 `adjustment search` 返回的默认补卡规则 `entityVO.id` 为 `null`,`search → get` 取 id 的链路走不通。补卡规则内容请直接看 `adjustment search` 的返回结果。
### 分页查询加班规则,支持按名称搜素
```
@@ -580,12 +592,17 @@ Flags:
Usage:
dws attendance summary [flags]
Example:
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"
dws attendance summary --user USER_ID --date 2026-03-12 --stats-type week
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
Flags:
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
--user string 钉钉用户 ID (必填)
--date string 查询日期, 格式 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss (必填)
--stats-type string 统计类型: week 周统计 / month 月统计 (必填)
--tag-name string 标签名称 (可选)
--user string 钉钉用户 ID (必填)
```
`summary` 必须同时传 `--user`、`--date`、`--stats-type`,缺一即报错(如 C0002)。`--stats-type` 只能是 `week`(周统计)或 `month`(月统计)。
### 查询考勤组与考勤规则
```
Usage:
@@ -847,10 +864,10 @@ Example:
dws attendance vacation balance --users userId1,userId2 --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890
Flags:
--users string 目标员工 ID 列表, 逗号分隔 (必填)
--leave-code string 假期规则 code (选填,不传则查询所有假期规则余额)
--leave-code string 假期规则 code (必填,服务端要求非空,不传返回 INVALID_PARAMS)
```
调用 MCP 工具 get_leave_balance_quota 查询指定员工的假期余额。例如:查询某员工年假还剩多少、病假额度等。`--leave-code` 可通过 `vacation types` 获取;不传 `--leave-code` 时查询所有假期规则余额。认证信息(corpId、opUserId)由系统自动注入。
调用 MCP 工具 get_leave_balance_quota 查询指定员工的假期余额。例如:查询某员工年假还剩多少、病假额度等。`--leave-code` 可通过 `vacation types` 获取。**注意:`--help` 虽标"选填",但服务端要求 `leaveCode` 非空,实际必填;不传会返回 `INVALID_PARAMS`(corpId、opUserId、leaveCode、targetUserIds 不能为空)。** 若要一次查所有假期规则余额,必须走 [attendance-vacation.md](./attendance-vacation.md) 工作流脚本逐个规则遍历。认证信息(corpId、opUserId)由系统自动注入。
如用户需要“所有假期规则余额 / 导出假期余额列表 / 所有假期规则余额 Excel / 按截图样式导出假期余额”,必须先读取 [attendance-vacation.md](./attendance-vacation.md),再按其中工作流调用脚本生成 Excel。
@@ -867,7 +884,7 @@ Flags:
--end string 查询结束日期, 格式 YYYY-MM-DD (必填)
```
调用 MCP 工具 get_leave_balance_records 查询指定员工的假期余额变更记录。例如:查询某员工年假变更历史、请假扣减记录等。`--leave-code` 可通过 `vacation types` 获取。认证信息(corpId、opUserId)由系统自动注入。
调用 MCP 工具 get_leave_balance_records_v2 查询指定员工的假期余额变更记录。例如:查询某员工年假变更历史、请假扣减记录等。`--leave-code` 必填,可通过 `vacation types` 获取。认证信息(corpId、opUserId)由系统自动注入。
### 更新假期规则(写场景接口,必须走二次确认流程)
@@ -1002,7 +1019,7 @@ Usage:
dws attendance checkin records [flags]
Example:
dws attendance checkin records \
--operator-staff-id op001 --staff-ids user001,user002 --start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00"
--operator-corp-id corp001 --operator-staff-id op001 --staff-ids user001,user002 --start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00"
Flags:
--end string 结束时间, 格式 yyyy-MM-dd HH:mm:ss(必填)
--operator-corp-id string 操作者企业 ID(必填)
@@ -1032,7 +1049,7 @@ Flags:
用户说"班次详情/某个班次的具体信息" → `class search --name "..."`(search 直出,直接返回详情)。`class get` 仅在需要按已知 classId 精确查询时使用
用户说"更新班次/修改班次/班次改名/修改上下班时间" → `class update`
用户说"补卡规则/补卡设置" → `adjustment search`(返回结果已包含全量属性,无需再调 get)
用户说"补卡规则详情/某条补卡规则的具体信息" → `adjustment search --name "..."`(search 直出)。`adjustment get` 仅在需要按已知 adjustmentId 精确查询时使用
用户说"补卡规则详情/某条补卡规则的具体信息" → `adjustment search --name "..."`(search 直出,已含全量属性)。**不要用 `adjustment get`**:当前服务端对任意 id 都只返回"有效期类型"、拿不到规则明细
用户说"加班规则/加班设置/加班计算" → `overtime search`(返回结果已包含全量属性,无需再调 get)
用户说"加班规则详情/某条加班规则的具体信息" → `overtime search --name "..."`(search 直出)。如需查已删除/被覆盖的历史记录 → `overtime get`
用户说"考勤组列表/有哪些考勤组" → `group search`
@@ -1068,7 +1085,7 @@ Flags:
```bash
# 导入排班记录
dws attendance schedule import --group-id 123456 \
--schedules '[{"userId":"user001","classId":123,"workDate":"2026-04-22","checkBeginTime":"09:00","checkEndTime":"18:00"}]' \
--schedules '[{"userId":"user001","classId":123,"workDate":"2026-04-22","isRest":"N"}]' \
--yes --format json
# 获取排班记录 — 禁止直接调用,必须走 attendance-schedule.md 排班查询导出工作流
@@ -1123,8 +1140,8 @@ dws attendance group update --group-id 123456 --group-vo '{"positions":[{"title"
dws attendance group create --name "研发考勤组" --type FIXED --group-vo '{"defaultClassId":1170996821,"workDayClassList":[0,1170996821,0,0,0,0,0]}' --timeout 10 --format json
dws attendance group create --name "自由工时分组" --type NONE --timeout 10 --format json
# 查看考勤统计摘要
dws attendance summary --user <USER_ID> --date "2026-03-12 15:00:00" --format json
# 查看考勤统计摘要(--stats-type 必填:week 周统计 / month 月统计)
dws attendance summary --user <USER_ID> --date 2026-03-12 --stats-type week --format json
# 查看考勤组和规则
dws attendance rules --date 2026-03-14 --format json
@@ -1157,13 +1174,13 @@ dws attendance report query-leave --users userId1,userId2 \
dws attendance vacation types --format json
# 查看指定员工假期余额
dws attendance vacation balance --users userId1,userId2 --format json
dws attendance vacation balance --users userId1,userId2 --leave-code <假期类型code> --format json
# 查看指定员工某类假期余额
dws attendance vacation balance --users userId1 --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --format json
# 查看指定员工假期余额变更记录
dws attendance vacation records --user USER_ID --start 2026-04-01 --end 2026-04-22 --format json
dws attendance vacation records --user USER_ID --leave-code <假期类型code> --start 2026-04-01 --end 2026-04-22 --format json
# 更新假期规则名称
dws attendance vacation update-type --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
@@ -1202,7 +1219,7 @@ dws attendance vacation save-balance --target user001 \
--num 8 --reason "绩效奖励发放3天" --format json
# 查询签到记录
dws attendance checkin records --operator-staff-id op001 --staff-ids user001,user002 \
dws attendance checkin records --operator-corp-id corp001 --operator-staff-id op001 --staff-ids user001,user002 \
--start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00" --format json
```
@@ -1233,7 +1250,7 @@ dws attendance checkin records --operator-staff-id op001 --staff-ids user001,use
- `class get` 的 `--class-id` 必填,班次 ID 可从 `class search` 结果中提取
- `class search` 返回结果已包含全量属性,无需再调用 `class get`;`class get` 仅在需要按已知 classId 精确查询时使用
- `class update` 的 `--class-id` 必填,其余均可选,仅需对要修改的字段赋值,未传字段会自动从已有配置补充;由于保存班次耗时较久,建议加 `--timeout 10`
- `adjustment search` 返回结果已包含全量属性,无需再调用 `adjustment get`;`adjustment get` 仅在需要按已知 adjustmentId 精确查询时使用
- `adjustment search` 返回结果已包含全量属性;`adjustment get` 当前服务端对任意 id 都只返回"有效期类型"、无规则明细,不可用,补卡规则详情一律看 `adjustment search` 返回
- `overtime search` 返回结果已包含全量属性,无需再调用 `overtime get`;`overtime get` 仅在需要按已知 overtimeId 查询时使用(包括已删除/被覆盖的历史记录)
- `adjustment search` / `overtime search` 分页字段为 `--page` 和 `--limit`,不传时自动使用默认值 1 / 20
- `group search` 的分页字段为 `--page` 和 `--limit`,不传时自动使用默认值 1 / 20
@@ -1242,7 +1259,7 @@ dws attendance checkin records --operator-staff-id op001 --staff-ids user001,use
- `group update` 的 --group-id 必填,其余均可选,至少需指定一个修改项;仅需对要修改的字段赋値,未传字段会从已有配置自动补充;修改打卡地址/wifi/蓝牙等复杂子对象时用 `--group-vo` 传入完整 JSON;`--group-vo` 与单字段 flag 同时传入时单字段 flag 优先级更高
- `group create` 的 `--name` 和 `--type` 必填,`--type` 必须为 FIXED/TURN/NONE 之一;type=FIXED 时 `--group-vo` 必须包含 `workDayClassList`(非空)和 `defaultClassId`(非 null);由于保存考勤组耗时较久,建议加 `--timeout 10`
- `group filtered-get` 的 `--group-id` 必填,`--member/--position/--wifi/--bles` 均可选,默认 false。**返回结果中如含成员 userId 列表,必须调用 `dws contact user get --ids <userId1>,<userId2>,...`(支持逗号分隔传多个 ID),将 userId 转换为员工姓名后再输出;不得直接输出裸 userId。**
- `summary` 的 `--date` 格式: yyyy-MM-dd HH:mm:ss(如 `2026-03-12 15:00:00`)
- `summary` 必须同时传 `--user`、`--date`、`--stats-type`(week 周统计 / month 月统计),三者缺一即报错;`--date` 支持 `YYYY-MM-DD` 或 `yyyy-MM-dd HH:mm:ss`;`--tag-name` 可选
- `rules` 的 `--date` 支持 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss 两种格式
- `selfsetting get/save` 的 `--setting-scene` 必须是 `checkRemind`、`fastCheck`、`checkResultNotify`、`lackRemind`、`personalAttendStatNotify`、`bossAttendStatNotify` 之一
- `selfsetting get/save` 的 MCP 入参 `userId` 为必填;CLI 的 `--user` 也必填,必须显式传入目标用户 ID
@@ -1255,8 +1272,8 @@ dws attendance checkin records --operator-staff-id op001 --staff-ids user001,use
- 用户 ID 需从 `contact user get-self` 或 `aisearch person` 获取
- 考勤组 ID 需从 `rules` 命令返回结果中获取
- `vacation types` 无需任何参数,认证信息自动注入
- `vacation balance` 的 `--users` 为目标员工 ID 列表,逗号分隔;`--leave-code` 选填,可通过 `vacation types` 获取
- `vacation records` 的 `--start/--end` 使用 YYYY-MM-DD 格式,CLI 自动转换为毫秒时间戳;`--leave-code` 选填
- `vacation balance` 的 `--users` 为目标员工 ID 列表,逗号分隔;`--leave-code` 服务端要求非空、实际必填(`--help` 标"选填"不准),不传返回 `INVALID_PARAMS`,可通过 `vacation types` 获取
- `vacation records` 的 `--start/--end` 使用 YYYY-MM-DD 格式,CLI 自动转换为毫秒时间戳;`--leave-code` 必填(不传无法查询),底层 MCP 工具为 `get_leave_balance_records_v2`
- `vacation balance` 和 `vacation records` 的认证参数(corpId、opUserId)由系统自动注入,无需手动传入
- `vacation update-type` 的 `--leave-code` 必填;其他字段均为可选,但至少需传一个更新字段
- `vacation update-type` 的 `--visibility-rules` 为 JSON 数组字符串,格式:`[{"type":"dept","visible":["1","2","3"]}]`,type 可取值 staff/label/dept

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