Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ba1dcdda4 | ||
|
|
1637ae16c7 | ||
|
|
eee19d7347 | ||
|
|
19f7b59ffb | ||
|
|
c4952d0207 | ||
|
|
ecf2684f58 | ||
|
|
9e9b898dd2 | ||
|
|
17f692e7f1 | ||
|
|
574d9aa2f7 | ||
|
|
1aaaef0274 | ||
|
|
00c037b5be |
@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [1.0.26] - 2026-05-12
|
||||
|
||||
Platform-stability round: Windows PAT-auth browser opener no longer truncates URLs at `&userCode=`, macOS sandbox hosts get an opt-in keychain fallback, and `dws doc download` rejects `axls` nodes before requesting `drive:download` consent. Two new global output formats `-f ndjson` and `-f csv` (matching `larksuite/cli`) land as first-class citizens with real-traffic-verified list detection. The `dws doc comment *` regression tracked in #240 is also resolved — fix is in the market metadata, users just need `dws cache refresh` once.
|
||||
|
||||
### Added
|
||||
|
||||
- **`-f ndjson` and `-f csv` global output formats** (#259, closes #252) — `ndjson` emits one compact JSON record per line (works straight with `jq -c` / `while read` / log pipelines); `csv` goes through `encoding/csv` (RFC-4180 — quoting, embedded newlines, CJK all handled by stdlib) and reuses the existing `-f table` column resolver (`normalizePayload` / `unwrapPrimaryObject` / `extractRowsFromMap` / `rowsFromSlice` / `formatValue`) so table and csv stay visually aligned. After a 7-product real-traffic sweep (contact / chat / doc / mail / todo / minutes / schema), the `preferredListKeys` whitelist was extended to cover the actual DingTalk envelope shapes — `contact user search` (`result`), `chat search` (`result.value`), `doc search` (`documents`), `mail mailbox list` (`emailAccounts`), `todo task list` (`result.todoCards`) — so these commands now degrade into a proper row stream instead of collapsing to a single-line `key,value` blob. Lives in `internal/output/ndjson.go` + `internal/output/csv.go`; `--format` help in `internal/app/flags.go` now lists `ndjson|csv` alongside `json|table|raw|pretty`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` 单聊缺 `--title` 时前置校验** (#250) — 单聊(`--user` / `--open-dingtalk-id`)的底层工具 `send_direct_message_as_user` 在 API 层强制要求 title,缺失时返回误导性的 `发群服务窗会话消息失败`。CLI 现在在 `buildChatMessageSendInvocation` 里前置校验,直接返回 `--title is required for direct messages (--user / --open-dingtalk-id)`;同时把 `Long` help、`--title` flag 描述、Example 和 `skills/references/products/chat.md` 全部对齐为「单聊必填,群聊可选」。群聊行为不变。
|
||||
- **PAT auth URLs were truncated on Windows browser open** (#242, fixes #230) — `cmd /c start <url>` on Windows interprets `&` as a command separator, so PAT URLs containing `&userCode=...` were silently chopped before the userCode segment, and the browser landed on a 0-permission DingTalk page. The retry opener now uses `rundll32 url.dll,FileProtocolHandler`, which passes the URL through verbatim. The PAT response also exposes a copy-safe `data.authorizationUrl` (in addition to the service-provided `data.uri`, which is preserved as-is), and human-readable PAT output prints `PAT_AUTHORIZATION_URL=<full-url>` on its own line so OpenClaw-style host wrappers that swallow or reformat stderr can still capture the full link. Legacy DingTalk hash-route shapes (`https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId=...%26userCode=...`) are normalised back into the working `/fe/old?hash=...#/personalAuthorization?...&userCode=...` form. Regression tests cover the issue-shaped URLs (encoded hash, fragment, `&userCode`) plus the OpenClaw malformed-hash variant.
|
||||
- **`dws doc download` triggered `drive:download` PAT consent for unsupported axls nodes** (#268, fixes #190) — added a `get_document_info` preflight before `download_file`, so online-sheet (`axls`) nodes are rejected locally with guidance to use sheet range tools instead. The preflight reads `extension` from deterministic response paths (no recursive payload scan) and routes its own PAT errors back through `handlePatAuthCheck`, preserving device-flow / host-owned PAT behaviour. Costs one extra MCP roundtrip per `doc download` — deliberate, so the unsupported path fails before consent. Lives in `internal/app/doc_download_preflight.go`; coverage in `internal/app/runner_test.go`.
|
||||
- **macOS sandbox hosts (Codex App etc.) couldn't read/write tokens via Keychain** (#267, fixes #214) — sandboxed macOS environments intercept `security` / Keychain APIs, so every token operation failed. New opt-in `DWS_DISABLE_KEYCHAIN=1` switches macOS to the same file-DEK path Linux uses (DEK at `~/Library/Application Support/dws-cli/dek`, mode `0600`), bypassing the system Keychain. Default behaviour is unchanged — fallback is strictly opt-in because file-DEK is a weaker trust model than Keychain-managed storage (DEK file sits next to ciphertext in the same directory). The Darwin / Linux file-DEK implementation is now shared in `internal/keychain/file_dek.go` (Linux path deduplicated by ~40 lines). Documented in `docs/reference.md` (中英) with the security tradeoff spelt out so users make the choice explicitly.
|
||||
- **`dws doc comment {list,create,create-inline,reply}` returned `PARAM_ERROR - 未找到指定工具`** (fixes #240, also #234) — the four comment tools used to live on an independent `doc-comment` MCP server. After the Portal merged comment functionality into the `doc` server descriptor, the runtime `tools/list` on the merged `doc` server didn't include them, so every `dws doc comment *` call returned the "tool not found" PARAM_ERROR. The market metadata for the `doc` server now declares `serverOverride: "doc-comment"` on all four comment `toolOverrides`, so the existing CLI routing path sends `dws doc comment *` to the still-running `doc-comment` MCP server (which has the tools). No CLI code change was required, but **existing users must run `dws cache refresh` once** to pick up the updated descriptor — without that, the stale local market cache keeps pointing the call at the merged `doc` server and the error persists. Verified post-refresh: dry-run resolves to `https://mcp-gw.dingtalk.com/server/doc-comment` with tool `list_comments`, real calls return normal business responses (e.g. legitimate cross-org authz errors) instead of `未找到指定工具`.
|
||||
|
||||
## [1.0.25] - 2026-05-11
|
||||
|
||||
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands. Plus missing skill references for the already-registered `sheet` and `wiki` products are now shipped.
|
||||
|
||||
### Added
|
||||
|
||||
- **`sheet` (在线电子表格) skill reference + product-overview entry** — the `sheet` product registers **34 envelope tools** covering worksheet CRUD (`create` / `new` / `list` / `info` / `copy_sheet` / `update_sheet`), range read/write (`range read` / `range update` / `append`), dimension ops (`add-dimension` / `insert-dimension` / `delete-dimension` / `move-dimension` / `update-dimension`), merge (`merge-cells` / `unmerge-cells`), find/replace (`find` / `replace`), filter views (`filter-view {create, list, update, delete, update-criteria, delete-criteria}`), sheet-level filters (`create_filter` / `get_filter` / `update_filter` / `delete_filter` / `set_filter_criteria` / `clear_filter_criteria` / `sort_filter`), image write (`write-image`), and async export (`submit_export_job` + `query_export_job`). These were live in the envelope but `skills/references/products/sheet.md` had not shipped and `skills/SKILL.md` 产品总览 didn't list `sheet`, so agents had no reference to consult and were skipping it during intent routing. This release adds the doc, registers `sheet` in 产品总览 + 意图判断决策树, extends `description` to include 在线电子表格, adds a Sheet row to `README.md` / `README_zh.md` "Key Services", and notes the v1.0.25 reality on naming (about a third of `sheet` tools still expose snake_case cli_names pending `CLIAliases` (#246) rollout) and on export (no consolidated `dws sheet export` exists in v1.0.25 — `submit_export_job` + `query_export_job` are the atomic primitives; Pipeline (#247) provides the future plumbing).
|
||||
- **`wiki` (知识库) skill reference + product-overview entry** — the wiki product's 7 envelope tools (`wiki.create_wikiSpace`, `wiki.get_wikiSpace`, `wiki.list_wikiSpaces`, `wiki.search_wikiSpaces`, `wiki.add_member`, `wiki.list_member`, `wiki.update_member`, surfaced as `dws wiki space create / get / list / search` and `dws wiki member add / list / update`) have been registered for a while, but no `skills/references/products/wiki.md` shipped with them, so agents had no per-command reference to consult. This release adds the reference doc, registers `wiki` in `skills/SKILL.md`'s 产品总览 table and 意图判断决策树, mentions 知识库 in the skill `description` frontmatter, adds a Wiki row to `README.md` / `README_zh.md` "Key Services", and removes `wiki` from the "Coming soon" callout (which was now stale).
|
||||
- **`CLIToolOverride.CLIAliases` envelope field** (#246) — lets a single MCP tool register additional cobra command aliases via envelope JSON (e.g. `range read` also accepts `range get`, `member list` accepts `member ls`). Plumbed through the existing `Route.Aliases → cobra.Command.Aliases` path; sibling conflicts are silently dropped by cobra. Lives in `internal/market/registry.go` + `internal/compat/dynamic_commands.go`.
|
||||
- **`json_parse_strict` transform** (#246) — strict-JSON variant of `json_parse` that does **not** fall back to YAML. Use when the upstream tool requires a structured array/object and silently coercing a malformed input to a scalar string would mask a real user error (observed: `filter-view --criteria 'NOT_VALID_JSON'` was being accepted and quietly creating an empty-criteria view). In `internal/compat/transform.go`.
|
||||
- **`CLIToolOverride.Pipeline` + pipeline executor** (#247) — a single CLI command can now orchestrate an ordered sequence of MCP tool calls plus optional HTTP-download sinks, declared entirely in envelope JSON. Motivating use case: the "submit-job → poll-status → download-result" pattern (e.g. sheet export) that previously required per-product hardcoded helpers.
|
||||
- `PipelineStep` supports `type:"call"` (with optional `PollUntilField` / `PollUntilValue` / `PollIntervalSec` / `PollTimeoutSec` for polling loops) and `type:"download"` (resolves `DownloadURLField`, HTTP GETs the body, writes to the path from `OutputFlag`, infers filename for directory paths).
|
||||
- Template language: `$flag.<name>` resolves a user CLI flag by alias; `$step.<idx>.<dotPath>` walks a prior step's response (works through wrapped MCP envelopes); literals pass through.
|
||||
- `CLIFlagOverride.PipelineLocal` marks a flag as CLI-side only so `CollectBindings` skips it (value never reaches MCP params); the pipeline executor still reads it via `extractFlagValuesByAlias`.
|
||||
- Download step emits machine-parseable plain-text lines (`jobId: <id>\n`, `downloadUrl: <url>\n`) alongside the standard JSON envelope, so shell pipelines and regex-based tests can extract key values without JSON parsing.
|
||||
|
||||
## [1.0.24] - 2026-05-09
|
||||
|
||||
Three small but user-visible safety/usability changes: the embedded distribution now refuses to self-upgrade, the `dws auth login` help text finally matches the actual default flow (loopback, not device), and the release workflow gains a manual fallback trigger.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws upgrade` is blocked in embedded distributions** (#248) — when the CLI is shipped as an embedded asset (e.g. inside another product), `dws upgrade` would happily overwrite the host-managed binary. The upgrade entry point now detects the embedded build flag and exits early with a clear message; covered by `internal/app/upgrade_embedded_guard_test.go`.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`dws auth login` help text reflects the real default** (#238, fixes #226) — the long help previously claimed "OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback listener and only switches to device flow when `--device` is passed. SSH-into-headless-Linux users following the old text hit a dead end (remote-side 127.0.0.1 is unreachable from the local browser). Help and two `flagErrorWithSuggestions` messages in `root.go` are realigned: each method is named after its real flag (`OAuth Loopback 流 (默认)` / `OAuth 设备流 (--device)` / `直接提供 Token (--token)`), with an explicit `--device` example for SSH/headless. No behaviour change.
|
||||
|
||||
### CI
|
||||
|
||||
- **`workflow_dispatch` trigger added to release workflow as a fallback** (#261) — GitHub occasionally drops tag-push events; the release job can now be re-run manually against any tag ref without having to delete and re-push the tag.
|
||||
|
||||
## [1.0.23] - 2026-05-08
|
||||
|
||||
A single fix for HTTP proxy support across the CLI's custom HTTP transports. No behaviour changes elsewhere.
|
||||
|
||||
@@ -413,17 +413,19 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload |
|
||||
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
|
||||
| Mail | `mail` | 4 | `mailbox` `message` | List mailbox addresses, KQL message search, get full message content, send email |
|
||||
| Sheet | `sheet` | 34 | `range` `filter-view` (top-level: `create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read/write/append, dimension ops, cell merge, find/replace, named filter views + sheet-level filters, image write, async export (`submit_export_job` + `query_export_job` — no consolidated `export` in v1.0.25) |
|
||||
| Wiki | `wiki` | 7 | `space` `member` | Knowledge base management: space `create` / `get` / `list` / `search` + member `add` / `list` / `update` |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **163 commands across 14 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> **204 commands across 16 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
<details>
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
`conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
|
||||
`conference` (video) · `aiapp` (AI apps) · `live` (streaming)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+4
-2
@@ -413,17 +413,19 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 |
|
||||
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
|
||||
| 邮箱 | `mail` | 4 | `mailbox` `message` | 邮箱地址列表、KQL 邮件搜索、邮件详情、发送邮件 |
|
||||
| 在线电子表格 | `sheet` | 34 | `range` `filter-view`(顶层:`create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并、查找替换、命名筛选视图 + 表级筛选、写入图片、异步导出(`submit_export_job` + `query_export_job`,v1.0.25 暂无合并的 `export` 命令) |
|
||||
| 知识库 | `wiki` | 7 | `space` `member` | 知识库管理:空间 `create` / `get` / `list` / `search` + 成员 `add` / `list` / `update` |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> **14 个产品,163 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> **16 个产品,204 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
<details>
|
||||
<summary>即将推出</summary>
|
||||
|
||||
`conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
|
||||
`conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
||||
| `DWS_TRUSTED_DOMAINS` | Comma-separated trusted domains for bearer token (default: `*.dingtalk.com`). `*` for dev only / Bearer token 允许发送的域名白名单,默认 `*.dingtalk.com`,仅开发环境可设为 `*` |
|
||||
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
|
||||
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
|
||||
|
||||
## Exit Codes / 退出码
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
docProductID = "doc"
|
||||
docDownloadFileTool = "download_file"
|
||||
docGetDocumentInfoTool = "get_document_info"
|
||||
docAXLSExtension = "axls"
|
||||
)
|
||||
|
||||
func (r *runtimeRunner) preflightDocDownload(ctx context.Context, tc *transport.Client, endpoint string, invocation executor.Invocation) error {
|
||||
if !isDocDownloadInvocation(invocation) {
|
||||
return nil
|
||||
}
|
||||
nodeID := docDownloadNodeID(invocation.Params)
|
||||
if nodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
preflightStart := time.Now()
|
||||
info, err := tc.CallTool(ctx, endpoint, docGetDocumentInfoTool, map[string]any{"nodeId": nodeID})
|
||||
RecordTiming(ctx, "doc_download_preflight", time.Since(preflightStart))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||
if err := classify(info.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patCheck := apperrors.ClassifyPatAuthCheck(info.Content); patCheck != nil {
|
||||
return patCheck
|
||||
}
|
||||
if info.IsError {
|
||||
return apperrors.NewAPI(
|
||||
extractMCPErrorMessage(info),
|
||||
apperrors.WithOperation("doc.get_document_info"),
|
||||
apperrors.WithReason("doc_download_preflight_failed"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>"),
|
||||
)
|
||||
}
|
||||
if bizErr := detectBusinessError(info.Content); bizErr != "" {
|
||||
return apperrors.NewAPI(
|
||||
bizErr,
|
||||
apperrors.WithOperation("doc.get_document_info"),
|
||||
apperrors.WithReason("doc_download_preflight_failed"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>"),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.EqualFold(documentInfoExtension(info.Content), docAXLSExtension) {
|
||||
return unsupportedAXLSDownloadError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDocDownloadInvocation(invocation executor.Invocation) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), docProductID) &&
|
||||
strings.TrimSpace(invocation.Tool) == docDownloadFileTool
|
||||
}
|
||||
|
||||
func docDownloadNodeID(params map[string]any) string {
|
||||
for _, key := range []string{"nodeId", "node", "dentryUuid"} {
|
||||
if value, ok := params[key].(string); ok {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unsupportedAXLSDownloadError() error {
|
||||
return apperrors.NewValidation(
|
||||
"nodeId 指向的节点是钉钉表格(extension=axls),在线表格不支持直接下载。请使用 getRange 工具获取表格数据。",
|
||||
apperrors.WithOperation("doc.download_file.preflight"),
|
||||
apperrors.WithReason("unsupported_alidoc_extension"),
|
||||
apperrors.WithServerKey(docProductID),
|
||||
apperrors.WithHint("在线表格应先用 doc info 确认 extension,再改用表格 MCP 的 get_all_sheets / get_range 读取数据。"),
|
||||
apperrors.WithActions("dws doc info --node <nodeId>", "使用表格 MCP get_all_sheets / get_range"),
|
||||
)
|
||||
}
|
||||
|
||||
func documentInfoExtension(content map[string]any) string {
|
||||
for _, path := range [][]string{
|
||||
{"result", "extension"},
|
||||
{"data", "extension"},
|
||||
{"extension"},
|
||||
} {
|
||||
if value := stringAtPath(content, path...); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringAtPath(value any, path ...string) string {
|
||||
current := value
|
||||
for _, key := range path {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
current = object[key]
|
||||
}
|
||||
if text, ok := current.(string); ok {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
|
||||
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
|
||||
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
|
||||
cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty|ndjson|csv")
|
||||
cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')")
|
||||
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
|
||||
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
|
||||
|
||||
@@ -224,6 +224,9 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
|
||||
data = map[string]any{}
|
||||
payload["data"] = data
|
||||
}
|
||||
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = apperrors.PATAuthorizationURL(rawURI)
|
||||
}
|
||||
data["openBrowser"] = openBrowser
|
||||
|
||||
encoded, err := json.Marshal(payload)
|
||||
@@ -359,10 +362,10 @@ func openPATAuthorizationURI(rawURI string) error {
|
||||
return nil
|
||||
}
|
||||
// The PAT service returns the complete authorization URL. Treat it as an
|
||||
// opaque string and open it verbatim instead of parsing/rebuilding it
|
||||
// locally, because required parameters may live in query, hash, or
|
||||
// fragment sections.
|
||||
return openBrowserFunc(rawURI)
|
||||
// opaque string unless it is the known legacy DingTalk hash-route variant.
|
||||
// That variant is normalized by the PAT error contract helper while still
|
||||
// preserving the original data.uri in structured output.
|
||||
return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI))
|
||||
}
|
||||
|
||||
func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
|
||||
@@ -457,7 +460,7 @@ func handlePatAuthCheck(
|
||||
|
||||
if wantsStructuredPATOutput(r) {
|
||||
if openBrowser && patData.Data.URI != "" {
|
||||
_ = openBrowserFunc(patData.Data.URI)
|
||||
_ = openPATAuthorizationURI(patData.Data.URI)
|
||||
}
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
|
||||
}
|
||||
@@ -475,9 +478,11 @@ func handlePatAuthCheck(
|
||||
fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc)
|
||||
}
|
||||
if patData.Data.URI != "" {
|
||||
fmt.Fprintf(output, " %s %s\n\n", dim("🔗"), cyan(patData.Data.URI))
|
||||
authURL := apperrors.PATAuthorizationURL(patData.Data.URI)
|
||||
fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL))
|
||||
fmt.Fprintf(output, " PAT_AUTHORIZATION_URL=%s\n\n", authURL)
|
||||
if openBrowser {
|
||||
_ = openPATAuthorizationURI(patData.Data.URI)
|
||||
_ = openPATAuthorizationURI(authURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,18 +723,24 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
|
||||
}
|
||||
}
|
||||
|
||||
// tryOpenBrowser opens url in the default browser; errors are silently ignored.
|
||||
func tryOpenBrowser(url string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
func browserOpenCommand(goos, rawURL string) *exec.Cmd {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
return exec.Command("open", rawURL)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
return exec.Command("xdg-open", rawURL)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
|
||||
func tryOpenBrowser(rawURL string) error {
|
||||
cmd := browserOpenCommand(runtime.GOOS, rawURL)
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
@@ -903,7 +903,8 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "json"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Df72437f040f04a8295988ff71e690b35%26userCode%3D98JV-JSBL#/personalAuthorization?flowId=f72437f040f04a8295988ff71e690b35&userCode=98JV-JSBL"
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"` + rawURI + `","clientId":"test-client-id"}}`
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
@@ -920,8 +921,26 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
if opened != "https://example.com/pat" {
|
||||
t.Fatalf("opened url = %q, want https://example.com/pat", opened)
|
||||
if opened != rawURI {
|
||||
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PATError, got %T: %v", err, err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
|
||||
}
|
||||
data, _ := payload["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
}
|
||||
if got, ok := data["openBrowser"].(bool); !ok || !got {
|
||||
t.Fatalf("data.openBrowser = %#v, want true", data["openBrowser"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1189,4 +1208,78 @@ func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) {
|
||||
if opened != rawURI {
|
||||
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
|
||||
}
|
||||
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+rawURI) {
|
||||
t.Fatalf("output missing copy-safe PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
|
||||
defer server.Close()
|
||||
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN"
|
||||
wantURL := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN"
|
||||
var opened string
|
||||
origOpenBrowser := openBrowserFunc
|
||||
openBrowserFunc = func(rawURL string) error {
|
||||
opened = rawURL
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
var retryCalled bool
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
retryCalled = true
|
||||
return executor.Result{Response: map[string]any{"ok": true}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: mock}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-legacy-hash", "test-client-id", rawURI)}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
CanonicalProduct: "test",
|
||||
Tool: "test_tool",
|
||||
}, patErr, configDir, &buf)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !retryCalled {
|
||||
t.Fatal("expected retry to run after approved PAT flow")
|
||||
}
|
||||
if opened != wantURL {
|
||||
t.Fatalf("opened url = %q, want normalized %q", opened, wantURL)
|
||||
}
|
||||
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+wantURL) {
|
||||
t.Fatalf("output missing normalized PAT_AUTHORIZATION_URL line:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpenCommand_WindowsPreservesOpaquePATURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Df72437f040f04a8295988ff71e690b35%26userCode%3D98JV-JSBL#/personalAuthorization?flowId=f72437f040f04a8295988ff71e690b35&userCode=98JV-JSBL"
|
||||
cmd := browserOpenCommand("windows", rawURI)
|
||||
if cmd == nil {
|
||||
t.Fatal("browserOpenCommand(windows) returned nil")
|
||||
}
|
||||
if got := cmd.Args[0]; got == "cmd" {
|
||||
t.Fatalf("windows browser opener must not route PAT URLs through cmd.exe: args=%v", cmd.Args)
|
||||
}
|
||||
if got := len(cmd.Args); got != 3 {
|
||||
t.Fatalf("windows browser opener args length = %d, want 3: %v", got, cmd.Args)
|
||||
}
|
||||
if got := cmd.Args[0]; got != "rundll32" {
|
||||
t.Fatalf("windows browser opener command = %q, want rundll32", got)
|
||||
}
|
||||
if got := cmd.Args[1]; got != "url.dll,FileProtocolHandler" {
|
||||
t.Fatalf("windows browser opener handler = %q, want url.dll,FileProtocolHandler", got)
|
||||
}
|
||||
if got := cmd.Args[2]; got != rawURI {
|
||||
t.Fatalf("windows browser opener URL arg = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +364,17 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if err := r.preflightDocDownload(callCtx, tc, endpoint, invocation); err != nil {
|
||||
if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil {
|
||||
if IsPatRetrying(ctx) {
|
||||
return executor.Result{}, patCheck
|
||||
}
|
||||
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||
}
|
||||
captureRuntimeFailure(invocation, err, err)
|
||||
return executor.Result{}, err
|
||||
}
|
||||
|
||||
callStart := time.Now()
|
||||
callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params)
|
||||
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -27,7 +28,10 @@ import (
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
|
||||
)
|
||||
@@ -324,6 +328,186 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightRejectsAXLSBeforeDownloadPAT(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"contentType": "ALIDOC",
|
||||
"extension": "axls",
|
||||
"nodeType": "file",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
t.Fatalf("download_file should not be called for axls")
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "axls-node"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("executeInvocation() error = nil, want axls rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "extension=axls") {
|
||||
t.Fatalf("executeInvocation() error = %v, want extension=axls guidance", err)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error category = %q, want validation", typed.Category)
|
||||
}
|
||||
if typed.Reason != "unsupported_alidoc_extension" {
|
||||
t.Fatalf("error reason = %q, want unsupported_alidoc_extension", typed.Reason)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
|
||||
t.Fatalf("tool calls = %q, want only %s", got, docGetDocumentInfoTool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightAllowsNonAXLSDownload(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"contentType": "DRIVE",
|
||||
"extension": "xlsx",
|
||||
"nodeType": "file",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"resourceUrl": []any{"https://example.invalid/file.xlsx"},
|
||||
}, false)
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
result, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "xlsx-node"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("executeInvocation() error = %v", err)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool+","+docDownloadFileTool {
|
||||
t.Fatalf("tool calls = %q, want preflight then download", got)
|
||||
}
|
||||
content, ok := result.Response["content"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("response.content = %#v, want map", result.Response["content"])
|
||||
}
|
||||
if _, ok := content["resourceUrl"]; !ok {
|
||||
t.Fatalf("response.content.resourceUrl missing: %#v", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocDownloadPreflightPATAuthorizationUsesExistingHandler(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
|
||||
|
||||
originalOpenBrowser := openBrowserFunc
|
||||
var openedURI string
|
||||
openBrowserFunc = func(uri string) error {
|
||||
openedURI = uri
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = originalOpenBrowser })
|
||||
|
||||
const authURI = "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-1%26userCode%3DCODE#/personalAuthorization?flowId=flow-1&userCode=CODE"
|
||||
var calls []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := jsonRPCToolName(req)
|
||||
calls = append(calls, name)
|
||||
switch name {
|
||||
case docGetDocumentInfoTool:
|
||||
writeJSONRPCToolResult(t, w, req, map[string]any{
|
||||
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"flowId": "flow-1",
|
||||
"uri": authURI,
|
||||
"clientId": "client-1",
|
||||
},
|
||||
}, false)
|
||||
case docDownloadFileTool:
|
||||
t.Fatalf("download_file should not be called before preflight PAT authorization")
|
||||
default:
|
||||
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
runner := runtimeRunnerForHTTPTest(server)
|
||||
runner.globalFlags.Format = "json"
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: docProductID,
|
||||
Tool: docDownloadFileTool,
|
||||
CanonicalPath: "doc.download_file",
|
||||
Params: map[string]any{"nodeId": "pat-node"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("executeInvocation() error = nil, want PAT error")
|
||||
}
|
||||
var patErr *apperrors.PATError
|
||||
if !errors.As(err, &patErr) {
|
||||
t.Fatalf("executeInvocation() error = %T, want *errors.PATError", err)
|
||||
}
|
||||
if openedURI != authURI {
|
||||
t.Fatalf("opened URI = %q, want %q", openedURI, authURI)
|
||||
}
|
||||
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
|
||||
t.Fatalf("tool calls = %q, want only %s before PAT authorization", got, docGetDocumentInfoTool)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
|
||||
// a valid token are rejected with a clear error before making any network call.
|
||||
func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
|
||||
@@ -658,6 +842,36 @@ func contentScanServer() *mockmcp.Server {
|
||||
return mockmcp.MustNewServer(fixture)
|
||||
}
|
||||
|
||||
func runtimeRunnerForHTTPTest(server *httptest.Server) *runtimeRunner {
|
||||
client := transport.NewClient(server.Client())
|
||||
client.Stderr = &bytes.Buffer{}
|
||||
return &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "test-token", Timeout: 30},
|
||||
}
|
||||
}
|
||||
|
||||
func jsonRPCToolName(req map[string]any) string {
|
||||
params, _ := req["params"].(map[string]any)
|
||||
if params == nil {
|
||||
return ""
|
||||
}
|
||||
name, _ := params["name"].(string)
|
||||
return name
|
||||
}
|
||||
|
||||
func writeJSONRPCToolResult(t *testing.T, w http.ResponseWriter, req map[string]any, content map[string]any, isError bool) {
|
||||
t.Helper()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req["id"],
|
||||
"result": map[string]any{
|
||||
"content": content,
|
||||
"isError": isError,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestClassifyToolResultHookPreemptsBusinessError(t *testing.T) {
|
||||
setupRuntimeCommandTest(t)
|
||||
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
|
||||
|
||||
@@ -163,9 +163,12 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
}
|
||||
|
||||
route := Route{
|
||||
Use: cliName,
|
||||
Short: short,
|
||||
Long: long,
|
||||
Use: cliName,
|
||||
// CLIAliases register additional cobra command aliases for the
|
||||
// same MCP tool. Empty / nil means no extra names.
|
||||
Aliases: append([]string(nil), override.CLIAliases...),
|
||||
Short: short,
|
||||
Long: long,
|
||||
// Preserve left-side indentation: cobra's Examples template
|
||||
// renders {{.Example}} verbatim, and hardcoded helper commands
|
||||
// rely on a 2-space prefix to look indented under "Examples:".
|
||||
@@ -176,7 +179,12 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
CanonicalProduct: canonicalProduct,
|
||||
Tool: toolName,
|
||||
},
|
||||
Bindings: bindings,
|
||||
Bindings: bindings,
|
||||
// §pipeline: when the envelope declares a multi-step
|
||||
// orchestration, NewDirectCommand reroutes RunE into the
|
||||
// pipeline executor instead of the single-tool flow. The
|
||||
// CLIName / Group / Flags surface above still applies.
|
||||
Pipeline: append([]market.PipelineStep(nil), override.Pipeline...),
|
||||
Normalizer: normalizer,
|
||||
}
|
||||
|
||||
@@ -612,10 +620,16 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
binding := FlagBinding{
|
||||
FlagName: flagName,
|
||||
Aliases: extraAliases,
|
||||
Short: strings.TrimSpace(flagOverride.Shorthand),
|
||||
Property: paramName,
|
||||
Kind: kindFromTypeName(flagOverride.Type),
|
||||
Usage: usage,
|
||||
// §pipeline: PipelineLocal flags (e.g. `--output` in the
|
||||
// sheet export pipeline) are CLI-side only — they appear in
|
||||
// --help and are bindable, but CollectBindings skips them so
|
||||
// the value never reaches MCP params. The pipeline executor
|
||||
// reads them via extractFlagValuesByAlias.
|
||||
PipelineLocal: flagOverride.PipelineLocal,
|
||||
Short: strings.TrimSpace(flagOverride.Shorthand),
|
||||
Property: paramName,
|
||||
Kind: kindFromTypeName(flagOverride.Type),
|
||||
Usage: usage,
|
||||
// §P1: Required is preserved for positional bindings too. For
|
||||
// pure positional, cobra arity (MinimumNArgs) enforces presence
|
||||
// at parse time. For dual-mode positional (positional + alias),
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package compat — pipeline executor for CLIToolOverride.Pipeline.
|
||||
//
|
||||
// A pipeline turns a single CLI command into an ordered sequence of MCP
|
||||
// tool calls plus optional HTTP-download sinks, declared entirely in the
|
||||
// envelope JSON. Use cases:
|
||||
//
|
||||
// 1. submit-job + poll-status + download-result patterns (the canonical
|
||||
// example: `dws sheet export --node X --output PATH` calls
|
||||
// submit_export_job → query_export_job (poll until status=done) →
|
||||
// HTTP GET downloadUrl → write to PATH).
|
||||
// 2. compose-then-update flows where step 2's args reference step 1's
|
||||
// response.
|
||||
//
|
||||
// Templates supported in PipelineStep.Args / DownloadURLField:
|
||||
//
|
||||
// $flag.<aliasName> — value of the user's CLI flag whose alias
|
||||
// equals <aliasName>
|
||||
// $step.<idx>.<dotPath> — field from a prior step's response
|
||||
// literal string — passed through unchanged
|
||||
//
|
||||
// Limitations (intentional, to keep the executor small):
|
||||
// - No conditional branching: steps run unconditionally in order.
|
||||
// - No retry-on-error: the pipeline aborts on the first runner error.
|
||||
// - PollUntil compares as strings; numeric/boolean comparisons stringify.
|
||||
// - Download step uses the standard library net/http with no custom
|
||||
// timeout (relies on the user's Ctrl-C).
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// pipelineCtx carries flag values + accumulated step responses through
|
||||
// the executor. Unexported because callers always interact via runPipeline.
|
||||
type pipelineCtx struct {
|
||||
flags map[string]string
|
||||
stepOutputs []map[string]any
|
||||
}
|
||||
|
||||
// runPipeline executes route.Pipeline against runner, returning the last
|
||||
// "call"-type step's response (or a synthesized success payload if the
|
||||
// pipeline ends with a "download" step). The map is the shape returned to
|
||||
// the user via the standard output formatter.
|
||||
func runPipeline(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
runner executor.Runner,
|
||||
route Route,
|
||||
flagValues map[string]string,
|
||||
) (map[string]any, error) {
|
||||
pctx := &pipelineCtx{
|
||||
flags: flagValues,
|
||||
stepOutputs: make([]map[string]any, 0, len(route.Pipeline)),
|
||||
}
|
||||
|
||||
var lastCallResponse map[string]any
|
||||
for i, step := range route.Pipeline {
|
||||
stepType := strings.TrimSpace(step.Type)
|
||||
if stepType == "" {
|
||||
stepType = "call"
|
||||
}
|
||||
switch stepType {
|
||||
case "call":
|
||||
resp, err := executePipelineCall(ctx, runner, route, step, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline step %d (%s): %w", i, step.Tool, err)
|
||||
}
|
||||
pctx.stepOutputs = append(pctx.stepOutputs, resp)
|
||||
lastCallResponse = resp
|
||||
case "download":
|
||||
resp, err := executePipelineDownload(cmd, step, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline step %d (download): %w", i, err)
|
||||
}
|
||||
pctx.stepOutputs = append(pctx.stepOutputs, resp)
|
||||
default:
|
||||
return nil, apperrors.NewValidation(
|
||||
fmt.Sprintf("pipeline step %d: unsupported type %q (allowed: call, download)", i, stepType),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if lastCallResponse != nil {
|
||||
return lastCallResponse, nil
|
||||
}
|
||||
return map[string]any{"success": true}, nil
|
||||
}
|
||||
|
||||
// executePipelineCall resolves args templates, then either polls or fires
|
||||
// a single MCP tool invocation via runner. PollUntilField + PollUntilValue
|
||||
// non-empty enable polling.
|
||||
func executePipelineCall(
|
||||
ctx context.Context,
|
||||
runner executor.Runner,
|
||||
route Route,
|
||||
step market.PipelineStep,
|
||||
pctx *pipelineCtx,
|
||||
) (map[string]any, error) {
|
||||
if strings.TrimSpace(step.Tool) == "" {
|
||||
return nil, apperrors.NewValidation("pipeline call step requires non-empty `tool`")
|
||||
}
|
||||
args, err := resolveArgs(step.Args, pctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
invoke := func() (map[string]any, error) {
|
||||
invocation := executor.NewCompatibilityInvocation(
|
||||
route.Use,
|
||||
route.Target.CanonicalProduct,
|
||||
step.Tool,
|
||||
args,
|
||||
)
|
||||
result, err := runner.Run(ctx, invocation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Response == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
// Fail-fast on MCP business errors. Pre-execution validation (e.g.
|
||||
// cobra MarkFlagRequired) only checks that the flag was set, not
|
||||
// that the value is non-empty — so a `--required-flag ""` reaches
|
||||
// here and the upstream tool rejects with errorCode. Without this
|
||||
// check the pipeline would happily proceed to poll/download and
|
||||
// either spin until PollTimeout or burn through retries.
|
||||
if errCode := getDotPath(result.Response, "content.errorCode"); errCode != nil && fmt.Sprint(errCode) != "" {
|
||||
msg := getDotPath(result.Response, "content.errorMessage")
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"%s rejected: %s — %v", step.Tool, errCode, msg,
|
||||
))
|
||||
}
|
||||
return result.Response, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(step.PollUntilField) == "" {
|
||||
return invoke()
|
||||
}
|
||||
|
||||
// Polling loop.
|
||||
interval := time.Duration(step.PollIntervalSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 2 * time.Second
|
||||
}
|
||||
timeoutSec := step.PollTimeoutSec
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 300
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
|
||||
|
||||
for {
|
||||
resp, err := invoke()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actual := getDotPath(resp, step.PollUntilField)
|
||||
if actual != nil && fmt.Sprint(actual) == step.PollUntilValue {
|
||||
return resp, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"pipeline poll timeout after %ds: field %q never reached value %q (last seen: %v)",
|
||||
timeoutSec, step.PollUntilField, step.PollUntilValue, actual,
|
||||
))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executePipelineDownload resolves the URL template, fetches the body via
|
||||
// HTTP GET, and writes it to the path supplied by OutputFlag's user value.
|
||||
// Empty output path → print URL to stdout (terminal-friendly mode).
|
||||
func executePipelineDownload(
|
||||
cmd *cobra.Command,
|
||||
step market.PipelineStep,
|
||||
pctx *pipelineCtx,
|
||||
) (map[string]any, error) {
|
||||
urlAny, err := resolveTemplate(step.DownloadURLField, pctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urlStr := strings.TrimSpace(fmt.Sprint(urlAny))
|
||||
if urlStr == "" {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"pipeline download: URL template %q resolved to empty value",
|
||||
step.DownloadURLField,
|
||||
))
|
||||
}
|
||||
|
||||
outputPath := strings.TrimSpace(pctx.flags[step.OutputFlag])
|
||||
jobID := fmt.Sprint(inferJobIDFromContext(pctx))
|
||||
|
||||
// Always print machine-parseable "key: value" lines. Tests and shell
|
||||
// pipelines that consume the pipeline output (regex / awk) rely on
|
||||
// this exact format. The structured JSON output follows via
|
||||
// output.WriteCommandPayload, so AI / SDK callers still get a typed
|
||||
// response.
|
||||
if jobID != "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "jobId: %s\n", jobID)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "downloadUrl: %s\n", urlStr)
|
||||
|
||||
if outputPath == "" {
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": urlStr,
|
||||
"jobId": jobID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// If outputPath is a directory, infer filename from URL basename.
|
||||
if info, statErr := os.Stat(outputPath); statErr == nil && info.IsDir() {
|
||||
filename := inferFilenameFromURL(urlStr)
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("export_%d", time.Now().Unix())
|
||||
}
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
|
||||
resp, err := http.Get(urlStr) //nolint:gosec // user-supplied URL via MCP discovery is expected
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP GET %s: %w", urlStr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP GET %s: status %d", urlStr, resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", outputPath, err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
written, err := io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "导出完成: %s (%d bytes)\n", outputPath, written)
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": urlStr,
|
||||
"jobId": jobID,
|
||||
"output": outputPath,
|
||||
"size": written,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveArgs applies resolveTemplate to every value in the map.
|
||||
func resolveArgs(args map[string]string, pctx *pipelineCtx) (map[string]any, error) {
|
||||
out := make(map[string]any, len(args))
|
||||
for k, tmpl := range args {
|
||||
v, err := resolveTemplate(tmpl, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("arg %q: %w", k, err)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveTemplate evaluates a single template string. Returns the literal
|
||||
// when input does not start with '$'.
|
||||
func resolveTemplate(tmpl string, pctx *pipelineCtx) (any, error) {
|
||||
s := strings.TrimSpace(tmpl)
|
||||
if !strings.HasPrefix(s, "$") {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Split on the first dot: head ("$flag" / "$step") + tail (rest).
|
||||
dot := strings.Index(s, ".")
|
||||
if dot <= 0 || dot == len(s)-1 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("malformed template %q (expected $flag.<name> or $step.<idx>.<path>)", tmpl))
|
||||
}
|
||||
head := s[:dot]
|
||||
tail := s[dot+1:]
|
||||
|
||||
switch head {
|
||||
case "$flag":
|
||||
// tail is a flag alias name (no nested path supported)
|
||||
return pctx.flags[tail], nil
|
||||
case "$step":
|
||||
// tail format: <idx>.<dotPath>
|
||||
secondDot := strings.Index(tail, ".")
|
||||
if secondDot <= 0 || secondDot == len(tail)-1 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("malformed $step template %q (expected $step.<idx>.<dotPath>)", tmpl))
|
||||
}
|
||||
idxStr := tail[:secondDot]
|
||||
dotPath := tail[secondDot+1:]
|
||||
idx, err := strconv.Atoi(idxStr)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q has non-numeric index", tmpl))
|
||||
}
|
||||
if idx < 0 || idx >= len(pctx.stepOutputs) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q references step %d, but only %d step(s) executed so far", tmpl, idx, len(pctx.stepOutputs)))
|
||||
}
|
||||
return getDotPath(pctx.stepOutputs[idx], dotPath), nil
|
||||
default:
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("unknown template prefix %q in %q (allowed: $flag, $step)", head, tmpl))
|
||||
}
|
||||
}
|
||||
|
||||
// getDotPath walks dotPath through nested map[string]any. Returns nil if
|
||||
// any segment is missing or the value isn't a map at an intermediate step.
|
||||
func getDotPath(m map[string]any, dotPath string) any {
|
||||
parts := strings.Split(dotPath, ".")
|
||||
var current any = m
|
||||
for _, p := range parts {
|
||||
nested, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
current = nested[p]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// inferFilenameFromURL extracts the basename from a URL's path component,
|
||||
// stripping query string + fragment. Returns "" if the URL doesn't parse
|
||||
// or has no useful basename.
|
||||
func inferFilenameFromURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
base := path.Base(u.Path)
|
||||
if base == "" || base == "/" || base == "." {
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// inferJobIDFromContext walks prior step outputs looking for a `jobId`
|
||||
// field at top level or one level under common MCP wrappers ("content" /
|
||||
// "result"), so the synthetic download response can echo it back to the
|
||||
// user. Returns "" when no jobId is present anywhere in prior responses.
|
||||
func inferJobIDFromContext(pctx *pipelineCtx) any {
|
||||
candidates := []string{"jobId", "content.jobId", "result.jobId"}
|
||||
for i := len(pctx.stepOutputs) - 1; i >= 0; i-- {
|
||||
for _, p := range candidates {
|
||||
if v := getDotPath(pctx.stepOutputs[i], p); v != nil && fmt.Sprint(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractFlagValuesByAlias reads the cobra command's flag values keyed by
|
||||
// the FlagBinding's primary CLI flag name, so the pipeline executor can
|
||||
// resolve "$flag.<name>" templates in O(1). Pipeline-local flags are
|
||||
// always included (they are the whole point of the lookup).
|
||||
//
|
||||
// Note on key choice: buildOverrideBindings populates FlagName from the
|
||||
// envelope's `alias` field (or kebab-case of the MCP property name when
|
||||
// alias is empty), and leaves the FlagBinding.Alias struct field empty —
|
||||
// so $flag templates reference the user-visible CLI flag name, e.g.
|
||||
// "$flag.node" matches `--node`.
|
||||
func extractFlagValuesByAlias(cmd *cobra.Command, bindings []FlagBinding) map[string]string {
|
||||
flags := cmd.Flags()
|
||||
out := make(map[string]string, len(bindings))
|
||||
for _, b := range bindings {
|
||||
primary := strings.TrimSpace(b.FlagName)
|
||||
if primary == "" {
|
||||
primary = strings.TrimSpace(b.Alias)
|
||||
}
|
||||
if primary == "" {
|
||||
continue
|
||||
}
|
||||
// Try the primary flag name first, then any of the extra aliases.
|
||||
// Whichever the user actually set wins; if none was set, the
|
||||
// cobra-level default value is returned.
|
||||
candidates := make([]string, 0, 2+len(b.Aliases))
|
||||
candidates = append(candidates, primary)
|
||||
if a := strings.TrimSpace(b.Alias); a != "" && a != primary {
|
||||
candidates = append(candidates, a)
|
||||
}
|
||||
for _, a := range b.Aliases {
|
||||
if a = strings.TrimSpace(a); a != "" {
|
||||
candidates = append(candidates, a)
|
||||
}
|
||||
}
|
||||
var value string
|
||||
for _, c := range candidates {
|
||||
f := flags.Lookup(c)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
value = f.Value.String()
|
||||
if f.Changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
out[primary] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonRoundTrip marshals + unmarshals so user-provided strings come out
|
||||
// the other side as Go primitives where appropriate. Unused for now —
|
||||
// the resolveTemplate path returns strings as-is to keep the contract
|
||||
// simple; tools that need JSON-shaped values can use the existing
|
||||
// `transform: "json_parse_strict"` on the relevant flag (post-pipeline
|
||||
// composition is not in scope for the MVP).
|
||||
var _ = json.Unmarshal
|
||||
+58
-14
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -60,12 +61,16 @@ type FlagBinding struct {
|
||||
// parameter. Any of them being set satisfies Required, and the value
|
||||
// is resolved via firstChangedFlag(FlagName, Alias, Aliases...).
|
||||
// Mirrors cmdutil.ValidateRequiredFlagWithAliases / FlagOrFallback.
|
||||
Aliases []string
|
||||
Short string
|
||||
Property string
|
||||
Kind ValueKind
|
||||
Usage string
|
||||
Required bool
|
||||
Aliases []string
|
||||
// PipelineLocal, when true, marks this binding as CLI-side only — its
|
||||
// value is consumed by the pipeline executor (e.g. as an HTTP
|
||||
// download destination) and NOT forwarded to any MCP tool's params.
|
||||
PipelineLocal bool
|
||||
Short string
|
||||
Property string
|
||||
Kind ValueKind
|
||||
Usage string
|
||||
Required bool
|
||||
// Default is the cobra-level flag default value as a string. Parsed
|
||||
// into the Kind-appropriate primitive at registration time. Empty
|
||||
// string keeps the existing zero-value default. This only affects
|
||||
@@ -82,14 +87,19 @@ type FlagBinding struct {
|
||||
type Normalizer func(cmd *cobra.Command, params map[string]any) error
|
||||
|
||||
type Route struct {
|
||||
Use string
|
||||
Aliases []string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Hidden bool
|
||||
Target Target
|
||||
Bindings []FlagBinding
|
||||
Use string
|
||||
Aliases []string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Hidden bool
|
||||
Target Target
|
||||
Bindings []FlagBinding
|
||||
// Pipeline, when non-empty, replaces the single-tool dispatch with a
|
||||
// multi-step orchestration. NewDirectCommand sees this and wires the
|
||||
// pipeline executor into RunE instead of the standard
|
||||
// invoke-then-output flow. See internal/compat/pipeline.go.
|
||||
Pipeline []market.PipelineStep
|
||||
Normalizer Normalizer
|
||||
// OutputTransform, when non-nil, post-processes the MCP response payload
|
||||
// (rename / drop / columns) before the formatter emits it. Wired up from
|
||||
@@ -277,6 +287,33 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
delete(params, "_blocked")
|
||||
}
|
||||
|
||||
// §pipeline: when the override declares a multi-step pipeline,
|
||||
// dispatch via the pipeline executor instead of the single-tool
|
||||
// invoke-then-output flow. The executor reads flag values by
|
||||
// alias (so $flag.<alias> templates resolve), walks each step,
|
||||
// handles polling + downloads, and returns the last "call"
|
||||
// step's response as the payload to the formatter.
|
||||
if len(route.Pipeline) > 0 {
|
||||
flagValues := extractFlagValuesByAlias(cmd, route.Bindings)
|
||||
resp, err := runPipeline(cmd.Context(), cmd, runner, route, flagValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := executor.Result{
|
||||
Invocation: executor.NewCompatibilityInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
route.Target.CanonicalProduct,
|
||||
"pipeline",
|
||||
params,
|
||||
),
|
||||
Response: resp,
|
||||
}
|
||||
if route.OutputTransform != nil && result.Response != nil {
|
||||
result.Response = route.OutputTransform(result.Response)
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
|
||||
}
|
||||
|
||||
invocation := executor.NewCompatibilityInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
route.Target.CanonicalProduct,
|
||||
@@ -706,6 +743,13 @@ func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[st
|
||||
}
|
||||
params := make(map[string]any)
|
||||
for _, binding := range bindings {
|
||||
// Pipeline-local flags exist purely for the pipeline executor
|
||||
// (e.g. --output destination paths) and must never be forwarded
|
||||
// to MCP tools as params, otherwise the upstream API would
|
||||
// either reject the unknown field or silently store junk.
|
||||
if binding.PipelineLocal {
|
||||
continue
|
||||
}
|
||||
if binding.Positional {
|
||||
// Pure positional (no flag aliases) is handled by
|
||||
// collectPositionalBindings. Dual-mode positional bindings
|
||||
|
||||
@@ -26,7 +26,8 @@ import (
|
||||
)
|
||||
|
||||
// ApplyTransform applies a named transform rule to a value.
|
||||
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse, enum_map.
|
||||
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
|
||||
// json_parse_strict, enum_map.
|
||||
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
|
||||
switch strings.TrimSpace(transform) {
|
||||
case "":
|
||||
@@ -37,6 +38,8 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
|
||||
return transformCSVToArray(value)
|
||||
case "json_parse":
|
||||
return transformJSONParse(value)
|
||||
case "json_parse_strict":
|
||||
return transformJSONParseStrict(value)
|
||||
case "enum_map":
|
||||
return transformEnumMap(value, args)
|
||||
default:
|
||||
@@ -151,6 +154,30 @@ func transformJSONParse(value any) (any, error) {
|
||||
)
|
||||
}
|
||||
|
||||
// transformJSONParseStrict is the strict variant of json_parse: only accepts
|
||||
// well-formed JSON, rejecting input that the YAML fallback would otherwise
|
||||
// silently coerce to a scalar string. Use when the upstream tool requires a
|
||||
// structured array/object value and "garbage in → empty out" is unacceptable.
|
||||
func transformJSONParseStrict(value any) (any, error) {
|
||||
s, ok := toString(value)
|
||||
if !ok {
|
||||
return value, nil
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return value, nil
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
|
||||
return nil, apperrors.NewValidation(
|
||||
"json_parse_strict: input is not valid JSON; " +
|
||||
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
|
||||
"as strict JSON (e.g. '[{\"key\":\"value\"}]') or use `json_parse` for YAML-tolerant parsing",
|
||||
)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func transformEnumMap(value any, args map[string]any) (any, error) {
|
||||
s, ok := toString(value)
|
||||
if !ok {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -107,6 +108,8 @@ const ExitCodePermission = 4
|
||||
// server-provided authorization link. Hosts must treat it as opaque and open
|
||||
// it verbatim instead of parsing and reconstructing it locally, because
|
||||
// required parameters may live in query, encoded hash, or fragment sections.
|
||||
// New hosts may prefer data.authorizationUrl when present; it preserves data.uri
|
||||
// while adding a copy/open-safe URL for legacy DingTalk hash-route variants.
|
||||
type PATError struct {
|
||||
RawJSON string
|
||||
}
|
||||
@@ -349,6 +352,9 @@ func ApplyHostMutations(out map[string]any) {
|
||||
data = map[string]any{}
|
||||
out["data"] = data
|
||||
}
|
||||
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
|
||||
data["authorizationUrl"] = PATAuthorizationURL(rawURI)
|
||||
}
|
||||
if block := HostControlBlock(); block != nil {
|
||||
delete(data, "callbacks")
|
||||
data["hostControl"] = block
|
||||
@@ -356,6 +362,80 @@ func ApplyHostMutations(out map[string]any) {
|
||||
data["openBrowser"] = PATOpenBrowserValue()
|
||||
}
|
||||
|
||||
// PATAuthorizationURL returns the best URL for hosts to open or show to users.
|
||||
// It keeps already-complete PAT URLs unchanged. For DingTalk's legacy
|
||||
// /fe/old#%2FpersonalAuthorization?... hash-route form, it adds the explicit
|
||||
// hash query and decoded fragment route used by the working authorization page.
|
||||
func PATAuthorizationURL(rawURI string) string {
|
||||
rawURI = strings.TrimSpace(rawURI)
|
||||
if rawURI == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURI)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return rawURI
|
||||
}
|
||||
if !strings.HasSuffix(parsed.Path, "/fe/old") {
|
||||
return rawURI
|
||||
}
|
||||
if parsed.Query().Get("hash") != "" && strings.Contains(parsed.Fragment, "personalAuthorization") {
|
||||
return rawURI
|
||||
}
|
||||
|
||||
routeQuery := patAuthorizationRouteQuery(parsed)
|
||||
if routeQuery.Get("flowId") == "" || routeQuery.Get("userCode") == "" {
|
||||
return rawURI
|
||||
}
|
||||
|
||||
route := "/personalAuthorization?" + routeQuery.Encode()
|
||||
|
||||
next := *parsed
|
||||
query := next.Query()
|
||||
query.Set("hash", "#"+route)
|
||||
next.RawQuery = query.Encode()
|
||||
next.Fragment = route
|
||||
next.RawFragment = ""
|
||||
return next.String()
|
||||
}
|
||||
|
||||
func patAuthorizationRouteQuery(parsed *url.URL) url.Values {
|
||||
candidates := []string{
|
||||
parsed.Fragment,
|
||||
parsed.RawFragment,
|
||||
parsed.Query().Get("hash"),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if values := parsePersonalAuthorizationRouteQuery(candidate); values.Get("flowId") != "" && values.Get("userCode") != "" {
|
||||
return values
|
||||
}
|
||||
if decoded, err := url.QueryUnescape(candidate); err == nil && decoded != candidate {
|
||||
if values := parsePersonalAuthorizationRouteQuery(decoded); values.Get("flowId") != "" && values.Get("userCode") != "" {
|
||||
return values
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePersonalAuthorizationRouteQuery(route string) url.Values {
|
||||
route = strings.TrimSpace(route)
|
||||
route = strings.TrimPrefix(route, "#")
|
||||
idx := strings.Index(route, "personalAuthorization?")
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
rawQuery := route[idx+len("personalAuthorization?"):]
|
||||
if cut := strings.IndexAny(rawQuery, "?#"); cut >= 0 {
|
||||
rawQuery = rawQuery[:cut]
|
||||
}
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func cleanPATJSON(body map[string]any, code string) string {
|
||||
out := map[string]any{
|
||||
"success": false,
|
||||
|
||||
@@ -16,6 +16,7 @@ package errors
|
||||
import (
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -737,6 +738,90 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != rawURI {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATAuthorizationURL_NormalizesLegacyHashRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2"
|
||||
want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2#/personalAuthorization?flowId=77108a9d0e6f4b74b769c04eb451e7d9&userCode=WSAX-EEF2"
|
||||
|
||||
if got := PATAuthorizationURL(rawURI); got != want {
|
||||
t.Fatalf("PATAuthorizationURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPATAuthorizationURL_NormalizesLegacyHashRoutePreservesExtraQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2%26agentCode%3Dcodex%26scene%3Ddesktop%26redirect%3Dhttps%253A%252F%252Fexample.com%252Fcallback%253Fa%253D1"
|
||||
|
||||
got := PATAuthorizationURL(rawURI)
|
||||
|
||||
if got == rawURI {
|
||||
t.Fatal("expected legacy hash route to be normalized")
|
||||
}
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse normalized URL: %v\nurl=%s", err, got)
|
||||
}
|
||||
hash := parsed.Query().Get("hash")
|
||||
if hash == "" {
|
||||
t.Fatalf("expected normalized URL to include hash query, got: %s", got)
|
||||
}
|
||||
if hash != "#"+parsed.Fragment {
|
||||
t.Fatalf("hash query = %q, want fragment route %q", hash, "#"+parsed.Fragment)
|
||||
}
|
||||
rawQuery, ok := strings.CutPrefix(parsed.Fragment, "/personalAuthorization?")
|
||||
if !ok {
|
||||
t.Fatalf("fragment = %q, want personalAuthorization route", parsed.Fragment)
|
||||
}
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
t.Fatalf("parse normalized route query: %v\nquery=%s", err, rawQuery)
|
||||
}
|
||||
want := map[string]string{
|
||||
"flowId": "77108a9d0e6f4b74b769c04eb451e7d9",
|
||||
"userCode": "WSAX-EEF2",
|
||||
"agentCode": "codex",
|
||||
"scene": "desktop",
|
||||
"redirect": "https://example.com/callback?a=1",
|
||||
}
|
||||
for key, wantValue := range want {
|
||||
if gotValue := values.Get(key); gotValue != wantValue {
|
||||
t.Fatalf("route query %s = %q, want %q", key, gotValue, wantValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN"
|
||||
want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN"
|
||||
body := map[string]any{
|
||||
"success": false,
|
||||
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
|
||||
"data": map[string]any{
|
||||
"desc": "在浏览器中打开以下链接进行认证",
|
||||
"flowId": "56b12fd3201d4efab9a9138672cf4deb",
|
||||
"uri": rawURI,
|
||||
},
|
||||
}
|
||||
|
||||
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
|
||||
}
|
||||
data, _ := parsed["data"].(map[string]any)
|
||||
if got, _ := data["uri"].(string); got != rawURI {
|
||||
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
|
||||
}
|
||||
if got, _ := data["authorizationUrl"].(string); got != want {
|
||||
t.Fatalf("data.authorizationUrl = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -92,12 +92,13 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
--open-dingtalk-id 指定 openDingTalkId 发单聊 (适用于无法获取 userId 的场景)。
|
||||
三者只能选其一,不能同时指定。
|
||||
|
||||
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。必须提供 --title 作为消息标题。
|
||||
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。
|
||||
单聊消息(--user / --open-dingtalk-id)必须提供 --title 作为消息标题;群聊可选。
|
||||
|
||||
群聊场景下可用 --at-all / --at-users / --at-mobiles 进行 @ 提醒(仅 --group 时生效)。
|
||||
注意 --text 中需包含对应的 <@userId> / <@all> 占位符才能在客户端渲染出 @ 效果。`,
|
||||
Example: ` dws chat message send --group <openconversation_id> --text "hello"
|
||||
dws chat message send --user <userId> --text "请查收"
|
||||
dws chat message send --user <userId> --title "提醒" --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请确认"
|
||||
dws chat message send --group <openconversation_id> --title "拉群通知" --text "<@uid> 你被 @ 了" --at-users uid`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
@@ -127,7 +128,7 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("user", "", "接收人 userId (单聊三选一)")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "接收人 openDingTalkId (单聊三选一)")
|
||||
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
|
||||
cmd.Flags().String("title", "", "消息标题 (可选)")
|
||||
cmd.Flags().String("title", "", "消息标题 (单聊必填,群聊可选)")
|
||||
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
|
||||
cmd.Flags().String("at-users", "", "按 userId @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
|
||||
cmd.Flags().String("at-mobiles", "", "按手机号 @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
|
||||
@@ -195,6 +196,12 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
if !hasGroup && (atAll || hasAtUsers || hasAtMobiles) {
|
||||
return nil, "", apperrors.NewValidation("--at-all / --at-users / --at-mobiles only apply when --group is set")
|
||||
}
|
||||
// Direct-message tools (send_direct_message_as_user) reject an empty title at
|
||||
// the API level with a misleading "发群服务窗会话消息失败" error, so fail loudly
|
||||
// here instead. Group messages do not require a title.
|
||||
if (hasUser || hasOpenID) && strings.TrimSpace(title) == "" {
|
||||
return nil, "", apperrors.NewValidation("--title is required for direct messages (--user / --open-dingtalk-id)")
|
||||
}
|
||||
|
||||
params := map[string]any{"text": text}
|
||||
if strings.TrimSpace(title) != "" {
|
||||
|
||||
@@ -67,14 +67,14 @@ func TestChatMessageSendRoutesByDestination(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "user-direct",
|
||||
args: []string{"--user", "034766", "--text", "hi"},
|
||||
args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
|
||||
wantTool: "send_direct_message_as_user",
|
||||
wantKey: "receiverUserId",
|
||||
wantValue: "034766",
|
||||
},
|
||||
{
|
||||
name: "open-dingtalk-id-direct",
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--text", "hi"},
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
|
||||
wantTool: "send_direct_message_as_user",
|
||||
wantKey: "receiverOpenDingTalkId",
|
||||
wantValue: "OP123",
|
||||
@@ -132,6 +132,16 @@ func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
|
||||
args: []string{"--group", "cid-x"},
|
||||
wantErr: "--text (or positional argument) is required",
|
||||
},
|
||||
{
|
||||
name: "direct-user-without-title",
|
||||
args: []string{"--user", "034766", "--text", "hi"},
|
||||
wantErr: "--title is required for direct messages",
|
||||
},
|
||||
{
|
||||
name: "direct-open-dingtalk-id-without-title",
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--text", "hi"},
|
||||
wantErr: "--title is required for direct messages",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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 || linux
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// fileDEK retrieves or generates a Data Encryption Key stored as a plain
|
||||
// file under the platform storage directory. Shared by Linux (default) and
|
||||
// the macOS sandbox fallback path (DWS_DISABLE_KEYCHAIN=1).
|
||||
func fileDEK(service string) ([]byte, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, "dek")
|
||||
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if err == nil && len(key) == dekBytes {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create keychain dir: %w", err)
|
||||
}
|
||||
|
||||
key = make([]byte, dekBytes)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("generate dek: %w", err)
|
||||
}
|
||||
|
||||
tmpKeyPath := filepath.Join(dir, "dek."+uuid.New().String()+".tmp")
|
||||
defer os.Remove(tmpKeyPath)
|
||||
|
||||
if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil {
|
||||
return nil, fmt.Errorf("write dek: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpKeyPath, keyPath); err != nil {
|
||||
// If rename fails, another process might have created it. Try reading again.
|
||||
existingKey, readErr := os.ReadFile(keyPath)
|
||||
if readErr == nil && len(existingKey) == dekBytes {
|
||||
return existingKey, nil
|
||||
}
|
||||
return nil, fmt.Errorf("save dek: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
@@ -30,6 +30,14 @@ const (
|
||||
// real user environment and from sibling test packages running in
|
||||
// parallel. When empty, the platform default applies.
|
||||
StorageDirEnv = "DWS_KEYCHAIN_DIR"
|
||||
|
||||
// DisableKeychainEnv opts the macOS implementation out of system
|
||||
// Keychain access for the DEK, falling back to a file-based DEK
|
||||
// (same scheme as Linux). Intended for sandboxed runtimes where
|
||||
// Keychain APIs are blocked (e.g. Codex App). This weakens the
|
||||
// at-rest protection — DEK and ciphertext live in the same
|
||||
// directory — and is therefore opt-in.
|
||||
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
|
||||
)
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
|
||||
@@ -59,8 +59,16 @@ func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
// getDEK retrieves or generates the Data Encryption Key from system Keychain.
|
||||
// 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) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return fileDEK(service)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
|
||||
defer cancel()
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// This is the support path for sandboxed runtimes such as Codex App.
|
||||
func TestDisableKeychainFallback(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, tmp)
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
|
||||
service := "test-disable-keychain"
|
||||
account := "auth-token"
|
||||
payload := `{"access_token":"abc","refresh_token":"def"}`
|
||||
|
||||
if err := Set(service, account, payload); err != nil {
|
||||
t.Fatalf("Set() error = %v", err)
|
||||
}
|
||||
|
||||
// File DEK must materialize on disk.
|
||||
dekPath := filepath.Join(tmp, service, "dek")
|
||||
info, err := os.Stat(dekPath)
|
||||
if err != nil {
|
||||
t.Fatalf("file DEK not created at %s: %v", dekPath, err)
|
||||
}
|
||||
if mode := info.Mode().Perm(); mode != 0600 {
|
||||
t.Fatalf("DEK file perm = %o, want 0600", mode)
|
||||
}
|
||||
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if got != payload {
|
||||
t.Fatalf("Get() = %q, want %q", got, payload)
|
||||
}
|
||||
|
||||
// A second Get must reuse the same DEK (no regeneration).
|
||||
dek1, err := os.ReadFile(dekPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(dek) error = %v", err)
|
||||
}
|
||||
if _, err := Get(service, account); err != nil {
|
||||
t.Fatalf("second Get() error = %v", err)
|
||||
}
|
||||
dek2, err := os.ReadFile(dekPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(dek) second error = %v", err)
|
||||
}
|
||||
if string(dek1) != string(dek2) {
|
||||
t.Fatal("DEK rotated between calls; want stable")
|
||||
}
|
||||
|
||||
if err := Remove(service, account); err != nil {
|
||||
t.Fatalf("Remove() error = %v", err)
|
||||
}
|
||||
if Exists(service, account) {
|
||||
t.Fatal("Exists() = true after Remove(), want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisableKeychainOverwrite verifies the fallback path supports
|
||||
// overwriting an existing token entry.
|
||||
func TestDisableKeychainOverwrite(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, tmp)
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
|
||||
service := "test-disable-keychain-overwrite"
|
||||
account := "auth-token"
|
||||
|
||||
if err := Set(service, account, "initial"); err != nil {
|
||||
t.Fatalf("Set() initial error = %v", err)
|
||||
}
|
||||
if err := Set(service, account, "overwritten"); err != nil {
|
||||
t.Fatalf("Set() overwrite error = %v", err)
|
||||
}
|
||||
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if got != "overwritten" {
|
||||
t.Fatalf("Get() = %q, want %q", got, "overwritten")
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// getDEK retrieves or generates the Data Encryption Key from local file.
|
||||
func getDEK(service string) ([]byte, error) {
|
||||
return fileDEK(service)
|
||||
}
|
||||
|
||||
const (
|
||||
dekBytes = 32 // DEK = Data Encryption Key (AES-256)
|
||||
ivBytes = 12
|
||||
@@ -56,48 +61,6 @@ func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
// getDEK retrieves or generates the Data Encryption Key from local file.
|
||||
func getDEK(service string) ([]byte, error) {
|
||||
dir := StorageDir(service)
|
||||
keyPath := filepath.Join(dir, "dek")
|
||||
|
||||
// Try to read existing DEK
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if err == nil && len(key) == dekBytes {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("create keychain dir: %w", err)
|
||||
}
|
||||
|
||||
// Generate new random DEK
|
||||
key = make([]byte, dekBytes)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("generate dek: %w", err)
|
||||
}
|
||||
|
||||
// Atomic write to prevent multi-process initialization collision
|
||||
tmpKeyPath := filepath.Join(dir, "dek."+uuid.New().String()+".tmp")
|
||||
defer os.Remove(tmpKeyPath)
|
||||
|
||||
if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil {
|
||||
return nil, fmt.Errorf("write dek: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpKeyPath, keyPath); err != nil {
|
||||
// If rename fails, another process might have created it. Try reading again.
|
||||
existingKey, readErr := os.ReadFile(keyPath)
|
||||
if readErr == nil && len(existingKey) == dekBytes {
|
||||
return existingKey, nil
|
||||
}
|
||||
return nil, fmt.Errorf("save dek: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func encryptData(plaintext string, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
|
||||
@@ -173,8 +173,16 @@ type CLIOutputFormat struct {
|
||||
|
||||
// CLIToolOverride maps an MCP tool to a CLI command with flag aliases and transforms.
|
||||
type CLIToolOverride struct {
|
||||
CLIName string `json:"cliName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CLIName string `json:"cliName"`
|
||||
// CLIAliases registers additional cobra command aliases for the same MCP
|
||||
// tool, so the leaf command can be invoked under multiple names without
|
||||
// duplicating the override. Mirrors cobra.Command.Aliases. Each alias is
|
||||
// added to the cobra Aliases slice; conflicts with existing siblings are
|
||||
// silently ignored by cobra. Use for command-name normalisation (e.g.
|
||||
// `range read` accepts `range get` as an alias) or hardcoded-command
|
||||
// migration paths. Empty / nil means no extra aliases.
|
||||
CLIAliases []string `json:"cliAliases,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// Example, when non-empty, is wired to cobra.Command.Example to render
|
||||
// the "Examples:" section in --help. Mirrors hardcoded helper commands'
|
||||
// Example field (e.g. wukong/products/oa.go list-forms). Empty value
|
||||
@@ -212,6 +220,56 @@ type CLIToolOverride struct {
|
||||
// fields (Flags / BodyWrapper / IsSensitive / ServerOverride) are
|
||||
// ignored. Use for deprecated leaf commands that moved to a new path.
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
// Pipeline declares a multi-step orchestration: each step calls one
|
||||
// MCP tool, with subsequent steps able to reference prior step outputs
|
||||
// in their argument templates. PollUntilField/Value turn a step into a
|
||||
// polling loop (for async jobs); type:"download" turns a step into an
|
||||
// HTTP download sink that writes to a CLI-local --output flag. When
|
||||
// Pipeline is non-empty, dispatch ignores the parent toolOverrides map
|
||||
// key (no single "primary tool"); the executor walks the steps in
|
||||
// order. CLI surface (CLIName / Group / Flags) still comes from the
|
||||
// parent override; flags can be marked PipelineLocal=true to be
|
||||
// consumed by the executor without being forwarded to MCP tools.
|
||||
//
|
||||
// See internal/compat/pipeline.go for the executor + envelope examples.
|
||||
Pipeline []PipelineStep `json:"pipeline,omitempty"`
|
||||
}
|
||||
|
||||
// PipelineStep declares one step in a multi-step CLIToolOverride.Pipeline.
|
||||
// Templates supported in Args / DownloadURLField:
|
||||
//
|
||||
// $flag.<aliasName> — value of the user's CLI flag whose alias is
|
||||
// <aliasName> (resolved at dispatch time).
|
||||
// $step.<idx>.<dotPath> — field from a prior step's response, e.g.
|
||||
// "$step.0.jobId" or "$step.1.result.url".
|
||||
// literal value — passed through unchanged.
|
||||
type PipelineStep struct {
|
||||
// Type controls dispatch. Empty / "call" invokes Tool as an MCP tool.
|
||||
// "download" treats this step as an HTTP GET sink (no MCP tool is
|
||||
// invoked); URL is resolved from DownloadURLField.
|
||||
Type string `json:"type,omitempty"`
|
||||
// Tool is the MCP tool name to invoke for type=="call".
|
||||
Tool string `json:"tool,omitempty"`
|
||||
// Args maps MCP tool parameter names to template strings.
|
||||
Args map[string]string `json:"args,omitempty"`
|
||||
// PollUntilField, when non-empty (with PollUntilValue), turns this
|
||||
// step into a polling loop: invoke repeatedly with the same Args
|
||||
// until response[<PollUntilField>] equals PollUntilValue (string
|
||||
// compare). Use for async-job patterns where a status field
|
||||
// transitions to a terminal value (e.g. "done" / "succeeded").
|
||||
PollUntilField string `json:"pollUntilField,omitempty"`
|
||||
PollUntilValue string `json:"pollUntilValue,omitempty"`
|
||||
PollIntervalSec int `json:"pollIntervalSec,omitempty"` // default 2 when polling
|
||||
PollTimeoutSec int `json:"pollTimeoutSec,omitempty"` // default 300 when polling
|
||||
// DownloadURLField (type=="download") is a $step.X.field template
|
||||
// that resolves to an HTTP URL. The body is fetched via GET and
|
||||
// written to the path given by OutputFlag's value. If OutputFlag's
|
||||
// value is empty, the URL is printed to stdout for the user.
|
||||
DownloadURLField string `json:"downloadURLField,omitempty"`
|
||||
// OutputFlag (type=="download") names the CLI flag (alias) whose
|
||||
// user-supplied value is the local destination path. When the path
|
||||
// is a directory, the filename is inferred from the URL's basename.
|
||||
OutputFlag string `json:"outputFlag,omitempty"`
|
||||
}
|
||||
|
||||
// CLIFlagOverride describes how to map an MCP parameter to a CLI flag.
|
||||
@@ -261,6 +319,12 @@ type CLIFlagOverride struct {
|
||||
// Resolution comes from edition.Hooks.RuntimeDefaults; open-source core
|
||||
// only recognises the placeholder set. See schema v3 §2.3.
|
||||
RuntimeDefault string `json:"runtimeDefault,omitempty"`
|
||||
// PipelineLocal, when true, marks this flag as CLI-side only — its
|
||||
// value is consumed by the pipeline executor (e.g. as an HTTP
|
||||
// download destination) and NOT forwarded to any MCP tool's params.
|
||||
// Only meaningful when the enclosing CLIToolOverride.Pipeline is set.
|
||||
// Use for flags like `--output` that describe local destination paths.
|
||||
PipelineLocal bool `json:"pipelineLocal,omitempty"`
|
||||
}
|
||||
|
||||
type CLITool struct {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// 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 output
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"io"
|
||||
)
|
||||
|
||||
// writeCSV renders a payload as RFC-4180 CSV.
|
||||
//
|
||||
// It mirrors the shape decisions `-f table` already makes (same helpers:
|
||||
// normalizePayload / unwrapPrimaryObject / extractRowsFromMap / rowsFromSlice /
|
||||
// formatValue), so column order and value flattening stay consistent between
|
||||
// the two formats:
|
||||
//
|
||||
// - a list of objects — either a bare [{...},...] or wrapped under a
|
||||
// well-known key ({items|results|data|records|...}) — becomes a header row
|
||||
// plus one row per element. The union of keys (sorted) is the column set;
|
||||
// missing values are empty cells; nested objects/arrays render as compact
|
||||
// JSON in the cell. Any sibling metadata of the list (total, hasMore, ...)
|
||||
// is broadcast as extra trailing columns, repeated on every row, so a CSV
|
||||
// consumer never loses it (CSV has no "two tables in one file" concept the
|
||||
// way the table renderer's footer does). Meta keys that collide with a data
|
||||
// column are skipped. An empty list still emits the header plus one row of
|
||||
// empty data cells carrying just the meta values.
|
||||
// - a single object becomes a two-column `key,value` CSV.
|
||||
// - a non-uniform list or a scalar becomes a single-column `value` CSV.
|
||||
//
|
||||
// `--fields` projection composes for free: WriteFiltered applies SelectFields
|
||||
// before Write reaches us, so the rows are already narrowed.
|
||||
//
|
||||
// encoding/csv.Writer handles quoting/escaping of commas, double quotes and
|
||||
// embedded newlines; cell text goes through formatValue (which also strips
|
||||
// terminal control sequences, same as the table renderer).
|
||||
func writeCSV(w io.Writer, payload any) error {
|
||||
normalized, err := normalizePayload(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
|
||||
switch typed := normalized.(type) {
|
||||
case map[string]any:
|
||||
// Try table extraction first so wrappers around list payloads
|
||||
// (e.g. {result: {todoCards: [...]}}) render as a real table
|
||||
// instead of being peeled by unwrapPrimaryObject and degraded
|
||||
// to key/value rows. unwrapPrimaryObject is then the fallback
|
||||
// for single-object wrappers like {invocation: {...}}.
|
||||
if headers, rows, meta, ok := extractRowsFromMap(typed); ok {
|
||||
headers, rows = broadcastMeta(headers, rows, meta)
|
||||
return writeTableCSV(cw, headers, rows)
|
||||
}
|
||||
if inner, ok := unwrapPrimaryObject(typed); ok {
|
||||
return writeKeyValueCSV(cw, inner)
|
||||
}
|
||||
return writeKeyValueCSV(cw, typed)
|
||||
case []any:
|
||||
headers, rows, _ := rowsFromSlice(typed)
|
||||
return writeTableCSV(cw, headers, rows)
|
||||
case nil:
|
||||
// Nothing to write — emit an empty document rather than erroring.
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
default:
|
||||
// Scalar: a single-cell, single-row CSV.
|
||||
if err := cw.Write([]string{formatValue(normalized)}); err != nil {
|
||||
return err
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func writeTableCSV(cw *csv.Writer, headers []string, rows [][]string) error {
|
||||
if err := cw.Write(headers); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
// rowsFromSlice / extractRowsFromMap already guarantee
|
||||
// len(row) == len(headers), but stay defensive against future callers.
|
||||
if len(row) != len(headers) {
|
||||
padded := make([]string, len(headers))
|
||||
copy(padded, row)
|
||||
row = padded
|
||||
}
|
||||
if err := cw.Write(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
// broadcastMeta appends the list's sibling metadata (total, hasMore, ...) as
|
||||
// trailing columns repeated on every row. Meta keys that collide with an
|
||||
// existing data column are skipped. If there are no rows but there is meta, a
|
||||
// single row of empty data cells is emitted so the meta values aren't lost.
|
||||
func broadcastMeta(headers []string, rows [][]string, meta map[string]any) ([]string, [][]string) {
|
||||
if len(meta) == 0 {
|
||||
return headers, rows
|
||||
}
|
||||
existing := make(map[string]bool, len(headers))
|
||||
for _, h := range headers {
|
||||
existing[h] = true
|
||||
}
|
||||
var metaKeys []string
|
||||
var metaVals []string
|
||||
for _, k := range sortedMapKeys(meta) {
|
||||
if existing[k] {
|
||||
continue
|
||||
}
|
||||
metaKeys = append(metaKeys, k)
|
||||
metaVals = append(metaVals, formatValue(meta[k]))
|
||||
}
|
||||
if len(metaKeys) == 0 {
|
||||
return headers, rows
|
||||
}
|
||||
|
||||
outHeaders := append(append([]string{}, headers...), metaKeys...)
|
||||
if len(rows) == 0 {
|
||||
emptyData := make([]string, len(headers))
|
||||
return outHeaders, [][]string{append(emptyData, metaVals...)}
|
||||
}
|
||||
outRows := make([][]string, len(rows))
|
||||
for i, r := range rows {
|
||||
nr := make([]string, 0, len(outHeaders))
|
||||
nr = append(nr, r...)
|
||||
nr = append(nr, metaVals...)
|
||||
outRows[i] = nr
|
||||
}
|
||||
return outHeaders, outRows
|
||||
}
|
||||
|
||||
func writeKeyValueCSV(cw *csv.Writer, m map[string]any) error {
|
||||
if err := cw.Write([]string{"key", "value"}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range sortedMapKeys(m) {
|
||||
if err := cw.Write([]string{key, formatValue(m[key])}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
+26
-14
@@ -82,16 +82,21 @@ type dataListLocation struct {
|
||||
|
||||
// findDataList walks the object tree looking for the first array of
|
||||
// objects under well-known keys. It searches both top-level and one
|
||||
// level deep (e.g. result.value, response.items).
|
||||
// level deep (e.g. result.value, response.items). The allow-list lives
|
||||
// in preferredListKeys (formatter.go) and is shared with the table /
|
||||
// csv renderers so all tabular formatters agree on what counts as the
|
||||
// data list.
|
||||
func findDataList(m map[string]any) *dataListLocation {
|
||||
listKeys := []string{"value", "items", "results", "data", "list", "records", "tools", "servers", "products"}
|
||||
|
||||
// Top-level: {value: [...]}
|
||||
for _, key := range listKeys {
|
||||
if arr, ok := m[key].([]any); ok && len(arr) > 0 {
|
||||
if _, isMap := arr[0].(map[string]any); isMap {
|
||||
return &dataListLocation{list: arr, innerKey: key}
|
||||
}
|
||||
// Top-level: {value: [...]}. Empty arrays under a preferred key still
|
||||
// match so an "empty list + metadata" payload renders as an empty table
|
||||
// (with the meta broadcast) rather than degrading to key/value rows.
|
||||
for _, key := range preferredListKeys {
|
||||
arr, ok := m[key].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(arr) == 0 || isMapValue(arr[0]) {
|
||||
return &dataListLocation{list: arr, innerKey: key}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,11 +106,13 @@ func findDataList(m map[string]any) *dataListLocation {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, key := range listKeys {
|
||||
if arr, ok := inner[key].([]any); ok && len(arr) > 0 {
|
||||
if _, isMap := arr[0].(map[string]any); isMap {
|
||||
return &dataListLocation{list: arr, outerKey: outerKey, innerKey: key}
|
||||
}
|
||||
for _, key := range preferredListKeys {
|
||||
arr, ok := inner[key].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(arr) == 0 || isMapValue(arr[0]) {
|
||||
return &dataListLocation{list: arr, outerKey: outerKey, innerKey: key}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +120,11 @@ func findDataList(m map[string]any) *dataListLocation {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isMapValue(v any) bool {
|
||||
_, ok := v.(map[string]any)
|
||||
return ok
|
||||
}
|
||||
|
||||
// filterSlice applies field filtering to each object element in a
|
||||
// slice. Non-object elements are passed through unchanged.
|
||||
func filterSlice(items []any, wanted map[string]bool) []any {
|
||||
|
||||
@@ -33,9 +33,26 @@ const (
|
||||
FormatTable Format = "table"
|
||||
FormatRaw Format = "raw"
|
||||
FormatPretty Format = "pretty"
|
||||
// FormatNDJSON emits one JSON object per line — friendly for streaming /
|
||||
// piping list results into downstream tools. See ndjson.go.
|
||||
FormatNDJSON Format = "ndjson"
|
||||
// FormatCSV emits RFC-4180 comma-separated values for list-shaped results —
|
||||
// friendly for spreadsheets and non-technical consumers. See csv.go.
|
||||
FormatCSV Format = "csv"
|
||||
)
|
||||
|
||||
var preferredListKeys = []string{"items", "results", "data", "list", "records", "tools", "servers", "products"}
|
||||
// preferredListKeys is the shared allow-list of keys whose array values are
|
||||
// treated as the "data list" by all tabular formatters (-f table / csv /
|
||||
// ndjson). It is the single source of truth — findDataList in filter.go
|
||||
// reuses it. When adding a new key, prefer real envelope keys observed in
|
||||
// production responses over speculative future names.
|
||||
var preferredListKeys = []string{
|
||||
// Generic well-known list keys.
|
||||
"value", "items", "results", "data", "list", "records",
|
||||
"tools", "servers", "products",
|
||||
// Envelope keys observed in real DingTalk responses.
|
||||
"result", "documents", "emailAccounts", "todoCards", "events", "messages",
|
||||
}
|
||||
|
||||
func ResolveFormat(cmd *cobra.Command, fallback Format) Format {
|
||||
if cmd == nil {
|
||||
@@ -78,6 +95,10 @@ func Write(w io.Writer, format Format, payload any) error {
|
||||
return writeTableish(w, payload)
|
||||
case FormatPretty:
|
||||
return writePretty(w, payload)
|
||||
case FormatNDJSON:
|
||||
return writeNDJSON(w, payload)
|
||||
case FormatCSV:
|
||||
return writeCSV(w, payload)
|
||||
default:
|
||||
return WriteJSON(w, payload)
|
||||
}
|
||||
@@ -128,6 +149,10 @@ func normalizeFormat(raw string, fallback Format) Format {
|
||||
return FormatTable
|
||||
case string(FormatPretty):
|
||||
return FormatPretty
|
||||
case string(FormatNDJSON):
|
||||
return FormatNDJSON
|
||||
case string(FormatCSV):
|
||||
return FormatCSV
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
@@ -261,9 +286,11 @@ func writeTableish(w io.Writer, payload any) error {
|
||||
|
||||
switch typed := normalized.(type) {
|
||||
case map[string]any:
|
||||
if inner, ok := unwrapPrimaryObject(typed); ok {
|
||||
return writeKeyValues(w, inner)
|
||||
}
|
||||
// Try table extraction first so wrappers around list payloads
|
||||
// (e.g. {result: {todoCards: [...]}}) render as a table instead
|
||||
// of being peeled by unwrapPrimaryObject and degraded to key/
|
||||
// value rows. unwrapPrimaryObject remains the fallback for
|
||||
// single-object wrappers like {invocation: {kind, params, ...}}.
|
||||
if headers, rows, meta, ok := extractRowsFromMap(typed); ok {
|
||||
if err := writeTable(w, headers, rows); err != nil {
|
||||
return err
|
||||
@@ -276,6 +303,9 @@ func writeTableish(w io.Writer, payload any) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if inner, ok := unwrapPrimaryObject(typed); ok {
|
||||
return writeKeyValues(w, inner)
|
||||
}
|
||||
return writeKeyValues(w, typed)
|
||||
case []any:
|
||||
if headers, rows, ok := rowsFromSlice(typed); ok {
|
||||
@@ -322,30 +352,52 @@ func unwrapPrimaryObject(payload map[string]any) (map[string]any, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// extractRowsFromMap finds the data list inside a wrapper map and returns it
|
||||
// as (headers, rows, meta). It delegates the search to findDataList so the
|
||||
// detection rules stay aligned with -f ndjson: top-level under a preferred
|
||||
// key, or one level deep under {result|response|data}. Meta is built from
|
||||
// every sibling of the list — at both the outer and inner level when the
|
||||
// list sits one level deep — so callers like the table renderer's footer and
|
||||
// the csv broadcastMeta path see the same key set.
|
||||
func extractRowsFromMap(payload map[string]any) ([]string, [][]string, map[string]any, bool) {
|
||||
for _, key := range preferredListKeys {
|
||||
value, ok := payload[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
list, ok := value.([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
headers, rows, ok := rowsFromSlice(list)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta := make(map[string]any, len(payload)-1)
|
||||
for metaKey, metaValue := range payload {
|
||||
if metaKey == key {
|
||||
loc := findDataList(payload)
|
||||
if loc == nil {
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
headers, rows, ok := rowsFromSlice(loc.list)
|
||||
if !ok {
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
meta := make(map[string]any)
|
||||
if loc.outerKey == "" {
|
||||
for k, v := range payload {
|
||||
if k == loc.innerKey {
|
||||
continue
|
||||
}
|
||||
meta[metaKey] = metaValue
|
||||
meta[k] = v
|
||||
}
|
||||
} else {
|
||||
for k, v := range payload {
|
||||
if k == loc.outerKey {
|
||||
continue
|
||||
}
|
||||
meta[k] = v
|
||||
}
|
||||
if inner, ok := payload[loc.outerKey].(map[string]any); ok {
|
||||
for k, v := range inner {
|
||||
if k == loc.innerKey {
|
||||
continue
|
||||
}
|
||||
if _, exists := meta[k]; exists {
|
||||
// Outer wins on key collision so users see the wrapper-level
|
||||
// sibling rather than a clobbered inner one.
|
||||
continue
|
||||
}
|
||||
meta[k] = v
|
||||
}
|
||||
}
|
||||
return headers, rows, meta, true
|
||||
}
|
||||
return nil, nil, nil, false
|
||||
return headers, rows, meta, true
|
||||
}
|
||||
|
||||
func rowsFromSlice(items []any) ([]string, [][]string, bool) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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 output
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
)
|
||||
|
||||
// writeNDJSON renders payload as newline-delimited JSON (https://ndjson.org):
|
||||
// - a top-level array → one element per line
|
||||
// - an object that wraps a → one element of that list per line
|
||||
// well-known list key (items / results / data / records / value / ...)
|
||||
// - anything else → a single line containing the whole value
|
||||
//
|
||||
// This is the streaming-friendly counterpart to `-f json`: each line is an
|
||||
// independent, compact JSON document so consumers can `jq -c`, `while read`,
|
||||
// or pipe into log pipelines without buffering the whole response.
|
||||
//
|
||||
// TODO(#252): consider honouring --fields per-line projection here too (today
|
||||
// WriteFiltered already applies SelectFields before Write is reached, so this
|
||||
// works, but a dedicated test would be good). Also decide whether non-list
|
||||
// payloads should error under `-f ndjson` instead of degrading to one line.
|
||||
func writeNDJSON(w io.Writer, payload any) error {
|
||||
normalized, err := roundTripJSON(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bw := bufio.NewWriter(w)
|
||||
enc := json.NewEncoder(bw)
|
||||
// json.Encoder.Encode already appends a trailing newline per call.
|
||||
|
||||
switch v := normalized.(type) {
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if err := enc.Encode(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
if loc := findDataList(v); loc != nil {
|
||||
for _, item := range loc.list {
|
||||
if err := enc.Encode(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return bw.Flush()
|
||||
}
|
||||
|
||||
// roundTripJSON normalizes an arbitrary Go value into the
|
||||
// map[string]any / []any / scalar shape used by the rest of this package by
|
||||
// marshalling and unmarshalling it through encoding/json.
|
||||
func roundTripJSON(payload any) (any, error) {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// 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 output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeFormatRecognizesNDJSONAndCSV(t *testing.T) {
|
||||
if got := normalizeFormat("ndjson", FormatJSON); got != FormatNDJSON {
|
||||
t.Errorf("normalizeFormat(ndjson) = %q, want %q", got, FormatNDJSON)
|
||||
}
|
||||
if got := normalizeFormat("CSV", FormatJSON); got != FormatCSV {
|
||||
t.Errorf("normalizeFormat(CSV) = %q, want %q", got, FormatCSV)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNDJSON(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
payload any
|
||||
wantLines []string
|
||||
}{
|
||||
{
|
||||
name: "top-level array",
|
||||
payload: []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}},
|
||||
wantLines: []string{`{"id":"1"}`, `{"id":"2"}`},
|
||||
},
|
||||
{
|
||||
name: "wrapped list",
|
||||
payload: map[string]any{"items": []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}}, "count": 2},
|
||||
wantLines: []string{`{"id":"1"}`, `{"id":"2"}`},
|
||||
},
|
||||
{
|
||||
name: "scalar-ish object",
|
||||
payload: map[string]any{"ok": true},
|
||||
wantLines: []string{`{"ok":true}`},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Write(&buf, FormatNDJSON, tc.payload); err != nil {
|
||||
t.Fatalf("Write(ndjson) error = %v", err)
|
||||
}
|
||||
got := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
if len(got) != len(tc.wantLines) {
|
||||
t.Fatalf("got %d lines %q, want %d %q", len(got), got, len(tc.wantLines), tc.wantLines)
|
||||
}
|
||||
for i, want := range tc.wantLines {
|
||||
if strings.TrimSpace(got[i]) != want {
|
||||
t.Errorf("line %d = %q, want %q", i, got[i], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCSV(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
payload any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// Union of keys (sorted), missing values → empty cells, a field with
|
||||
// a comma gets quoted, CJK passes through verbatim, a nested array is
|
||||
// rendered as compact JSON with its quotes CSV-escaped.
|
||||
name: "list of objects",
|
||||
payload: []any{
|
||||
map[string]any{"id": "1", "name": "张三"},
|
||||
map[string]any{"id": "2", "name": "Bob, Jr."},
|
||||
map[string]any{"id": "3", "tags": []any{"x", "y"}},
|
||||
},
|
||||
want: "id,name,tags\n" +
|
||||
"1,张三,\n" +
|
||||
"2,\"Bob, Jr.\",\n" +
|
||||
"3,,\"[\"\"x\"\",\"\"y\"\"]\"\n",
|
||||
},
|
||||
{
|
||||
// {records:[...], total:N}: the list becomes the table; sibling
|
||||
// metadata (total) is broadcast as a trailing column on every row.
|
||||
name: "wrapped list with metadata",
|
||||
payload: map[string]any{
|
||||
"records": []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}},
|
||||
"total": 2,
|
||||
},
|
||||
want: "id,total\n1,2\n2,2\n",
|
||||
},
|
||||
{
|
||||
// Empty list + metadata: still emit the header (data + meta) plus a
|
||||
// single row of empty data cells carrying the meta values.
|
||||
name: "empty wrapped list with metadata",
|
||||
payload: map[string]any{
|
||||
"records": []any{},
|
||||
"total": 0,
|
||||
"hasMore": false,
|
||||
},
|
||||
want: "value,hasMore,total\n,false,0\n",
|
||||
},
|
||||
{
|
||||
// A plain object → two-column key,value CSV with keys sorted.
|
||||
name: "single object",
|
||||
payload: map[string]any{"ok": true, "name": "x"},
|
||||
want: "key,value\nname,x\nok,true\n",
|
||||
},
|
||||
{
|
||||
name: "scalar",
|
||||
payload: "hello",
|
||||
want: "hello\n",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Write(&buf, FormatCSV, tc.payload); err != nil {
|
||||
t.Fatalf("Write(csv) error = %v", err)
|
||||
}
|
||||
if got := buf.String(); got != tc.want {
|
||||
t.Errorf("Write(csv) =\n%q\nwant\n%q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteCSVComposesWithFields guards that --fields projection (applied by
|
||||
// WriteFiltered before Write) narrows the CSV columns.
|
||||
func TestWriteCSVComposesWithFields(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{"id": "1", "name": "Alice", "secret": "s1"},
|
||||
map[string]any{"id": "2", "name": "Bob", "secret": "s2"},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteFiltered(&buf, FormatCSV, payload, "id,name", ""); err != nil {
|
||||
t.Fatalf("WriteFiltered(csv) error = %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
if strings.Contains(got, "secret") || strings.Contains(got, "s1") {
|
||||
t.Errorf("--fields did not drop the secret column; got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "id,name") || !strings.Contains(got, "Alice") {
|
||||
t.Errorf("expected projected columns id,name with values; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTabularDetectsRealDingTalkEnvelopes guards against shipping a -f csv /
|
||||
// -f ndjson that degrades to one-line-key-value for the envelope shapes the
|
||||
// real product surface actually returns. Each case is a payload shape observed
|
||||
// in production (contact / doc / mail / todo / chat search responses).
|
||||
func TestTabularDetectsRealDingTalkEnvelopes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
wantNDLines int // expected line count from -f ndjson
|
||||
wantCSVHead string // first header line of -f csv
|
||||
}{
|
||||
{
|
||||
name: "result direct array (contact user search)",
|
||||
payload: map[string]any{
|
||||
"result": []any{map[string]any{"name": "张三", "userId": "123"}, map[string]any{"name": "李四", "userId": "456"}},
|
||||
"success": true,
|
||||
},
|
||||
wantNDLines: 2,
|
||||
wantCSVHead: "name,userId,success",
|
||||
},
|
||||
{
|
||||
name: "documents top-level (doc search)",
|
||||
payload: map[string]any{
|
||||
"documents": []any{map[string]any{"nodeId": "n1", "name": "A"}, map[string]any{"nodeId": "n2", "name": "B"}},
|
||||
"hasMore": true,
|
||||
"nextPageToken": "tok",
|
||||
},
|
||||
wantNDLines: 2,
|
||||
wantCSVHead: "name,nodeId,hasMore,nextPageToken",
|
||||
},
|
||||
{
|
||||
name: "emailAccounts top-level (mail mailbox list)",
|
||||
payload: map[string]any{
|
||||
"emailAccounts": []any{map[string]any{"email": "a@b.com", "type": "ORG"}},
|
||||
"success": "true",
|
||||
},
|
||||
wantNDLines: 1,
|
||||
wantCSVHead: "email,type,success",
|
||||
},
|
||||
{
|
||||
name: "todoCards under result wrapper (todo task list)",
|
||||
payload: map[string]any{
|
||||
"result": map[string]any{
|
||||
"todoCards": []any{
|
||||
map[string]any{"taskId": "t1", "subject": "做一做"},
|
||||
map[string]any{"taskId": "t2", "subject": "再做一做"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantNDLines: 2,
|
||||
wantCSVHead: "subject,taskId",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var nd bytes.Buffer
|
||||
if err := Write(&nd, FormatNDJSON, tc.payload); err != nil {
|
||||
t.Fatalf("ndjson write: %v", err)
|
||||
}
|
||||
ndLines := strings.Split(strings.TrimRight(nd.String(), "\n"), "\n")
|
||||
if len(ndLines) != tc.wantNDLines {
|
||||
t.Errorf("ndjson: got %d lines %q, want %d", len(ndLines), ndLines, tc.wantNDLines)
|
||||
}
|
||||
|
||||
var c bytes.Buffer
|
||||
if err := Write(&c, FormatCSV, tc.payload); err != nil {
|
||||
t.Fatalf("csv write: %v", err)
|
||||
}
|
||||
gotHead := strings.SplitN(c.String(), "\n", 2)[0]
|
||||
if gotHead != tc.wantCSVHead {
|
||||
t.Errorf("csv header: got %q, want %q", gotHead, tc.wantCSVHead)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dws
|
||||
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件时使用。
|
||||
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。
|
||||
cli_version: ">=1.0.15"
|
||||
---
|
||||
|
||||
@@ -37,7 +37,9 @@ 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/区域读写/行列增删/合并/查找替换/筛选视图/导出(两步)/图片 | [sheet.md](./references/products/sheet.md) |
|
||||
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
|
||||
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 | [wiki.md](./references/products/wiki.md) |
|
||||
|
||||
## 意图判断决策树
|
||||
|
||||
@@ -54,7 +56,9 @@ cli_version: ">=1.0.15"
|
||||
用户提到"邮箱/邮件/发邮件/收邮件/搜邮件/查邮件" → `mail`
|
||||
用户提到"审批/请假/报销/出差/加班/同意/拒绝/撤销审批" → `oa`
|
||||
用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report`
|
||||
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
|
||||
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
|
||||
用户提到"知识库/wiki/团队空间/知识库成员管理" → `wiki`
|
||||
|
||||
关键区分: aitable(数据表格) vs todo(待办任务)
|
||||
关键区分: report(钉钉日志/日报周报) vs todo(待办任务)
|
||||
|
||||
@@ -183,7 +183,7 @@ Flags:
|
||||
|
||||
## message send — 以当前用户身份发消息
|
||||
|
||||
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。必须提供 --title 作为消息标题。
|
||||
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。单聊消息(--user / --open-dingtalk-id)必须提供 --title 作为消息标题;群聊可选。
|
||||
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
|
||||
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
|
||||
|
||||
@@ -192,8 +192,8 @@ Usage:
|
||||
dws chat message send [flags] [<text>]
|
||||
Example:
|
||||
dws chat message send --group <openconversation_id> --text "hello"
|
||||
dws chat message send --user <userId> --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --text "请查收"
|
||||
dws chat message send --user <userId> --title "提醒" --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请查收"
|
||||
dws chat message send --group <openconversation_id> "hello"
|
||||
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
|
||||
dws chat message send --group <openconversation_id> --at-all "<@all> 请大家注意"
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
# 在线电子表格 (sheet) 命令参考
|
||||
|
||||
## 适用范围(重要)
|
||||
|
||||
`sheet` 产品**仅支持钉钉在线电子表格**(`contentType=ALIDOC`、`extension=axls`),**不支持**上传的 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件。
|
||||
|
||||
| 文件类型 | 处理方式 |
|
||||
|---------|---------|
|
||||
| 在线电子表格(`axls`) | 走 `sheet` 全部命令(读/写/筛选/合并/导出等服务端原子操作) |
|
||||
| `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件 | 必须用 `dws doc download --node <ID> --output <路径>` 先下载到本地再用本地工具解析,**禁止**调用任何 `sheet` 子命令 |
|
||||
| 想把在线表格导出为 xlsx | 用 `dws sheet submit_export_job` 提交导出任务 → 拿到 `jobId` → `dws sheet query_export_job` 轮询直到 `finished` → 用返回的 `downloadUrl` 下载 |
|
||||
|
||||
> 用户直接粘贴 `alidocs` URL 时,先用 `dws doc info --node <URL> --format json` 确认 `contentType=ALIDOC` 且 `extension=axls` 后再走 `sheet`;否则转 `dws doc download`。
|
||||
|
||||
## 命名风格说明(v1.0.25 envelope 现状)
|
||||
|
||||
`sheet` 产品的命令 cli_name **当前同时存在两种风格**——这是 envelope schema 还在演进中、`CLIAliases` (#246) 重命名尚未完成的过渡态:
|
||||
|
||||
- **kebab-case** (~17 个):`add-dimension`、`merge-cells`、`filter-view update-criteria` 等,与 dws 其它产品风格一致
|
||||
- **snake_case** (~12 个):`copy_sheet`、`submit_export_job`、`set_filter_criteria` 等,envelope 原始名直透出来
|
||||
|
||||
**调用时以 `dws sheet --help` 输出为准**——本文档与 envelope schema 同步,未来命名收敛后会同步更新。所有命令的最终参数名以 `dws sheet <cmd> --help` 和 `dws schema sheet.<canonical_path>` 为准。
|
||||
|
||||
## 命令总览(按功能分组)
|
||||
|
||||
### 工作表 (Worksheet) 级
|
||||
|
||||
| 命令 | canonical | 用途 |
|
||||
|------|-----------|------|
|
||||
| `dws sheet create` | `sheet.create_workspace_sheet` | 在知识库中创建一个新的钉钉表格文档 |
|
||||
| `dws sheet new` | `sheet.create_sheet` | 在已有钉钉表格文档中新建一张工作表 |
|
||||
| `dws sheet list` | `sheet.get_all_sheets` | 列出指定文档的所有工作表 |
|
||||
| `dws sheet info` | `sheet.get_sheet` | 获取指定工作表详情 |
|
||||
| `dws sheet copy_sheet` | `sheet.copy_sheet` | 复制工作表(同文档内) |
|
||||
| `dws sheet update_sheet` | `sheet.update_sheet` | 更新工作表元信息(如改名) |
|
||||
|
||||
### 区域 (Range) 读写
|
||||
|
||||
| 命令 | canonical | 用途 |
|
||||
|------|-----------|------|
|
||||
| `dws sheet range read` | `sheet.get_range` | 读取指定区域的单元格内容 |
|
||||
| `dws sheet range update` | `sheet.update_range` | 写入/更新指定区域的单元格 |
|
||||
| `dws sheet append` | `sheet.append_rows` | 在工作表末尾追加若干行 |
|
||||
|
||||
### 行列 (Dimension)
|
||||
|
||||
| 命令 | canonical | 用途 |
|
||||
|------|-----------|------|
|
||||
| `dws sheet add-dimension` | `sheet.add_dimension` | 在末尾追加空行或空列 |
|
||||
| `dws sheet insert-dimension` | `sheet.insert_dimension` | 在指定位置插入空行/空列 |
|
||||
| `dws sheet delete-dimension` | `sheet.delete_dimension` | 删除指定位置起的若干行/列 |
|
||||
| `dws sheet move-dimension` | `sheet.move_dimension` | 移动行/列到指定位置 |
|
||||
| `dws sheet update-dimension` | `sheet.update_dimension` | 更新行/列属性(显隐、行高/列宽) |
|
||||
|
||||
### 单元格合并
|
||||
|
||||
| 命令 | canonical | 用途 |
|
||||
|------|-----------|------|
|
||||
| `dws sheet merge-cells` | `sheet.merge_cells` | 合并指定范围的单元格(`mergeAll`/`mergeRows`/`mergeColumns`) |
|
||||
| `dws sheet unmerge-cells` | `sheet.unmerge_range` | 取消指定范围的合并 |
|
||||
|
||||
### 查找/替换
|
||||
|
||||
| 命令 | canonical | 用途 |
|
||||
|------|-----------|------|
|
||||
| `dws sheet find` | `sheet.find_cells` | 在工作表中搜索单元格内容(支持正则/整格匹配/隐藏) |
|
||||
| `dws sheet replace` | `sheet.replace_all` | 全局查找替换 |
|
||||
|
||||
### 筛选视图 (Filter View) — 命名视图、按列条件、不影响表本身
|
||||
|
||||
| 命令 | canonical | 用途 |
|
||||
|------|-----------|------|
|
||||
| `dws sheet filter-view create` | `sheet.create_filter_view` | 创建筛选视图 |
|
||||
| `dws sheet filter-view list` | `sheet.get_filter_views` | 列出工作表的所有筛选视图 |
|
||||
| `dws sheet filter-view update` | `sheet.update_filter_view` | 更新筛选视图(名称/范围/条件) |
|
||||
| `dws sheet filter-view delete` | `sheet.delete_filter_view` | 删除整个筛选视图 |
|
||||
| `dws sheet filter-view update-criteria` | `sheet.set_filter_view_criteria` | 设置/更新视图内某列的筛选条件 |
|
||||
| `dws sheet filter-view delete-criteria` | `sheet.clear_filter_view_criteria` | 清除视图内某列的筛选条件 |
|
||||
|
||||
### 表级筛选 (Filter) — 直接作用于工作表本身的临时筛选
|
||||
|
||||
| 命令 | canonical | 用途 |
|
||||
|------|-----------|------|
|
||||
| `dws sheet create_filter` | `sheet.create_filter` | 在工作表上创建筛选器 |
|
||||
| `dws sheet get_filter` | `sheet.get_filter` | 获取当前筛选器配置 |
|
||||
| `dws sheet update_filter` | `sheet.update_filter` | 更新筛选器条件 |
|
||||
| `dws sheet delete_filter` | `sheet.delete_filter` | 删除筛选器 |
|
||||
| `dws sheet set_filter_criteria` | `sheet.set_filter_criteria` | 设置某列的筛选条件 |
|
||||
| `dws sheet clear_filter_criteria` | `sheet.clear_filter_criteria` | 清除某列的筛选条件 |
|
||||
| `dws sheet sort_filter` | `sheet.sort_filter` | 对筛选范围按指定列排序 |
|
||||
|
||||
### 图片
|
||||
|
||||
| 命令 | canonical | 用途 |
|
||||
|------|-----------|------|
|
||||
| `dws sheet write-image` | `sheet.write_image` | 将已上传的图片资源写入指定单元格 |
|
||||
|
||||
### 导出(两步原子,**v1.0.25 没有合并的 `export` 命令**)
|
||||
|
||||
| 命令 | canonical | 用途 |
|
||||
|------|-----------|------|
|
||||
| `dws sheet submit_export_job` | `sheet.submit_export_job` | 提交导出任务,返回 `jobId` |
|
||||
| `dws sheet query_export_job` | `sheet.query_export_job` | 轮询导出任务状态,完成后返回 `downloadUrl` |
|
||||
|
||||
> v1.0.25 envelope 暴露的是这两个**原子动作**。要完整完成"导出 → 下载"流程需要 client 侧轮询 + 调 `downloadUrl`。`CLIToolOverride.Pipeline` (#247) 提供了底层编排能力,但**当前 envelope 还没把这两个动作 Pipeline 化成一条 `dws sheet export` 总命令**。
|
||||
|
||||
## 通用必填参数
|
||||
|
||||
绝大多数 `sheet` 命令都需要:
|
||||
|
||||
- `--node <NODE_ID>` —— 钉钉表格文档的 nodeId 或 `https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>` URL(alias 来自 `nodeId`)
|
||||
- `--sheet-id <SHEET_ID>` —— 工作表 ID(alias 来自 `sheetId`),从 `dws sheet list` 拿
|
||||
|
||||
例外:
|
||||
- `create` 只需要 `--name`(在知识库创建文档时不需要 nodeId)
|
||||
- `list` / `info` / `range read` 只需要 `--node`
|
||||
- `submit_export_job` 只需要 `--node` + `--export-format`(无 sheet-id)
|
||||
- `query_export_job` 只需要 `--job-id`
|
||||
|
||||
## 常用命令示例
|
||||
|
||||
### 创建文档 + 新建工作表
|
||||
|
||||
```bash
|
||||
# 在知识库下创建一个钉钉表格文档
|
||||
dws sheet create --name "销售数据" --workspace <WS_ID> --format json
|
||||
# 返回的 nodeId 用于后续操作
|
||||
|
||||
# 在已有文档中新建一张工作表
|
||||
dws sheet new --node <NODE_ID> --name "Q1 数据" --format json
|
||||
```
|
||||
|
||||
### 读写区域
|
||||
|
||||
```bash
|
||||
# 读 A1:D10
|
||||
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --format json
|
||||
|
||||
# 写入 5x4 区域(values 是二维 JSON 数组)
|
||||
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D5" \
|
||||
--values '[["姓名","岗位","入职","薪资"],["张三","研发","2024-01","30000"]]' \
|
||||
--format json
|
||||
|
||||
# 追加行
|
||||
dws sheet append --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--values '[["李四","产品","2025-03","28000"]]' \
|
||||
--format json
|
||||
```
|
||||
|
||||
### 行列操作
|
||||
|
||||
```bash
|
||||
# 在第 5 行处插入 2 个空行
|
||||
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension rows --position 4 --length 2 --format json
|
||||
|
||||
# 末尾追加 3 列
|
||||
dws sheet add-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension columns --length 3 --format json
|
||||
|
||||
# 删除第 10-12 行
|
||||
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension rows --position 9 --length 3 --format json
|
||||
|
||||
# 隐藏 B 列(startIndex=1, length=1)
|
||||
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--dimension columns --start-index 1 --length 1 --hidden true --format json
|
||||
```
|
||||
|
||||
### 合并/取消合并
|
||||
|
||||
```bash
|
||||
# 合并 A1:C1
|
||||
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--range "A1:C1" --merge-type mergeAll --format json
|
||||
|
||||
# 取消合并
|
||||
dws sheet unmerge-cells --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--range "A1:C1" --format json
|
||||
```
|
||||
|
||||
### 查找/替换
|
||||
|
||||
```bash
|
||||
# 查找
|
||||
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--find "TODO" --use-regexp false --match-case false --format json
|
||||
|
||||
# 全局替换
|
||||
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--find "TODO" --replacement "DONE" --format json
|
||||
```
|
||||
|
||||
### 筛选视图(推荐:可命名、不破坏原表)
|
||||
|
||||
```bash
|
||||
# 创建筛选视图(范围必须包含表头行)
|
||||
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--name "未完成项" --range "A1:E100" --format json
|
||||
# 返回的 filterViewId 用于后续 update/delete/criteria 操作
|
||||
|
||||
# 列出所有筛选视图
|
||||
dws sheet filter-view list --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
|
||||
# 给视图的某一列设置筛选条件(column 是相对视图范围首列的 0-based 偏移)
|
||||
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--filter-view-id <FV_ID> --column 2 \
|
||||
--filter-criteria '{"conditions":[{"type":"TEXT_CONTAINS","values":["pending"]}]}' \
|
||||
--format json
|
||||
|
||||
# 清除某列的筛选条件(不删除视图本身)
|
||||
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--filter-view-id <FV_ID> --column 2 --format json
|
||||
|
||||
# 删除整个筛选视图
|
||||
dws sheet filter-view delete --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--filter-view-id <FV_ID> --format json
|
||||
```
|
||||
|
||||
### 表级筛选(snake_case 系列,直接作用于工作表本身)
|
||||
|
||||
```bash
|
||||
# 创建筛选器(一张表只有一个,再次 create 会替换)
|
||||
dws sheet create_filter --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--range "A1:E100" --format json
|
||||
|
||||
# 给某列加筛选条件
|
||||
dws sheet set_filter_criteria --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--column 0 --filter-criteria '{...}' --format json
|
||||
|
||||
# 按指定列排序
|
||||
dws sheet sort_filter --node <NODE_ID> --sheet-id <SHEET_ID> --field 0 --format json
|
||||
|
||||
# 删除筛选器
|
||||
dws sheet delete_filter --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
```
|
||||
|
||||
### 复制工作表
|
||||
|
||||
```bash
|
||||
dws sheet copy_sheet --node <NODE_ID> --sheet-id <SHEET_ID> --format json
|
||||
# 返回新工作表 ID
|
||||
```
|
||||
|
||||
### 写入图片
|
||||
|
||||
```bash
|
||||
# 已有图片资源 ID 和 URL(通过 drive 或 doc 上传得到)后写入单元格
|
||||
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
--range "B2:B2" --resource-id <RES_ID> --resource-url <RES_URL> \
|
||||
--width 200 --height 100 --format json
|
||||
```
|
||||
|
||||
### 导出 xlsx(两步流程)
|
||||
|
||||
```bash
|
||||
# Step 1: 提交导出任务
|
||||
JOB=$(dws sheet submit_export_job --node <NODE_ID> --export-format xlsx --format json --jq '.result.jobId')
|
||||
|
||||
# Step 2: 轮询任务状态(建议 sleep + 重试)
|
||||
dws sheet query_export_job --job-id "$JOB" --format json
|
||||
# 直到返回 status=finished + downloadUrl
|
||||
|
||||
# Step 3: 下载(用 curl / dws doc download / 其它工具拉 downloadUrl)
|
||||
```
|
||||
|
||||
## 易混淆点
|
||||
|
||||
| 区分 | 说明 |
|
||||
|---|---|
|
||||
| `dws sheet create` vs `dws sheet new` | `create` 在知识库**新建一个文档**(返回新 nodeId);`new` 在**已有文档中新建一张工作表**(需 nodeId) |
|
||||
| `dws sheet filter-view *` vs `dws sheet create_filter`/`set_filter_criteria` 等 | filter-view 是**命名视图**,多个并存、不影响表本身;filter 是**表级唯一**筛选器,直接作用于工作表显示 |
|
||||
| `filter-view update-criteria` vs `filter-view delete-criteria` | update 是设置/覆盖列条件;delete 是清除列条件(视图本身保留);要删整个视图用 `filter-view delete` |
|
||||
| `dws sheet submit_export_job` + `query_export_job` vs `dws sheet export` | 后者**不存在**于 v1.0.25 envelope。需 client 端自己轮询,或基于 Pipeline (#247) 在 envelope 侧 PR 一条总命令 |
|
||||
| `dws sheet write-image` vs `range update` | write-image 写入图片(需 resourceId + resourceUrl);range update 写入文本/数字/公式 |
|
||||
| `range update` vs `append` | range update 指定区域覆盖;append 在末尾追加行 |
|
||||
| online axls vs 本地 xlsx | sheet 全部命令只认 axls;本地 xlsx 必须先 `doc download` 再用本地工具解析 |
|
||||
|
||||
## 危险操作(必须先向用户确认)
|
||||
|
||||
| 命令 | 风险 |
|
||||
|---|---|
|
||||
| `delete-dimension` | 删除行/列(含数据),不可恢复 |
|
||||
| `filter-view delete` | 删除整个筛选视图 |
|
||||
| `delete_filter` | 删除表级筛选器 |
|
||||
| `replace` | 全局替换可能影响大量单元格 |
|
||||
| `unmerge-cells` | 取消合并可能丢失部分单元格内容(钉钉行为依赖合并模式) |
|
||||
| `update_sheet` | 更新工作表元信息(如改名) |
|
||||
|
||||
执行前先 `--dry-run` 预览,并向用户展示操作摘要 + 拿到明确同意,再加 `--yes` 提交。
|
||||
|
||||
## 何时**不要**用 sheet
|
||||
|
||||
- 用户给的是 `xlsx` / `xls` / `xlsm` / `csv` 本地文件 → 用 `dws doc download` 下载后本地解析
|
||||
- 用户给的是 AI 表格(不是在线电子表格)→ 用 `dws aitable record query` 等
|
||||
- 用户给的是富文本/普通文档 → 用 `dws doc read`
|
||||
|
||||
## 权威参考
|
||||
|
||||
- 列出所有 sheet 工具:`dws schema | jq '.products[] | select(.id=="sheet") | .tools[] | "\(.group) \(.cli_name)"' -r`
|
||||
- 看某个命令的完整 JSON Schema:`dws schema sheet.<canonical_path>`(如 `dws schema sheet.update_range`)
|
||||
- 看某个命令的 flag 别名映射:`dws schema sheet.<canonical_path> --jq '.tool.flag_overlay'`
|
||||
- 看必填字段:`dws schema sheet.<canonical_path> --jq '.tool.required'`
|
||||
- 命令的人读视图:`dws sheet <cmd> --help`
|
||||
@@ -0,0 +1,177 @@
|
||||
# 知识库 (wiki) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 创建知识库
|
||||
```
|
||||
Usage:
|
||||
dws wiki space create [flags]
|
||||
Example:
|
||||
dws wiki space create --name "产品文档库" --format json
|
||||
dws wiki space create --name "技术方案" --description "团队技术方案归档" --format json
|
||||
Flags:
|
||||
--name string 知识库名称 (必填,不超过 100 字符)
|
||||
--description string 知识库描述 (选填,不超过 500 字符)
|
||||
--icon string 知识库图标标识 (选填)
|
||||
```
|
||||
|
||||
### 查看知识库详情
|
||||
```
|
||||
Usage:
|
||||
dws wiki space get [flags]
|
||||
Example:
|
||||
dws wiki space get --id <workspaceId> --format json
|
||||
dws wiki space get --id "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --format json
|
||||
Flags:
|
||||
--id string 知识库 ID 或 URL (必填)
|
||||
```
|
||||
|
||||
支持传入知识库 ID 或知识库 URL,系统自动识别。
|
||||
知识库 URL 格式:`https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview`
|
||||
|
||||
### 列出知识库
|
||||
```
|
||||
Usage:
|
||||
dws wiki space list [flags]
|
||||
Example:
|
||||
dws wiki space list --format json
|
||||
dws wiki space list --type myWikiSpace --format json
|
||||
dws wiki space list --type orgWikiSpace --limit 50 --format json
|
||||
Flags:
|
||||
--type string 知识库类型: myWikiSpace / orgWikiSpace (默认 orgWikiSpace)
|
||||
--limit string 每页数量 1-50 (默认 20)
|
||||
--page-token string 分页游标 (首页留空)
|
||||
```
|
||||
|
||||
- `myWikiSpace`:返回当前用户的「我的文档」个人空间(固定 1 条,不支持分页)
|
||||
- `orgWikiSpace`(默认):返回组织内有权访问的知识库列表,支持分页
|
||||
|
||||
### 搜索知识库
|
||||
```
|
||||
Usage:
|
||||
dws wiki space search [flags]
|
||||
Example:
|
||||
dws wiki space search --keyword "产品文档" --format json
|
||||
dws wiki space search --keyword "技术方案" --limit 20 --format json
|
||||
dws wiki space search --type myWikiSpace --format json
|
||||
Flags:
|
||||
--keyword string 搜索关键词 (--type myWikiSpace 时可省略)
|
||||
--type string 知识库类型: myWikiSpace 时直接返回「我的文档」,省略则搜索组织知识库
|
||||
--limit string 返回数量 1-20 (默认 10)
|
||||
```
|
||||
|
||||
当 `--type myWikiSpace` 时,忽略 `--keyword`,直接返回「我的文档」个人空间。
|
||||
|
||||
### 添加知识库成员(容器级授权)
|
||||
```
|
||||
Usage:
|
||||
dws wiki member add [flags]
|
||||
Example:
|
||||
dws wiki member add --space <WS_ID> --user uid1 --role READER
|
||||
dws wiki member add --space <WS_ID> --user uid1,uid2 --role EDITOR
|
||||
dws wiki member add --space "https://alidocs.dingtalk.com/i/spaces/<WS_ID>/overview" --user uid1 --role MANAGER
|
||||
Flags:
|
||||
--space string 目标知识库 ID 或 URL (必填)
|
||||
--user strings 被加入的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--role string 授予的角色 (必填,大小写敏感,必须全大写): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
|
||||
```
|
||||
|
||||
> **❗ 重要约束**:
|
||||
> - 仅支持 USER 类型。
|
||||
> - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。
|
||||
> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,请改用 `dws doc permission add` 在节点级别授权。
|
||||
|
||||
### 修改知识库成员角色
|
||||
```
|
||||
Usage:
|
||||
dws wiki member update [flags]
|
||||
Example:
|
||||
dws wiki member update --space <WS_ID> --user uid1 --role EDITOR
|
||||
dws wiki member update --space <WS_ID> --user uid1,uid2 --role READER
|
||||
Flags:
|
||||
--space string 目标知识库 ID 或 URL (必填)
|
||||
--user strings 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--role string 新角色 (必填,大小写敏感,必须全大写): MANAGER / EDITOR / DOWNLOADER / READER
|
||||
```
|
||||
|
||||
### 列出知识库成员
|
||||
```
|
||||
Usage:
|
||||
dws wiki member list [flags]
|
||||
Example:
|
||||
dws wiki member list --space <WS_ID>
|
||||
dws wiki member list --space <WS_ID> --max-results 100
|
||||
dws wiki member list --space <WS_ID> --filter-role EDITOR
|
||||
Flags:
|
||||
--space string 目标知识库 ID 或 URL (必填)
|
||||
--max-results int 返回数量上限,最大 200 (默认 50)
|
||||
--filter-role string 按角色过滤: MANAGER / EDITOR / DOWNLOADER / READER (选填)
|
||||
```
|
||||
|
||||
> 接口不支持游标分页,使用 `--max-results` 一次性拉取。
|
||||
|
||||
## 意图判断
|
||||
|
||||
- 用户说"创建知识库/新建知识库" → `space create`
|
||||
- 用户说"查看知识库/知识库详情" → `space get`
|
||||
- 用户说"我的知识库/知识库列表/有哪些知识库" → `space list`
|
||||
- 用户说"搜索知识库/找知识库" → `space search`
|
||||
- 用户说"我的文档/个人空间" → `space search --type myWikiSpace` 或 `space list --type myWikiSpace`
|
||||
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--space` + `--user` + `--role`)
|
||||
- 用户说"修改某人在知识库的权限/调整成员角色" → `member update`
|
||||
- 用户说"知识库有哪些成员/查看知识库成员" → `member list`
|
||||
|
||||
关键区分:
|
||||
- wiki(知识库空间级管理:创建/查询/列出/搜索/成员管理) vs doc(文档内容级操作:搜索/读写/编辑/节点级权限)
|
||||
- wiki space(知识库容器) vs drive(钉盘文件存储/上传/下载)
|
||||
- **wiki member**(容器级,授权整个知识库)vs **doc permission**(节点级,授权单篇文档)
|
||||
- 「我的文档」**只能用** `doc permission`,不能用 `wiki member`
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# 列出我有权访问的组织知识库
|
||||
dws wiki space list --format json
|
||||
|
||||
# 获取「我的文档」个人空间
|
||||
dws wiki space list --type myWikiSpace --format json
|
||||
|
||||
# 搜索知识库
|
||||
dws wiki space search --keyword "产品" --format json
|
||||
|
||||
# 搜索「我的文档」
|
||||
dws wiki space search --type myWikiSpace --format json
|
||||
|
||||
# 创建知识库
|
||||
dws wiki space create --name "新项目文档" --description "项目相关文档归档" --format json
|
||||
|
||||
# 查看知识库详情
|
||||
dws wiki space get --id <workspaceId> --format json
|
||||
|
||||
# ── 工作流: 给知识库加成员 ──
|
||||
|
||||
# 1. 先确认知识库 ID(避免授权到「我的文档」)
|
||||
dws wiki space list --format json # 注意:不要 --type myWikiSpace
|
||||
|
||||
# 2. 添加成员
|
||||
dws wiki member add --space <WS_ID> --user <UID> --role EDITOR --format json
|
||||
|
||||
# 3. 查看当前成员
|
||||
dws wiki member list --space <WS_ID> --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `space create` | `workspaceId` | space get 的 --id / member add 的 --space |
|
||||
| `space list` | `workspaceId` | space get 的 --id / member add 的 --space |
|
||||
| `space search` | `workspaceId` | space get 的 --id / member add 的 --space |
|
||||
| `space get` | `spaceUrl` | 分享给用户 |
|
||||
| `member list` | `userId` | member update 的 --user |
|
||||
|
||||
## 相关产品
|
||||
|
||||
- [doc](./doc.md) — 文档内容级操作(搜索/读写/编辑文档、知识库内文档管理)
|
||||
- [drive](./drive.md) — 钉盘文件存储/上传/下载
|
||||
Reference in New Issue
Block a user