Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b4d808d39 | ||
|
|
c7d8ddf98d | ||
|
|
754b0df056 | ||
|
|
6be124777f | ||
|
|
355a1460d9 | ||
|
|
c6edc84e40 | ||
|
|
f497047fff | ||
|
|
a9de7d3ca4 | ||
|
|
1c200d883f | ||
|
|
d268524084 | ||
|
|
ed4673e7d2 | ||
|
|
de723914a5 | ||
|
|
649801e479 | ||
|
|
49c5bea4f3 | ||
|
|
6707e56f9c | ||
|
|
2ba1dcdda4 | ||
|
|
1637ae16c7 | ||
|
|
eee19d7347 | ||
|
|
19f7b59ffb | ||
|
|
c4952d0207 | ||
|
|
ecf2684f58 | ||
|
|
9e9b898dd2 | ||
|
|
17f692e7f1 |
+114
-2
@@ -4,12 +4,124 @@ 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.25] - 2026-05-11
|
||||
## [1.0.31] - 2026-05-21
|
||||
|
||||
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands.
|
||||
Closes the last drive-surface gap with the Wukong edition: `dws drive upload` lands as a single-shot composite (`drive.get_upload_info` → HTTP PUT to OSS → `drive.commit_upload`) so a local file reaches DingTalk drive in one CLI invocation, no manual three-step orchestration. Two more drive commands — `dws drive list-spaces` (list visible drive spaces) and `dws drive delete` (delete a drive file, routed via `serverOverride` to the doc MCP server) — ship via the portal envelope; `dws cache refresh` once to pick them up. Companion skill docs teach the agent to recognise dingpan URLs of the form `alidocs.dingtalk.com/document/edit?dentryKey=…` / `…/document/preview?dentryKey=…` and pass the whole URL through to `--node` instead of trying to extract `dentryKey` by hand (the server interprets `dentryKey` and a bare `nodeId` differently — manual extraction was failing).
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws drive upload --file <path> [--folder <dentryUuid>] [--space-id <id>] [--file-name <name>] [--mime-type <type>]`** (#335, see `internal/helpers/drive.go`) — composite leaf that runs the full three-step upload internally:
|
||||
1. `drive.get_upload_info` — fetch the OSS-signed `resourceUrl` + `uploadId` + per-URL headers.
|
||||
2. HTTP `PUT` the file binary to OSS (10-minute timeout, attaches every header returned by step 1).
|
||||
3. `drive.commit_upload` — register the new file under the target space / folder.
|
||||
|
||||
`--dry-run` prints the three step invocations as a single JSON payload without making any network calls. `--file -` is rejected on purpose: this is a local-path upload, not stdin streaming. `--folder` only accepts a `dentryUuid`; pure-numeric values are rejected up front (`validateDriveParentID`) so callers don't accidentally pass a chat-link `dentryId` (a different ID namespace) where the drive API expects a `dentryUuid`. Response normalisation handles all the wrapper shapes the upstream returns — `content` / `result` envelopes, `resourceUrls[]` arrays, and the flat `resourceUrl` / `uploadUrl` fallbacks — so the composite produces a stable JSON shape regardless of which path the upstream takes. The helper only registers `upload`; the existing six envelope-generated leaves (`list` / `info` / `download` / `mkdir` / `upload-info` / `commit`) keep flowing through dynamic discovery unchanged. `pickCommands.MergeHardcodedLeaves` guarantees dynamic leaves win on collision, so this helper only fills the upload gap.
|
||||
- **`dws drive list-spaces` and `dws drive delete` (envelope rollout)** (#335, ships via portal envelope) — `list_spaces` registers as a plain `cliName` alias on the existing drive MCP server; `delete_document` registers with `serverOverride: doc` so the call routes to the doc MCP server (which owns the delete API), surfacing under the drive command tree for ergonomics. **Existing users must run `dws cache refresh` once** to pick up these two new leaves; no binary upgrade is required for them, but they pair naturally with the v1.0.31 client that ships `upload`.
|
||||
- **`skills/references/url-patterns.md`** (#335) — single authority for dispatching `alidocs.dingtalk.com` URLs across doc / sheet / wiki. Five-way split: `/i/p/<token>` short links → expand via `doc info`; `/i/nodes/<id>` node URLs → probe with `doc info` and route by `contentType` / `extension` / `nodeType`; `/spreadsheetv2/...` → `sheet`; `/document/edit|preview?dentryKey=<key>` (dingpan format) → pass the whole URL to `--node`, do not strip `dentryKey` by hand; `/i/share/...` (read-only share) → use the `read_url` fallback. The "URL precheck" Step 0 in `skills/SKILL.md` now redirects every URL-bearing prompt through this dispatcher before the agent picks a product.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`skills/references/products/doc.md` — `--node` accepts dingpan URLs end-to-end** (#335) — `dws doc info` / `dws doc read` examples gain two extra rows showing `--node "https://alidocs.dingtalk.com/document/edit?dentryKey=<KEY>"` and `…/preview?dentryKey=<KEY>` as first-class `--node` inputs. The "URL recognition & DOC_ID extraction" table adds the `document/edit|preview?dentryKey=<key>` row, and the extraction rules are split into three explicit clauses so the agent stops manually pulling `dentryKey` out of the URL and feeding it as a bare `nodeId` (which the server rejects). The "nodeId dual-format note" upgrades to "nodeId multi-format note" with four equivalent `--node` input shapes side by side.
|
||||
|
||||
## [1.0.30] - 2026-05-19
|
||||
|
||||
Aligns the open-source CLI with the IM envelope and schema-pipeline plumbing the Wukong edition has been running in pre-prod, plus three user-visible quality-of-life fixes. The most visible one: chat-bot webhook payloads carrying literal Chinese mentions (`@所有人 周报来了` / `@张三 看一下`) no longer fail with `file not found` — `@` is only treated as the `@<filename>` file-injection prefix when followed by an ASCII path-shaped character. The `chat` command tree is refactored to lean on the service-discovery envelope: thin wrappers (`chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search`) move out of the hardcoded helper and become envelope-generated dynamic commands; the helper keeps only the chat commands with real business logic (intelligent routing, current-user resolution, response normalization, stdin/@file input). A new `dws chat message reply` joins the existing `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` family. Underneath: `transform: invert_bool` lets envelopes flip boolean semantics between CLI surface and MCP body (e.g. `--off` ↔ `mute=true`); the pipeline executor fail-fast on upstream `content.errorCode` instead of polling forever; service-discovery dedup keeps two envelope entries that share an MCP endpoint but declare different `cli.id` as separate descriptors (so the `bot-root` / `bot-message` / `bot-group` trio fronting one MCP server stays as three distinct CLI command roots); and `dws chat` no longer nests as `dws chat chat` when two envelope servers both declare the same top-level command name.
|
||||
|
||||
### Added
|
||||
|
||||
- **`transform: invert_bool` for envelope flag overrides** (#317, see `internal/compat/transform.go`) — flips a boolean at send time. Strings `true`/`1`/`yes`/`on` → `false`; `false`/`0`/`no`/`off`/`""` → `true`. Used when the CLI surface and the MCP body have opposite semantics — e.g. envelope declares `--off` on the CLI but the MCP parameter is `mute=true` for "muted". The framework flips at send time so the envelope keeps the natural CLI verb without forcing every caller to remember the inverted mapping. Coverage in `internal/compat/transform_test.go`.
|
||||
- **`dws chat message reply`** (#317, see `internal/helpers/chat.go`) — reply to a chat message. Sits alongside `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` under `dws chat message`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`chat` command tree refactored to lean on the service-discovery envelope** (#317, commit `6be1247`) — `internal/helpers/chat.go` now only carries the chat commands that need real business logic on top of the raw MCP call: `chat message send` (current-user resolution + symmetric direct/group title validation), `chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` (bot routing + stdin/@file input), and `chat group create` (response normalization). The thin wrappers — `chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search` — are now produced by the envelope as dynamic commands. Net diff in the helper: `+358 / -71` overall (re-aligning to envelope-owned chat structure), and `chat_test.go` drops 71 lines of test-stubs the dynamic path covers natively. Every previously documented chat command keeps the same flag set and the same MCP tool routing — the surface is just sourced differently.
|
||||
- **Pipeline executor fail-fast on `content.errorCode`** (#317, see `internal/compat/pipeline.go`) — when an upstream tool returns a non-empty `content.errorCode`, `executePipelineCall` raises a validation error immediately with the upstream `errorMessage` instead of proceeding into the poll/download phase. Pre-execution cobra validation (`MarkFlagRequired`) only checks that a flag was set, not that its value was non-empty — so a `--required-flag ""` reaches the upstream tool and the upstream rejects with `errorCode`. Without the short-circuit the pipeline kept polling for a task ID that would never exist, either spinning to `PollTimeout` or burning through retries with no actionable error. Exit code 2 (validation), same as any other CLI-layer pre-flight rejection.
|
||||
- **Service-discovery dedup keys now include `cli.id`** (#317, see `internal/market/registry.go`) — `NormalizeServers` used to dedup envelope entries by endpoint alone (and by `displayName` in the second pass), which collapsed envelope entries that intentionally split one MCP endpoint into multiple CLI command trees. The `bot-root` / `bot-message` / `bot-group` trio all front the same `.../server/4717...` MCP endpoint and share the displayName `机器人消息`, but each declares a distinct `cli.id` and a distinct CLI command root; the old dedup kept only the last-write and dropped two of them. The dedup key now appends `#<cli.id>` when present, falling back to endpoint / name when absent so historical envelopes without `cli.id` keep their existing behaviour. Coverage in `internal/market/registry_test.go`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`@<text>` injection no longer eats Chinese mentions like `@所有人` / `@张三`** (#317, see `internal/cli/stdin.go`) — `ReadFileArg` and `ResolveInputSource` used to treat *any* value starting with `@` as the `@<filename>` injection syntax. Chat-bot webhook payloads commonly contain literal mentions, so `dws chat message send-by-bot --text "@所有人 周报"` was failing with `file not found: 所有人 周报` before the message reached the API. The new `looksLikeFilePath` heuristic accepts `@` followed by an ASCII path-prefix character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin, and passes the value through unchanged otherwise. `@A 但接下来都是中文@测试` *does* still attempt a file lookup because the rune right after `@` is ASCII — this matches the documented `@<path>` prefix shape. The historical "bare `@` is an error" behaviour is preserved. Coverage in `internal/cli/stdin_test.go::TestReadFileArgChineseAtMention`.
|
||||
- **`dws chat` no longer nests as `dws chat chat` when two envelope servers contribute the same top-level command** (#317, see `internal/compat/dynamic_commands.go`) — `BuildDynamicCommands` used to overwrite `topLevel[name]` on the second contribution and rely on `attachOrMerge` later, which then attached the *whole* incoming command (named `chat`) under the existing root, producing `dws chat chat <leaf>`. The new `mergeSubcommandsInto` moves the second contribution's *children* under the first root and drops the duplicate wrapper, so e.g. `group-chat` + `im` envelopes that both declare `cli.command: chat` produce a single flat `dws chat` subtree.
|
||||
- **Multi-server tool-name authority correction in the runtime runner** (#317, see `internal/app/runner.go` + `internal/app/direct_runtime.go`) — when two envelope servers share the same `cli.command`, the per-product endpoint map `endpoints[cmd]` in `registerDynamicServer` is second-writer-wins, and `catalog.FindProduct` may return the wrong server's endpoint for a tool whose real owner is the *other* server. `runtimeRunner.Run` now cross-checks the canonical tool→endpoint map exposed by the new `directRuntimeToolEndpoint`: when the per-tool endpoint exists and differs from the per-product endpoint the catalog returned, the tool-owner endpoint wins. Pairs with the registry dedup change above so the routing matches the dedup result.
|
||||
|
||||
## [1.0.29] - 2026-05-17
|
||||
|
||||
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws aiapp` / `dws live` / `dws aisearch` — three new products discovered via envelope** (no public issue; pre-Diamond rollout) — open-source `dws` now exposes:
|
||||
- **`dws aiapp`** — AI application lifecycle: `create --prompt <p> [--attachments <json>] [--skills <csv>]` / `query --task-id <id>` / `modify --prompt <p> --thread-id <id> [--skills <csv>]`. Backed by upstream `create_ai_app` / `query_ai_app` / `modify_ai_app` MCP tools.
|
||||
- **`dws live stream list`** — list my DingTalk live streams. Backed by upstream `get_my_lives`.
|
||||
- **`dws aisearch person`** — enterprise people search by keyword + multi-dimension filter. Dimensions: `all` (default) / `name` / `department` / `position` / `duty` / `supervisor` / `subordinate` / `phone` / `jobNumber` — multiple comma-separated (`--dimension name,department`). Backed by upstream `enterprise_person_search`.
|
||||
- The `aisearch` envelope additionally registers `-w` / `-d` short flags (keyword / dimension); hidden flag aliases `--query` / `--name` / `--q` / `--text` / `--find` all routing to `keyword`; and cobra subcommand aliases `search` / `find` / `query` / `user` / `people` / `search-person` / `search-user` / `user-search` / `lookup` / `ask` / `contact` all routing to `person`. This closes the F-class model-tolerance regression cases in `dws-wukong/auto-test/cli_to_mcp/testcases/aisearch/test_90_aisearch_param_regression.py` (50/50 pass for aiapp + live + aisearch on the pre-mcp build).
|
||||
- **Users must run `dws cache refresh` once** to pick up the new envelopes; no binary upgrade is required, but pairs naturally with the v1.0.29 client (see Fixed below for the envelope-leaf-Args change).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Envelope-generated leaf commands now tolerate trailing positional args** (#306, no public issue) — `NewDirectCommand` in `internal/compat/registry.go` was hard-coding `cobra.NoArgs` for leaves without positional bindings (`totalMax == 0`). This is stricter than cobra's own `legacyArgs` (cobra `args.go:30-32` returns `nil` for any command without subcommands), and surfaced as `unknown command "<word>" for "<leaf>"` whenever an AI agent passed trailing positional words after a leaf — e.g. `dws aisearch person search --keyword "张"` or `dws aisearch person user search --keyword "张"`. Switching the `totalMax == 0` branch (and the initial value) from `cobra.NoArgs` to `cobra.ArbitraryArgs` restores cobra's natural leaf behavior: trailing positional args are silently ignored. Existing positional-binding paths (`MinimumNArgs` / `RangeArgs` / `MaximumNArgs`) are unchanged. Verified against `dws-wukong/auto-test/cli_to_mcp/testcases` — aiapp (9/9) + live (3/3) + aisearch (38/38) = **50/50** pass, vs 48/50 before this patch.
|
||||
|
||||
### Security
|
||||
|
||||
- **App credential files are partitioned by edition to prevent cross-edition credential leakage** (#300, no public issue; found during internal review) — different `dws` editions sharing the same config directory previously read and wrote the same `app.json`. A sibling edition that pinned its OAuth client ID could persist that ID through the shared post-login path, and the open-source build could later adopt it from the same file. Open-source/empty edition keeps the legacy `app.json` path for compatibility; sibling editions now use `app-<edition>.json`, matching the existing cache partitioning strategy. This prevents new cross-edition app credential writes and reads from colliding. After a sibling edition saves its new partitioned file, it also best-effort removes a legacy `~/.dws/app.json` only when that file's `clientId` matches the sibling edition being saved; a different, unparsable, or otherwise unowned `app.json` is left untouched to avoid deleting open-source credentials. If you previously ran multiple editions in one shared `~/.dws`, remove any confirmed-stale orphan manually with `rm ~/.dws/app.json` after verifying it is not the open-source credential file you still need.
|
||||
|
||||
## [1.0.28] - 2026-05-14
|
||||
|
||||
A single symmetric follow-up to 1.0.26's #250: `dws chat message send --group <cid>` now refuses an empty `--title` at the CLI layer instead of letting the call fall through to the API and surface a misleading `发群服务窗会话消息失败` error. No other behaviour changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` rejects missing `--title` on group messages** (#294, completes #250) — `send_message_as_user`'s schema marks `title` as required (just like `send_direct_message_as_user`), but `buildChatMessageSendInvocation` only had the pre-validation on the direct-message branches. Group sends without a title were falling through to the API and returning the same misleading `发群服务窗会话消息失败` that #250 already fixed for direct messages. The check now covers both branches: missing `--title` on `--group` returns `--title is required for group messages (--group)` with exit code 2; missing on `--user` / `--open-dingtalk-id` keeps the original `--title is required for direct messages (--user / --open-dingtalk-id)`. The `Long` help, `--title` flag description, the first `Example`, and `skills/references/products/chat.md` (including the drive→chat workflow example) are realigned to "title is required for both direct and group messages" — the docs previously contradicted themselves (the prose said 群聊可选 while the flag listing said 必填). `internal/helpers/chat_test.go` adds a `group-without-title` rejection case; the existing `group` / `positional-text` success cases now pass `--title` to stay aligned with the new validation. No API request shape change — the server has always required `title`; the CLI now matches.
|
||||
|
||||
## [1.0.27] - 2026-05-14
|
||||
|
||||
Two user-visible fixes plus the schema primitive they're built on. `dws doc update` now reads Markdown from a file or stdin, so long / multi-line / table-heavy content no longer gets mangled by shell escaping; `dws sheet find --query` stops returning `unknown flag` on the open-source build, restoring copy-paste from internal wukong docs. Underneath, schema/discovery envelopes get a generic `file_read` transform and a `CLIFlagOverride.MapsTo` field that lets two sibling CLI flags route into the same MCP parameter slot. Also suppresses a noisy WARN on normal stdio-plugin shutdown.
|
||||
|
||||
### Added
|
||||
|
||||
- **`file_read` transform + `CLIFlagOverride.MapsTo` field** (#291, closes #277 #278 #282 #288) — discovery envelopes can now declare a path-typed CLI flag that performs the "file path → file contents string" conversion client-side before the value reaches the upstream MCP parameter.
|
||||
- `transform: "file_read"` (`internal/compat/transform.go`) — reads the file at the flag's value with UTF-8 validation; `-` means stdin. Any IO / encoding failure is surfaced as a validation error (exit 2), distinct from the generic transient-failure path (exit 1).
|
||||
- `CLIFlagOverride.MapsTo` (`internal/market/registry.go`) — redirects the flag's final value (post-transform or literal) into a named MCP parameter slot instead of the default `params[propertyName]`. This lets a single MCP parameter (e.g. `markdown`) be fed by two sibling CLI flags — a literal `--content` and a file-reading `--content-file` — paired with the existing tool-level `MutuallyExclusive` / `RequireOneOf` to express "exclusive, at least one".
|
||||
- Wired into the `internal/compat/dynamic_commands.go` normalizer via a separate `mapsToRoutes` collection + routing pass; empty `MapsTo` preserves the legacy `params[propertyName] = value` semantics, so every pre-existing dynamic_commands test passes unchanged. Pre-prod end-to-end verified across 6 cases (see PR #291's Validation table).
|
||||
- **`dws doc update --content-file <path>` (envelope rollout)** — fixes "long Markdown can't reach the doc". The old command only accepted `--content "..."`, so long / multi-line / table-heavy Markdown got mangled by shell escaping and AI agents writing >2KB of content were stuck. The envelope now maps both `--content` (literal) and `--content-file` (`file_read` transform) to the `markdown` parameter, makes them mutually exclusive via cobra's `MarkFlagsMutuallyExclusive`, and requires at least one via `RequireOneOf`. `--content-file -` reads from stdin, so `cat long.md | dws doc update --content-file -` works directly. **Existing users must run `dws cache refresh` once** to pick up the new envelope.
|
||||
- **`dws sheet find --query` hidden alias (envelope rollout)** — fixes "unknown flag when copy-pasting commands across editions". Users copying `dws sheet find --query "..."` from internal wukong docs onto open-source `dws` got `unknown flag: --query`, because the open-source primary flag is named `--find`. The envelope now registers `--query` as a hidden alias of `--find` via `CLIFlagOverride.Aliases` (the field shipped in 1.0.26) — it doesn't show up in `--help`, but accepts values and writes to the same MCP parameter. `--find` behaviour is unchanged. Also requires `dws cache refresh` once.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Noisy `failed to stop stdio client: exit status 1` WARN on normal stdio-plugin shutdown** (#285) — when `Stop()` explicitly `Kill`s the subprocess, the non-zero exit code returned by `cmd.Wait()` is expected behaviour, but it was being propagated as an error and logged to stderr on every CLI exit, polluting agent log parsing. `Stop()` now returns `nil` after Kill + Wait; the error path is reserved for "process exited on its own with non-zero" (e.g. stdin close without an explicit Kill). `internal/transport/stdio.go` + `stdio_integration_test.go` assert "Stop() returns nil after kill".
|
||||
|
||||
## [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`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sticky flag splitting is now schema-aware** (#272) — PreParse `StickyHandler` 此前会把任何前缀命中已知 flag 的 `--flagsuffix` 一律切成 `--flag suffix`,于是 `--starttime20260507` 这类拼错被静默改写成 `--start time20260507`,把假值传到下游。新行为按 flag 的 pflag 类型 / JSON Schema `format` / `enum` 校验 suffix 是否像合法 value(共享逻辑见 `pkg/cmdutil/sticky_suffix.go`),不像就保留原 token 让 cobra 报 `unknown flag`。slice/array/object 类型的 flag 永不切分。首 rune 读取使用 `utf8.DecodeRuneInString`,对中文等多字节 value 安全。
|
||||
|
||||
### Added
|
||||
|
||||
- **`available_flags` field on unknown-flag errors** (#272) — `dws -f json` 的 unknown-flag 错误体里新增 `available_flags`(已排序、过滤掉 hidden 与内部 `json` / `params`),方便 agent 不解析 `--help` 就能恢复。Human-readable 输出会附 `Flags: ...` 行,截断在 200 字节内。
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>Table of Contents</strong></summary>
|
||||
@@ -394,6 +394,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report" --text @-
|
||||
```
|
||||
|
||||
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
|
||||
|
||||
</details>
|
||||
|
||||
## Key Services
|
||||
@@ -401,7 +403,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
|
||||
| Chat / IM | `chat` (alias `im`) | 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | Messages (send / list / list-all / by-sender / mentions / focused / unread / topic replies / search), group CRUD + member management (incl. `add-bot`), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common groups lookup |
|
||||
| Chat / IM | `chat` (alias `im`) | 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
|
||||
| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
|
||||
| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records |
|
||||
@@ -410,20 +412,25 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
|
||||
| AI Tables | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments; templates |
|
||||
| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
|
||||
| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload |
|
||||
| Drive | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | DingTalk drive file ops: list spaces, list / info / download, create folders, one-shot `upload` (three-step composite) or two-phase `upload-info` + `commit`, delete |
|
||||
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
|
||||
| Mail | `mail` | 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 |
|
||||
| AI Search | `aisearch` | 1 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| AI App | `aiapp` | 3 | — | AI application lifecycle: `create` (with prompt / attachments / skills) / `query` (by task ID) / `modify` (by thread ID) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **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.
|
||||
> **212 commands across 19 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 meetings)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+12
-5
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>目录</strong></summary>
|
||||
@@ -394,6 +394,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报" --text @-
|
||||
```
|
||||
|
||||
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
|
||||
|
||||
</details>
|
||||
|
||||
## 核心服务
|
||||
@@ -401,7 +403,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
|
||||
| 群聊 | `chat`(别名 `im`)| 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | 消息(发送 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索)、群增删改 + 成员管理(含 `add-bot`)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊 |
|
||||
| 群聊 | `chat`(别名 `im`)| 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
|
||||
| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
|
||||
| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 |
|
||||
@@ -410,20 +412,25 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、详情、统计 |
|
||||
| AI 表格 | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件;模板 |
|
||||
| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
|
||||
| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 |
|
||||
| 钉盘 | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | 钉盘文件操作:列出空间、文件列表 / 详情 / 下载、创建文件夹、一键 `upload`(三步合成)或两阶段 `upload-info` + `commit`、删除 |
|
||||
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
|
||||
| 邮箱 | `mail` | 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` | 搜索钉钉开放平台文档 |
|
||||
| AI 搜问 | `aisearch` | 1 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| AI 应用 | `aiapp` | 3 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> **14 个产品,163 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> **19 个产品,212 条命令。** 完整命令清单(带描述与使用场景):[`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`(视频会议)
|
||||
|
||||
</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 / 退出码
|
||||
|
||||
|
||||
@@ -210,6 +210,25 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
|
||||
// whose toolOverrides registered this tool name. Used to correct catalog
|
||||
// lookups when two envelope servers share the same cli.command and the
|
||||
// per-product endpoint map collides (see runner.go cross-check).
|
||||
func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
if te == nil {
|
||||
return "", false
|
||||
}
|
||||
endpoint, ok := te[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
@@ -310,7 +329,9 @@ func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
if _, exists := dynamicEndpoints[cmd]; !exists {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
}
|
||||
dynamicProducts[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
|
||||
@@ -270,6 +270,68 @@ func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
// --- Command field first-writer-wins regression test ---
|
||||
//
|
||||
// When two plugins declare the same CLI.Command but different CLI.ID values,
|
||||
// AppendDynamicServer must NOT let the second registration overwrite the
|
||||
// command → endpoint mapping established by the first. The fix uses a simple
|
||||
// "if not exists" guard on dynamicEndpoints[cmd].
|
||||
|
||||
const (
|
||||
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
|
||||
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
|
||||
)
|
||||
|
||||
func firstPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testFirstEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-alpha",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func secondPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testSecondEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-beta",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
|
||||
// two plugins declare the same Command (but different IDs), only the first
|
||||
// registration takes effect for the command → endpoint mapping. The second
|
||||
// plugin's own id-based endpoint is unaffected.
|
||||
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
AppendDynamicServer(firstPluginDescriptor())
|
||||
AppendDynamicServer(secondPluginDescriptor())
|
||||
|
||||
// The command "shared-cmd" must resolve to the first plugin's endpoint.
|
||||
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
|
||||
|
||||
// Each plugin's own id-based endpoint is always unconditionally written.
|
||||
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
|
||||
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
|
||||
|
||||
// Command must appear in dynamicProducts (discovery) regardless.
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["shared-cmd"] {
|
||||
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-alpha"] {
|
||||
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-beta"] {
|
||||
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
|
||||
// tool-level routing still works as a fallback when productID is empty or has
|
||||
// no registered endpoint (the original design intent for tool-level Priority 1).
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 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 (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestFlagErrorWithSuggestions_authStructured(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "login", Run: func(*cobra.Command, []string) {}}
|
||||
orig := fmt.Errorf("unknown flag: --json")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Message != orig.Error() {
|
||||
t.Fatalf("Message = %q, want %q", ae.Message, orig.Error())
|
||||
}
|
||||
if ae.Reason != "unknown_flag" {
|
||||
t.Fatalf("Reason = %q, want unknown_flag", ae.Reason)
|
||||
}
|
||||
if ae.Hint == "" || !strings.Contains(ae.Hint, "format json") {
|
||||
t.Fatalf("Hint = %q", ae.Hint)
|
||||
}
|
||||
if ae.Cause != orig {
|
||||
t.Fatalf("Cause = %v, want orig", ae.Cause)
|
||||
}
|
||||
if !stderrors.Is(err, orig) {
|
||||
t.Fatal("errors.Is(err, orig) should hold via unwrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
orig := fmt.Errorf("unknown flag: --starttime1")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != "unknown_flag" {
|
||||
t.Fatalf("Reason = %q", ae.Reason)
|
||||
}
|
||||
if strings.Contains(ae.Hint, "Space required") {
|
||||
t.Fatalf("false glue must not suggest space: %q", ae.Hint)
|
||||
}
|
||||
if !strings.Contains(ae.Hint, "help") {
|
||||
t.Fatalf("expected help fallback in hint, got %q", ae.Hint)
|
||||
}
|
||||
if len(ae.AvailableFlags) != 1 || ae.AvailableFlags[0] != "start" {
|
||||
t.Fatalf("AvailableFlags = %v, want [start]", ae.AvailableFlags)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -624,7 +624,7 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
if !retryHasKey {
|
||||
t.Fatal("expected retry context to have patRetryingKey")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err != nil {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(configDir)); err != nil {
|
||||
t.Fatalf("expected approved PAT flow to persist app.json, stat error = %v", err)
|
||||
}
|
||||
// Verify SetClientIDFromMCP was called with the PAT response clientId.
|
||||
@@ -700,7 +700,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
@@ -759,7 +759,7 @@ func TestHandlePatAuthCheck_HostControlledEmptyFlowID_StillReturnsContract(t *te
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
@@ -855,7 +855,7 @@ func TestHandlePatAuthCheck_JSONModeReturnsStructuredPATErrorWithoutRetry(t *tes
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
@@ -917,11 +918,29 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !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)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-1
@@ -47,6 +47,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -133,7 +134,28 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
|
||||
for flag, suggestion := range suggestions {
|
||||
if strings.Contains(errMsg, "unknown flag: "+flag) {
|
||||
return fmt.Errorf("%w\n%s", err, suggestion)
|
||||
return apperrors.NewValidation(
|
||||
errMsg,
|
||||
apperrors.WithHint(suggestion),
|
||||
apperrors.WithReason("unknown_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(errMsg, "unknown flag:") {
|
||||
fix := cmdutil.SuggestFlagFix(cmd, err)
|
||||
if fix.Suggestion != "" {
|
||||
return apperrors.NewValidation(
|
||||
errMsg,
|
||||
apperrors.WithHint(fix.Suggestion),
|
||||
apperrors.WithReason("unknown_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,19 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
|
||||
endpoint = override
|
||||
}
|
||||
// Multi-server tool-name authority correction.
|
||||
//
|
||||
// When two envelope servers share the same cli.command (e.g. group-chat
|
||||
// and im both publish `dws chat ...`), the endpoints[cmd] map in
|
||||
// registerDynamicServer is the second-writer wins, and catalog FindProduct
|
||||
// may pick the wrong product's Endpoint for a tool whose real owner is
|
||||
// a different server. Cross-check the canonical tool→endpoint map: when
|
||||
// the per-tool endpoint exists and differs from the per-product endpoint
|
||||
// catalog returned, trust the tool-owner endpoint (the server that
|
||||
// actually declares this tool in its toolOverrides).
|
||||
if toolEndpoint, ok := directRuntimeToolEndpoint(invocation.Tool); ok && toolEndpoint != "" && toolEndpoint != endpoint {
|
||||
endpoint = toolEndpoint
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
@@ -364,6 +377,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")
|
||||
|
||||
@@ -16,21 +16,31 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
configpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
// appConfigFile is the filename for storing app credentials.
|
||||
appConfigFile = "app.json"
|
||||
// appConfigFile is the filename for the open-source edition's app
|
||||
// credentials store. Sibling editions get a name-suffixed file via
|
||||
// config.EditionFileName so two dws binaries sharing the same config
|
||||
// directory (~/.dws or DWS_CONFIG_DIR) cannot read/write each other's
|
||||
// credentials. See GetAppConfigPath for the path derivation contract.
|
||||
appConfigBase = "app"
|
||||
appConfigExt = ".json"
|
||||
appConfigFile = appConfigBase + appConfigExt
|
||||
)
|
||||
|
||||
// AppConfig represents the application credentials configuration.
|
||||
// This is stored in ~/.dws/app.json with the client secret securely stored in keychain.
|
||||
// This is stored in the edition-specific app config file, with the client
|
||||
// secret securely stored in keychain when present.
|
||||
type AppConfig struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret SecretInput `json:"clientSecret"`
|
||||
@@ -53,9 +63,14 @@ var (
|
||||
cachedResolvedMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetAppConfigPath returns the path to the app config file.
|
||||
// GetAppConfigPath returns the path to the app config file for the
|
||||
// currently-active edition. The filename is partitioned by edition so that
|
||||
// two dws binaries from different editions sharing the same configDir
|
||||
// (typically ~/.dws or DWS_CONFIG_DIR) cannot read or overwrite each
|
||||
// other's credentials. Open-source stays on "app.json" for backwards
|
||||
// compatibility; sibling editions land on "app-<edition>.json".
|
||||
func GetAppConfigPath(configDir string) string {
|
||||
return filepath.Join(configDir, appConfigFile)
|
||||
return filepath.Join(configDir, configpkg.EditionFileName(edition.Get().Name, appConfigBase, appConfigExt))
|
||||
}
|
||||
|
||||
// LoadAppConfig loads the app configuration from disk.
|
||||
@@ -105,6 +120,7 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("writing app config: %w", err)
|
||||
}
|
||||
cleanupLegacySiblingAppConfig(configDir, config)
|
||||
|
||||
// Update cache
|
||||
cachedAppConfigMu.Lock()
|
||||
@@ -121,6 +137,34 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
|
||||
if config == nil || config.ClientID == "" || configpkg.IsOpenEdition(edition.Get().Name) {
|
||||
return
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
if legacyPath == GetAppConfigPath(configDir) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var legacy AppConfig
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return
|
||||
}
|
||||
if legacy.ClientID != config.ClientID {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAppConfig removes the app configuration and associated keychain secrets.
|
||||
func DeleteAppConfig(configDir string) error {
|
||||
// Load existing config to clean up keychain
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Verifies that two dws binaries from different editions sharing the same
|
||||
// configDir (e.g. ~/.dws via DWS_CONFIG_DIR) read and write disjoint
|
||||
// app.json files. Without partitioning, a sibling edition's post-login
|
||||
// persistence path could leak its pinned ClientID into the open-source
|
||||
// build by reading the shared file.
|
||||
|
||||
func TestGetAppConfigPath_OpenEditionUsesLegacyName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
for _, name := range []string{"", "open"} {
|
||||
edition.Override(&edition.Hooks{Name: name})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", "app.json")
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_SiblingEditionUsesSuffixedName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cases := []struct {
|
||||
editionName string
|
||||
wantFile string
|
||||
}{
|
||||
{"wukong", "app-wukong.json"},
|
||||
{"dev", "app-dev.json"},
|
||||
{"embedded", "app-embedded.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
edition.Override(&edition.Hooks{Name: tc.editionName})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", tc.wantFile)
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", tc.editionName, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_OpenAndSiblingAreDisjoint(t *testing.T) {
|
||||
// End-to-end invariant: when the same configDir is observed from two
|
||||
// different editions, the resulting app.json paths must NOT collide.
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
const cfg = "/tmp/shared-cfg"
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
openPath := GetAppConfigPath(cfg)
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(cfg)
|
||||
|
||||
if openPath == wukongPath {
|
||||
t.Fatalf("open and wukong editions share path %q; cross-edition leakage possible", openPath)
|
||||
}
|
||||
if filepath.Dir(openPath) != filepath.Dir(wukongPath) {
|
||||
t.Fatalf("paths landed in different directories (%q vs %q); partitioning should only differ by filename", filepath.Dir(openPath), filepath.Dir(wukongPath))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigIO_OpenEditionDoesNotReadSiblingCredentials(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(configDir)
|
||||
if err := os.WriteFile(wukongPath, []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}`+"\n"), 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
got, err := LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig(open) error = %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("open edition read sibling app config: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionRemovesMatchingLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("matching legacy app config should be removed, stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app-wukong.json")); err != nil {
|
||||
t.Fatalf("sibling app config not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsDifferentLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"open-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("different legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsMalformedLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid"`)
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing malformed legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("malformed legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("malformed legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_OpenEditionDoesNotCleanSiblingAppConfigs(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
siblingFiles := map[string][]byte{
|
||||
"app-wukong.json": []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
"app-dev.json": []byte(`{"clientId":"dev-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
}
|
||||
for name, data := range siblingFiles {
|
||||
if err := os.WriteFile(filepath.Join(configDir, name), data, 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "open-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(open) error = %v", err)
|
||||
}
|
||||
|
||||
for name, want := range siblingFiles {
|
||||
got, err := os.ReadFile(filepath.Join(configDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("open edition should preserve sibling app config %s: %v", name, err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("sibling app config %s changed: got %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsLegacyAppConfigWhenClientIDEmpty(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong empty client ID) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy app config should be preserved when client ID is empty: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
+45
-9
@@ -19,6 +19,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
@@ -122,19 +123,51 @@ func readStdinBounded() (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// looksLikeFilePath returns true when value should be interpreted as the
|
||||
// `@<path>` file-injection syntax. Heuristic: value must start with '@', and
|
||||
// the character right after '@' must be ASCII (letter, digit, or one of the
|
||||
// common path-prefix characters: . / ~ _ -). This rules out mistaken matches
|
||||
// for natural-language messages that happen to start with '@' followed by
|
||||
// non-ASCII text — e.g. "@所有人" should be a chat mention, not a file path.
|
||||
func looksLikeFilePath(value string) bool {
|
||||
if !strings.HasPrefix(value, "@") || len(value) < 2 {
|
||||
return false
|
||||
}
|
||||
rest := value[1:]
|
||||
if rest == "-" {
|
||||
return true // @- = stdin
|
||||
}
|
||||
first := rune(rest[0])
|
||||
if first > unicode.MaxASCII {
|
||||
// First byte is part of a multi-byte rune (e.g. Chinese) — not a path.
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case first >= 'A' && first <= 'Z',
|
||||
first >= 'a' && first <= 'z',
|
||||
first >= '0' && first <= '9',
|
||||
first == '.', first == '/', first == '~', first == '_', first == '-':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReadFileArg reads the contents of a file referenced by the @filename syntax.
|
||||
// Returns the original value unchanged if it does not start with "@".
|
||||
// Returns the original value unchanged if it does not start with "@" or is
|
||||
// otherwise not a file-path-shaped value (e.g. "@所有人" is treated as plain
|
||||
// text, not a path).
|
||||
// Returns an error if the file cannot be read or exceeds the size limit.
|
||||
//
|
||||
// Note: @- (stdin) is NOT handled here; use ResolveInputSource instead.
|
||||
func ReadFileArg(value string) (string, bool, error) {
|
||||
if !strings.HasPrefix(value, "@") {
|
||||
// Preserve the historical bare-"@" behaviour (empty filename → error).
|
||||
if value == "@" {
|
||||
return "", false, apperrors.NewValidation("@file: filename must not be empty")
|
||||
}
|
||||
if !looksLikeFilePath(value) {
|
||||
return value, false, nil
|
||||
}
|
||||
path := value[1:]
|
||||
if path == "" {
|
||||
return "", false, apperrors.NewValidation("@file: filename must not be empty")
|
||||
}
|
||||
// @- is stdin, not a file — callers should use ResolveInputSource.
|
||||
if path == "-" {
|
||||
return value, false, nil
|
||||
@@ -156,14 +189,17 @@ func ReadFileArg(value string) (string, bool, error) {
|
||||
//
|
||||
// The flagName parameter is used only for error messages and StdinGuard tracking.
|
||||
func ResolveInputSource(value string, flagName string, guard *StdinGuard) (string, error) {
|
||||
if !strings.HasPrefix(value, "@") {
|
||||
// Preserve the historical bare-"@" behaviour (empty filename → error).
|
||||
if value == "@" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
|
||||
}
|
||||
if !looksLikeFilePath(value) {
|
||||
// Pass through natural-language strings that happen to start with
|
||||
// '@' (e.g. "@所有人 早上好") so they reach the MCP payload intact.
|
||||
return value, nil
|
||||
}
|
||||
|
||||
path := value[1:]
|
||||
if path == "" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
|
||||
}
|
||||
|
||||
// @- reads from stdin.
|
||||
if path == "-" {
|
||||
|
||||
@@ -47,6 +47,38 @@ func TestReadFileArgPlainValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFileArgChineseAtMention guards the @所有人-style mentions that the
|
||||
// chat bot Webhook tests rely on: an '@' followed by non-ASCII text must be
|
||||
// treated as a literal message, not as the @file injection syntax.
|
||||
func TestReadFileArgChineseAtMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{
|
||||
"@所有人 这是 @ 所有人 的消息",
|
||||
"@张三",
|
||||
"@A 但接下来都是中文@测试",
|
||||
}
|
||||
for _, in := range cases {
|
||||
val, isFile, err := ReadFileArg(in)
|
||||
if in == "@A 但接下来都是中文@测试" {
|
||||
// '@A' starts with ASCII, treated as path → expect file error
|
||||
if err == nil {
|
||||
t.Errorf("@A... should attempt file lookup; got val=%q isFile=%v", val, isFile)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%q: unexpected error %v", in, err)
|
||||
continue
|
||||
}
|
||||
if isFile {
|
||||
t.Errorf("%q: should be plain text, got isFile=true", in)
|
||||
}
|
||||
if val != in {
|
||||
t.Errorf("%q: got %q", in, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileArgReadsFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -236,10 +236,18 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
for _, b := range built {
|
||||
if b.parent == "" {
|
||||
name := b.cmd.Name()
|
||||
if _, exists := topLevel[name]; !exists {
|
||||
if existing, exists := topLevel[name]; exists {
|
||||
// Multiple servers contribute the same top-level command
|
||||
// (e.g. group-chat and im both register `dws chat`). Move
|
||||
// the incoming command's *children* into the existing top-
|
||||
// level command instead of attaching the whole command (which
|
||||
// would create `dws chat chat` because attachOrMerge would
|
||||
// AddCommand(b.cmd) when no same-named sub exists).
|
||||
mergeSubcommandsInto(existing, b.cmd)
|
||||
} else {
|
||||
topOrder = append(topOrder, name)
|
||||
topLevel[name] = b.cmd
|
||||
}
|
||||
topLevel[name] = b.cmd
|
||||
} else {
|
||||
children = append(children, b)
|
||||
}
|
||||
@@ -248,12 +256,16 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
if parent, ok := topLevel[child.parent]; ok {
|
||||
attachOrMerge(parent, child.cmd)
|
||||
} else {
|
||||
// Parent not found among dynamic commands; emit as top-level.
|
||||
// Parent not found among dynamic commands; emit as top-level,
|
||||
// merging into an existing same-named top-level command if one
|
||||
// is already registered (same reasoning as the loop above).
|
||||
name := child.cmd.Name()
|
||||
if _, exists := topLevel[name]; !exists {
|
||||
if existing, exists := topLevel[name]; exists {
|
||||
mergeSubcommandsInto(existing, child.cmd)
|
||||
} else {
|
||||
topOrder = append(topOrder, name)
|
||||
topLevel[name] = child.cmd
|
||||
}
|
||||
topLevel[name] = child.cmd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,10 +295,12 @@ type toolRequestSchema struct {
|
||||
}
|
||||
|
||||
type toolRequestProp struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
}
|
||||
|
||||
// buildFlagsFromDetailSchema adds properly-typed cobra flags to cmd based on
|
||||
@@ -371,6 +385,17 @@ func buildFlagsFromDetailSchema(cmd *cobra.Command, schemaJSON string, flagOverr
|
||||
cmd.Flags().String(flagName, defaultVal, help)
|
||||
}
|
||||
|
||||
// Carry schema "format" / "enum" hints onto the cobra flag via
|
||||
// pflag annotations so PreParse handlers (e.g. StickyHandler)
|
||||
// can reason about whether a glued suffix looks like a real
|
||||
// value. The annotation keys are read by FlagInfoFromCommand.
|
||||
if prop.Format != "" {
|
||||
_ = cmd.Flags().SetAnnotation(flagName, "x-cli-format", []string{prop.Format})
|
||||
}
|
||||
if len(prop.Enum) > 0 {
|
||||
_ = cmd.Flags().SetAnnotation(flagName, "x-cli-enum", append([]string{}, prop.Enum...))
|
||||
}
|
||||
|
||||
if requiredSet[key] {
|
||||
_ = cmd.MarkFlagRequired(flagName)
|
||||
}
|
||||
@@ -478,6 +503,24 @@ func resolveNestedGroup(root *cobra.Command, groupPath string, registry map[stri
|
||||
return ensureNestedGroup(root, groupPath, groupPath, registry)
|
||||
}
|
||||
|
||||
// mergeSubcommandsInto moves all sub-commands of src into dst, using
|
||||
// attachOrMerge so subtree merges happen recursively. src itself is left
|
||||
// empty after the call. Used when two envelope entries register the same
|
||||
// top-level command (e.g. group-chat and im both `cli.command="chat"`):
|
||||
// we want their *children* to coexist under one chat root, not have one
|
||||
// nested inside the other.
|
||||
func mergeSubcommandsInto(dst, src *cobra.Command) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
subs := make([]*cobra.Command, len(src.Commands()))
|
||||
copy(subs, src.Commands())
|
||||
for _, sub := range subs {
|
||||
src.RemoveCommand(sub)
|
||||
attachOrMerge(dst, sub)
|
||||
}
|
||||
}
|
||||
|
||||
// attachOrMerge adds child as a sub-command of parent. If parent already has a
|
||||
// sub-command with the same Name(), the two are merged recursively: child's
|
||||
// sub-commands are moved onto the existing one and child itself is discarded.
|
||||
@@ -532,10 +575,19 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
var bindings []FlagBinding
|
||||
type transformEntry struct {
|
||||
paramName string
|
||||
mapsTo string
|
||||
transform string
|
||||
transformArgs map[string]any
|
||||
}
|
||||
var transforms []transformEntry
|
||||
// mapsToRoutes captures flags that only need value-routing (no transform)
|
||||
// — e.g. a literal --content flag that mapsTo "markdown". The dispatch
|
||||
// loop moves params[paramName] → params[mapsTo] after CLI binding.
|
||||
type mapsToRoute struct {
|
||||
paramName string
|
||||
mapsTo string
|
||||
}
|
||||
var mapsToRoutes []mapsToRoute
|
||||
type envDefaultEntry struct {
|
||||
paramName string
|
||||
envVar string
|
||||
@@ -664,9 +716,19 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
if flagOverride.Transform != "" {
|
||||
transforms = append(transforms, transformEntry{
|
||||
paramName: paramName,
|
||||
mapsTo: strings.TrimSpace(flagOverride.MapsTo),
|
||||
transform: flagOverride.Transform,
|
||||
transformArgs: flagOverride.TransformArgs,
|
||||
})
|
||||
} else if mt := strings.TrimSpace(flagOverride.MapsTo); mt != "" {
|
||||
// mapsTo without transform: just route the literal value into a
|
||||
// different MCP parameter slot. Common case is --content (literal
|
||||
// string) mapping to MCP parameter markdown, alongside a sibling
|
||||
// --content-file (transform: file_read) mapping to the same slot.
|
||||
mapsToRoutes = append(mapsToRoutes, mapsToRoute{
|
||||
paramName: paramName,
|
||||
mapsTo: mt,
|
||||
})
|
||||
}
|
||||
if flagOverride.EnvDefault != "" {
|
||||
envDefaults = append(envDefaults, envDefaultEntry{
|
||||
@@ -699,12 +761,15 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
}
|
||||
}
|
||||
bodyWrapper := strings.TrimSpace(override.BodyWrapper)
|
||||
if len(transforms) == 0 && len(envDefaults) == 0 && len(defaultInjects) == 0 && len(runtimeDefaults) == 0 && len(omits) == 0 && !needsDottedNesting && bodyWrapper == "" {
|
||||
if len(transforms) == 0 && len(envDefaults) == 0 && len(defaultInjects) == 0 && len(runtimeDefaults) == 0 && len(omits) == 0 && len(mapsToRoutes) == 0 && !needsDottedNesting && bodyWrapper == "" {
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// Build a normalizer that applies default injections + env defaults + runtime defaults
|
||||
// + transforms + omitWhen + nesting + body wrap.
|
||||
// Build a normalizer that applies default injections + env defaults +
|
||||
// runtime defaults + transforms + mapsTo routing + omitWhen + nesting +
|
||||
// body wrap. Tool-level cobra constraints (MutuallyExclusive /
|
||||
// RequireOneOf) are wired separately via applyFlagConstraints and don't
|
||||
// belong in this closure.
|
||||
normalizer := func(cmd *cobra.Command, params map[string]any) error {
|
||||
// §v3.2: Apply envelope flag.default for parameters not explicitly set.
|
||||
// Coerce by Kind so number-typed schemas don't reject string defaults.
|
||||
@@ -756,14 +821,21 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
}
|
||||
}
|
||||
|
||||
// §3: Apply transforms
|
||||
// §3: Apply transforms. When MapsTo is set, the transformed value is
|
||||
// routed to params[MapsTo] and the original params[paramName] is
|
||||
// dropped, so the MCP body carries a single (post-transform) entry
|
||||
// at the target slot.
|
||||
for _, t := range transforms {
|
||||
val, exists := params[t.paramName]
|
||||
if !exists {
|
||||
// For enum_map with _default, apply default even when flag is omitted
|
||||
if t.transform == "enum_map" && t.transformArgs != nil {
|
||||
if defaultVal, hasDefault := t.transformArgs["_default"]; hasDefault {
|
||||
params[t.paramName] = defaultVal
|
||||
target := t.paramName
|
||||
if t.mapsTo != "" {
|
||||
target = t.mapsTo
|
||||
}
|
||||
params[target] = defaultVal
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -772,7 +844,25 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params[t.paramName] = transformed
|
||||
if t.mapsTo != "" {
|
||||
params[t.mapsTo] = transformed
|
||||
delete(params, t.paramName)
|
||||
} else {
|
||||
params[t.paramName] = transformed
|
||||
}
|
||||
}
|
||||
|
||||
// §3b: mapsTo-only routes (no transform). Move params[paramName] →
|
||||
// params[mapsTo] verbatim. Common pattern: a literal --content flag
|
||||
// that routes to MCP parameter `markdown`, alongside a sibling
|
||||
// --content-file flag that transforms + routes to the same slot.
|
||||
for _, r := range mapsToRoutes {
|
||||
val, exists := params[r.paramName]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
params[r.mapsTo] = val
|
||||
delete(params, r.paramName)
|
||||
}
|
||||
|
||||
// §v3.2.2: Apply omitWhen — drop keys whose value meets the omit
|
||||
|
||||
@@ -15,6 +15,8 @@ package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -1945,3 +1947,283 @@ func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
|
||||
t.Fatalf("expected exactly one 'send' leaf, got %d", sendCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFlagsFromDetailSchema_FormatEnumAnnotations verifies that the
|
||||
// JSON Schema "format" and "enum" hints are copied onto the cobra flag's
|
||||
// pflag annotations under x-cli-format / x-cli-enum, so PreParse
|
||||
// handlers can use them when deciding whether to split glued tokens.
|
||||
func TestBuildFlagsFromDetailSchema_FormatEnumAnnotations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-calendar",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "calendar",
|
||||
Command: "calendar",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"event_list": {CLIName: "list"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
details := map[string][]market.DetailTool{
|
||||
"calendar": {
|
||||
{
|
||||
ToolName: "event_list",
|
||||
ToolRequest: `{"properties":{` +
|
||||
`"start":{"type":"string","format":"date-time","description":"开始时间"},` +
|
||||
`"end":{"type":"string","format":"date-time","description":"结束时间"},` +
|
||||
`"status":{"type":"string","enum":["confirmed","tentative","cancelled"]}` +
|
||||
`}}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
|
||||
list := findChild(cmds[0], "list")
|
||||
if list == nil {
|
||||
t.Fatal("list leaf not found")
|
||||
}
|
||||
|
||||
startFlag := list.Flags().Lookup("start")
|
||||
if startFlag == nil {
|
||||
t.Fatal("--start flag missing")
|
||||
}
|
||||
if got := startFlag.Annotations["x-cli-format"]; len(got) != 1 || got[0] != "date-time" {
|
||||
t.Errorf("--start x-cli-format = %v, want [date-time]", got)
|
||||
}
|
||||
|
||||
endFlag := list.Flags().Lookup("end")
|
||||
if endFlag == nil {
|
||||
t.Fatal("--end flag missing")
|
||||
}
|
||||
if got := endFlag.Annotations["x-cli-format"]; len(got) != 1 || got[0] != "date-time" {
|
||||
t.Errorf("--end x-cli-format = %v, want [date-time]", got)
|
||||
}
|
||||
|
||||
statusFlag := list.Flags().Lookup("status")
|
||||
if statusFlag == nil {
|
||||
t.Fatal("--status flag missing")
|
||||
}
|
||||
gotEnum := statusFlag.Annotations["x-cli-enum"]
|
||||
wantEnum := []string{"confirmed", "tentative", "cancelled"}
|
||||
if !equalStringSlice(gotEnum, wantEnum) {
|
||||
t.Errorf("--status x-cli-enum = %v, want %v", gotEnum, wantEnum)
|
||||
}
|
||||
// Status has no format and should not carry x-cli-format.
|
||||
if got := statusFlag.Annotations["x-cli-format"]; len(got) != 0 {
|
||||
t.Errorf("--status should not have x-cli-format, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommands_MapsTo_WithoutTransform verifies that a flag
|
||||
// carrying only MapsTo (no transform) moves its literal value to the
|
||||
// target MCP parameter slot and drops the source key. The canonical use
|
||||
// case is exposing --content as a sibling of --markdown that both feed
|
||||
// the same upstream `markdown` parameter.
|
||||
func TestBuildDynamicCommands_MapsTo_WithoutTransform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &captureRunner{}
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-doc",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"update_document": {
|
||||
CLIName: "update",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"nodeId": {Alias: "node"},
|
||||
"content": {Alias: "content", MapsTo: "markdown"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "# 标题"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
if err := cmds[0].Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
if runner.lastParams["markdown"] != "# 标题" {
|
||||
t.Errorf("params[markdown] = %v, want '# 标题'", runner.lastParams["markdown"])
|
||||
}
|
||||
if _, leftover := runner.lastParams["content"]; leftover {
|
||||
t.Errorf("source key 'content' must be deleted after mapsTo, got params=%+v", runner.lastParams)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommands_MapsTo_WithFileReadTransform verifies the full
|
||||
// envelope shape that #277 needs: a path-typed flag (--content-file) that
|
||||
// reads the file via the file_read transform AND routes the resulting
|
||||
// string into a sibling MCP parameter (markdown). End-to-end: user types
|
||||
// a path, the upstream tool receives file contents under the right key.
|
||||
func TestBuildDynamicCommands_MapsTo_WithFileReadTransform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.md")
|
||||
contents := "# 项目周报\n\n- 完成 A\n- 完成 B\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
runner := &captureRunner{}
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-doc",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"update_document": {
|
||||
CLIName: "update",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"nodeId": {Alias: "node"},
|
||||
"contentFile": {
|
||||
Alias: "content-file",
|
||||
MapsTo: "markdown",
|
||||
Transform: "file_read",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content-file", path})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
if err := cmds[0].Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
if runner.lastParams["markdown"] != contents {
|
||||
t.Errorf("params[markdown] = %v, want file contents", runner.lastParams["markdown"])
|
||||
}
|
||||
if _, leftover := runner.lastParams["contentFile"]; leftover {
|
||||
t.Errorf("source key 'contentFile' must be deleted after mapsTo, got params=%+v", runner.lastParams)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne verifies the
|
||||
// realistic pre-prod shape: two sibling flags (--content literal and
|
||||
// --content-file path) both mapsTo "markdown", guarded by the existing
|
||||
// tool-level cobra MutuallyExclusive constraint. When the user sets only
|
||||
// one, it routes through cleanly; the other source key is absent.
|
||||
func TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &captureRunner{}
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-doc",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"update_document": {
|
||||
CLIName: "update",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"nodeId": {Alias: "node"},
|
||||
"content": {Alias: "content", MapsTo: "markdown"},
|
||||
"contentFile": {
|
||||
Alias: "content-file",
|
||||
MapsTo: "markdown",
|
||||
Transform: "file_read",
|
||||
},
|
||||
},
|
||||
MutuallyExclusive: [][]string{{"content", "content-file"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "literal body"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
if err := cmds[0].Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
if runner.lastParams["markdown"] != "literal body" {
|
||||
t.Errorf("params[markdown] = %v, want 'literal body'", runner.lastParams["markdown"])
|
||||
}
|
||||
if _, leftover := runner.lastParams["content"]; leftover {
|
||||
t.Errorf("source key 'content' must be deleted, got params=%+v", runner.lastParams)
|
||||
}
|
||||
if _, leftover := runner.lastParams["contentFile"]; leftover {
|
||||
t.Errorf("untouched sibling key 'contentFile' must not appear, got params=%+v", runner.lastParams)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra verifies that
|
||||
// when both mapsTo siblings are set, the existing tool-level
|
||||
// MutuallyExclusive constraint produces a cobra error before dispatch
|
||||
// runs. This is a sanity regression check — the cobra mechanism is
|
||||
// pre-existing, but combining it with mapsTo is the realistic envelope
|
||||
// shape #277 needs.
|
||||
func TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &captureRunner{}
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-doc",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"update_document": {
|
||||
CLIName: "update",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"nodeId": {Alias: "node"},
|
||||
"content": {Alias: "content", MapsTo: "markdown"},
|
||||
"contentFile": {Alias: "content-file", MapsTo: "markdown", Transform: "file_read"},
|
||||
},
|
||||
MutuallyExclusive: [][]string{{"content", "content-file"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "x", "--content-file", "/tmp/y"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
err := cmds[0].Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected mutually-exclusive error, got nil")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "none of the others") && !strings.Contains(msg, "mutually") && !strings.Contains(msg, "exclusive") {
|
||||
t.Fatalf("expected mutually-exclusive error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// equalStringSlice is a small helper for slice comparison in tests.
|
||||
func equalStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -149,12 +149,12 @@ func executePipelineCall(
|
||||
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.
|
||||
// Fail-fast on MCP business errors. Pre-execution validation (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 proceeds to poll/download and either spins until
|
||||
// PollTimeout or burns 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(
|
||||
|
||||
@@ -169,10 +169,10 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
strictMin = b.PositionalIndex + 1
|
||||
}
|
||||
}
|
||||
var argsValidator cobra.PositionalArgs = cobra.NoArgs
|
||||
var argsValidator cobra.PositionalArgs = cobra.ArbitraryArgs
|
||||
switch {
|
||||
case totalMax == 0:
|
||||
argsValidator = cobra.NoArgs
|
||||
argsValidator = cobra.ArbitraryArgs
|
||||
case strictMin > 0 && strictMin == totalMax:
|
||||
argsValidator = cobra.MinimumNArgs(strictMin)
|
||||
case strictMin > 0:
|
||||
|
||||
@@ -16,9 +16,12 @@ package compat
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
@@ -27,7 +30,7 @@ import (
|
||||
|
||||
// ApplyTransform applies a named transform rule to a value.
|
||||
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
|
||||
// json_parse_strict, enum_map.
|
||||
// json_parse_strict, enum_map, file_read, invert_bool.
|
||||
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
|
||||
switch strings.TrimSpace(transform) {
|
||||
case "":
|
||||
@@ -42,6 +45,33 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
|
||||
return transformJSONParseStrict(value)
|
||||
case "enum_map":
|
||||
return transformEnumMap(value, args)
|
||||
case "file_read":
|
||||
return transformFileRead(value)
|
||||
case "invert_bool":
|
||||
return transformInvertBool(value)
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// transformInvertBool flips a boolean: true → false, false → true. Strings
|
||||
// "true"/"false" (any case) are accepted. Used by envelope flags whose CLI
|
||||
// surface and MCP body have opposite semantics — e.g. `--off` (CLI) maps to
|
||||
// `mute=true` (MCP) for "mute is enabled", so the flag override declares
|
||||
// `transform: invert_bool` and the framework flips at send time.
|
||||
func transformInvertBool(value any) (any, error) {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return !v, nil
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
switch s {
|
||||
case "true", "1", "yes", "on":
|
||||
return false, nil
|
||||
case "false", "0", "no", "off", "":
|
||||
return true, nil
|
||||
}
|
||||
return value, nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
@@ -194,6 +224,44 @@ func transformEnumMap(value any, args map[string]any) (any, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// transformFileRead reads the file at the given path and returns its contents
|
||||
// as a UTF-8 string. The special path "-" reads from stdin.
|
||||
//
|
||||
// Typical envelope use is paired with CLIFlagOverride.MapsTo so a path-typed
|
||||
// CLI flag (e.g. --content-file ./a.md) routes the file contents into a
|
||||
// content-typed MCP parameter (e.g. markdown), letting a sibling literal
|
||||
// flag (--content "# 标题") feed the same parameter without conflict.
|
||||
//
|
||||
// Errors are surfaced as validation errors so the dispatcher returns exit code 2
|
||||
// (user input) rather than the generic exit code 1 (transient failure).
|
||||
func transformFileRead(value any) (any, error) {
|
||||
s, ok := toString(value)
|
||||
if !ok {
|
||||
return nil, apperrors.NewValidation("file_read: expected string path, got non-string value")
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, apperrors.NewValidation("file_read: empty path")
|
||||
}
|
||||
var buf []byte
|
||||
var err error
|
||||
if s == "-" {
|
||||
buf, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read stdin: %v", err))
|
||||
}
|
||||
} else {
|
||||
buf, err = os.ReadFile(s)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read %q: %v", s, err))
|
||||
}
|
||||
}
|
||||
if !utf8.Valid(buf) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: %q is not valid UTF-8", s))
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func toString(v any) (string, bool) {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
package compat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -123,3 +126,162 @@ func TestJSONParse_InvalidInput(t *testing.T) {
|
||||
t.Fatal("error message should be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
|
||||
// read in full and surfaced as a string value. This is the contract the
|
||||
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
|
||||
// file contents in place of the path.
|
||||
func TestFileRead_BasicFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.md")
|
||||
contents := "# Heading\n\n- bullet one\n- bullet two\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
got, err := ApplyTransform(path, "file_read", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("file_read should succeed, got err: %v", err)
|
||||
}
|
||||
if got != contents {
|
||||
t.Errorf("file_read should return file contents verbatim; got %q want %q", got, contents)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_EmptyPath rejects empty input with a validation error rather
|
||||
// than silently reading "" / cwd. The dispatcher maps validation errors to
|
||||
// exit code 2 so the user sees a usage problem.
|
||||
func TestFileRead_EmptyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ApplyTransform("", "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file_read") {
|
||||
t.Errorf("error should mention the transform name, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_MissingFile surfaces a clear validation error when the path
|
||||
// doesn't exist. The previous `os.ReadFile` error is wrapped so the user
|
||||
// sees what they passed.
|
||||
func TestFileRead_MissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "definitely-not-here.md")
|
||||
_, err := ApplyTransform(missing, "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "definitely-not-here.md") {
|
||||
t.Errorf("error should mention the missing path, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_InvalidUTF8 rejects binary input. Upstream tools expect text
|
||||
// content and silently shipping a corrupted byte string would mask a real
|
||||
// user error.
|
||||
func TestFileRead_InvalidUTF8(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "binary.dat")
|
||||
if err := os.WriteFile(path, []byte{0xff, 0xfe, 0x00, 0x01}, 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
_, err := ApplyTransform(path, "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected UTF-8 validation error for binary input")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UTF-8") {
|
||||
t.Errorf("error should mention UTF-8, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_NonString rejects non-string flag values. CLI flags resolve to
|
||||
// string by default but a misconfigured envelope (e.g. Type: int) shouldn't
|
||||
// silently no-op.
|
||||
func TestFileRead_NonString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ApplyTransform(123, "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for non-string value")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_StdinDashIsAccepted documents the contract: the special value
|
||||
// "-" is reserved for stdin. We don't test stdin redirection here (that
|
||||
// requires plumbing os.Stdin replacement which complicates the test) — this
|
||||
// is a compile-time signal that "-" doesn't path-resolve to a file named "-"
|
||||
// in the current directory. The end-to-end stdin path is covered in
|
||||
// test/cli_compat once the envelope ships.
|
||||
func TestFileRead_StdinDashIsAccepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Run with stdin redirected from an empty pipe so we don't hang.
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
if _, err := w.Write([]byte("piped content")); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
origStdin := os.Stdin
|
||||
os.Stdin = r
|
||||
defer func() { os.Stdin = origStdin }()
|
||||
|
||||
got, err := ApplyTransform("-", "file_read", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("file_read with '-' should read stdin, got err: %v", err)
|
||||
}
|
||||
if got != "piped content" {
|
||||
t.Errorf("expected stdin contents, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_UnknownTransformPassThrough double-checks that the new case
|
||||
// is gated by name and doesn't regress when the transform name is missing.
|
||||
func TestFileRead_UnknownTransformPassThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ApplyTransform("./some-path", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("empty transform should pass through, got err: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, "./some-path") {
|
||||
t.Errorf("expected pass-through, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvertBoolTransform(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want any
|
||||
}{
|
||||
{true, false},
|
||||
{false, true},
|
||||
{"true", false},
|
||||
{"false", true},
|
||||
{"True", false},
|
||||
{"FALSE", true},
|
||||
{"on", false},
|
||||
{"off", true},
|
||||
{"", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ApplyTransform(c.in, "invert_bool", nil)
|
||||
if err != nil {
|
||||
t.Errorf("ApplyTransform(%v, invert_bool) err=%v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("ApplyTransform(%v) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
-13
@@ -37,19 +37,20 @@ const (
|
||||
|
||||
// Error is the structured repository-local error model for the Go rewrite.
|
||||
type Error struct {
|
||||
Category Category
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
Category Category
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
AvailableFlags []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
@@ -135,6 +136,16 @@ func WithActions(actions ...string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithAvailableFlags records visible local flag names for agent recovery.
|
||||
func WithAvailableFlags(names ...string) Option {
|
||||
return func(err *Error) {
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
err.AvailableFlags = append([]string{}, names...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithSnapshot records the recovery snapshot path associated with the failure.
|
||||
func WithSnapshot(path string) Option {
|
||||
return func(err *Error) {
|
||||
@@ -260,6 +271,9 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
if len(typed.Actions) > 0 {
|
||||
errorPayload["actions"] = typed.Actions
|
||||
}
|
||||
if len(typed.AvailableFlags) > 0 {
|
||||
errorPayload["available_flags"] = typed.AvailableFlags
|
||||
}
|
||||
if typed.Snapshot != "" {
|
||||
errorPayload["snapshot_path"] = typed.Snapshot
|
||||
}
|
||||
@@ -359,6 +373,9 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
lines = append(lines, fmt.Sprintf("Action: %s", action))
|
||||
}
|
||||
}
|
||||
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if typed.Retryable {
|
||||
lines = append(lines, "Retryable: true")
|
||||
}
|
||||
@@ -414,3 +431,31 @@ func category(err error) string {
|
||||
}
|
||||
return string(CategoryInternal)
|
||||
}
|
||||
|
||||
const availableFlagsHumanMaxRunes = 200
|
||||
|
||||
func formatAvailableFlagsHumanLine(flags []string) string {
|
||||
if len(flags) == 0 {
|
||||
return ""
|
||||
}
|
||||
b := strings.Builder{}
|
||||
b.WriteString("Flags: ")
|
||||
written := 0
|
||||
for i, name := range flags {
|
||||
if i > 0 {
|
||||
if written+2 > availableFlagsHumanMaxRunes {
|
||||
b.WriteString("...")
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString(", ")
|
||||
written += 2
|
||||
}
|
||||
if written+len(name) > availableFlagsHumanMaxRunes {
|
||||
b.WriteString("...")
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString(name)
|
||||
written += len(name)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -77,6 +77,27 @@ func TestPrintJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation(
|
||||
"unknown flag: --foo",
|
||||
WithReason("unknown_flag"),
|
||||
WithHint("Did you mean --bar?"),
|
||||
WithAvailableFlags("bar", "baz"),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"available_flags"`) {
|
||||
t.Fatalf("expected available_flags in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"bar"`) || !strings.Contains(got, `"baz"`) {
|
||||
t.Fatalf("expected flag names in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHuman(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+87
-296
@@ -15,6 +15,7 @@ package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
@@ -30,6 +31,12 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
// chatHandler retains only the chat commands that carry real business logic
|
||||
// (intelligent tool routing, current-user resolution, response normalization,
|
||||
// or stdin/@file input support that dynamic commands do not yet provide).
|
||||
// Thin wrappers — search, group rename, group members list/add/remove/add-bot,
|
||||
// bot search — are now produced by the dynamic service-discovery envelope
|
||||
// (envelope/pre-discovery.json) so the helper does not have to duplicate them.
|
||||
type chatHandler struct{}
|
||||
|
||||
func (chatHandler) Name() string {
|
||||
@@ -40,7 +47,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "chat",
|
||||
Short: "群聊 / 消息 / 机器人",
|
||||
Long: "管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取会话消息、发送群消息、机器人消息与 Webhook。",
|
||||
Long: "钉钉会话与群聊:发送消息(用户/机器人/Webhook)、撤回机器人消息、创建群。其余命令由服务发现 envelope 提供。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -64,11 +71,12 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newChatMessageSendByBotCommand(runner),
|
||||
newChatMessageRecallByBotCommand(runner),
|
||||
newChatMessageSendByWebhookCommand(runner),
|
||||
newChatMessageReplyCommand(runner),
|
||||
)
|
||||
|
||||
bot := &cobra.Command{
|
||||
Use: "bot",
|
||||
Short: "机器人管理",
|
||||
group := &cobra.Command{
|
||||
Use: "group",
|
||||
Short: "群组管理",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -76,9 +84,9 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
bot.AddCommand(newChatBotSearchCommand(runner))
|
||||
group.AddCommand(newChatGroupCreateCommand(runner))
|
||||
|
||||
root.AddCommand(message, newChatSearchCommand(runner), newChatGroupCommand(runner), bot)
|
||||
root.AddCommand(message, group)
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -92,12 +100,13 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
--open-dingtalk-id 指定 openDingTalkId 发单聊 (适用于无法获取 userId 的场景)。
|
||||
三者只能选其一,不能同时指定。
|
||||
|
||||
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。必须提供 --title 作为消息标题。
|
||||
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。
|
||||
--title 是消息标题,群聊与单聊都必填(API 强制要求;缺失时返回误导性的 "发群服务窗会话消息失败")。
|
||||
|
||||
群聊场景下可用 --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 "请查收"
|
||||
Example: ` dws chat message send --group <openconversation_id> --title "周报" --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 +136,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 +204,16 @@ 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")
|
||||
}
|
||||
// Both send_message_as_user (group) and send_direct_message_as_user (direct)
|
||||
// reject an empty title at the API level with a misleading
|
||||
// "发群服务窗会话消息失败" error, so fail loudly here instead. The schema
|
||||
// declares title as a required parameter on both tools.
|
||||
if strings.TrimSpace(title) == "" {
|
||||
if hasGroup {
|
||||
return nil, "", apperrors.NewValidation("--title is required for group messages (--group)")
|
||||
}
|
||||
return nil, "", apperrors.NewValidation("--title is required for direct messages (--user / --open-dingtalk-id)")
|
||||
}
|
||||
|
||||
params := map[string]any{"text": text}
|
||||
if strings.TrimSpace(title) != "" {
|
||||
@@ -259,94 +278,6 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "根据名称搜索会话列表",
|
||||
Example: ` dws chat search --query "项目冲刺"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
query, err := cmd.Flags().GetString("query")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --query")
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return apperrors.NewValidation("--query is required")
|
||||
}
|
||||
|
||||
searchReq := map[string]any{"query": query}
|
||||
cursor, err := cmd.Flags().GetString("cursor")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --cursor")
|
||||
}
|
||||
if strings.TrimSpace(cursor) != "" {
|
||||
searchReq["cursor"] = cursor
|
||||
}
|
||||
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"chat",
|
||||
"search_groups_by_keyword",
|
||||
map[string]any{"OpenSearchRequest": searchReq},
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatGroupCommand(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "group",
|
||||
Short: "群组管理",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
members := &cobra.Command{
|
||||
Use: "members",
|
||||
Short: "群成员管理",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
// Keeps the helper-restructured group winning over the dynamic envelope's
|
||||
// `members` leaf (which only exposes `get_group_members`); without this
|
||||
// the merge layer treats the shape mismatch as "envelope is authority"
|
||||
// and drops the entire helper subtree (issue #164).
|
||||
preferLegacyLeaf(members)
|
||||
|
||||
members.AddCommand(
|
||||
newChatGroupMembersListCommand(runner),
|
||||
newChatGroupMemberAddCommand(runner),
|
||||
newChatGroupMemberRemoveCommand(runner),
|
||||
newChatGroupMembersAddBotCommand(runner),
|
||||
)
|
||||
|
||||
root.AddCommand(
|
||||
newChatGroupCreateCommand(runner),
|
||||
members,
|
||||
newChatGroupRenameCommand(runner),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -636,6 +567,10 @@ func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command {
|
||||
}
|
||||
|
||||
// ── message send-by-webhook ────────────────────────────────
|
||||
//
|
||||
// Kept as a helper (rather than delegating to the dynamic envelope) because
|
||||
// it needs --text @file / stdin pipe support via resolveStringFlag, which the
|
||||
// dynamic-command layer does not yet provide.
|
||||
|
||||
func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
@@ -702,65 +637,63 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members list ─────────────────────────────────────
|
||||
// ── message reply ────────────────────────────────────────
|
||||
//
|
||||
// Kept as a helper because the underlying MCP tool send_personal_message
|
||||
// requires the reply payload to be a JSON-encoded string assembled from
|
||||
// --ref-msg-id / --ref-sender / --text. Envelope toolOverride does flat
|
||||
// flag→param mapping only and cannot construct nested JSON, so this
|
||||
// orchestration must live in CLI code.
|
||||
|
||||
func newChatGroupMembersListCommand(runner executor.Runner) *cobra.Command {
|
||||
func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询群成员列表",
|
||||
Example: ` dws chat group members list --id <openconversation_id>`,
|
||||
Use: "reply",
|
||||
Short: "引用回复消息(支持单聊/群聊)",
|
||||
Long: "以当前用户身份引用某条消息并回复。需 --conversation-id 会话 ID、--ref-msg-id 被引用消息 ID、--ref-sender 原发送者 openDingTalkId、--text 回复内容。",
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
convID, _ := cmd.Flags().GetString("conversation-id")
|
||||
refMsgID, _ := cmd.Flags().GetString("ref-msg-id")
|
||||
refSender, _ := cmd.Flags().GetString("ref-sender")
|
||||
text, _ := cmd.Flags().GetString("text")
|
||||
if strings.TrimSpace(convID) == "" {
|
||||
return apperrors.NewValidation("--conversation-id is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
if strings.TrimSpace(refMsgID) == "" {
|
||||
return apperrors.NewValidation("--ref-msg-id is required")
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
|
||||
params["cursor"] = v
|
||||
if strings.TrimSpace(refSender) == "" {
|
||||
return apperrors.NewValidation("--ref-sender is required")
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "get_group_members", params,
|
||||
))
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return apperrors.NewValidation("--text is required")
|
||||
}
|
||||
replyContent := map[string]any{
|
||||
"referenceOpenMessageId": refMsgID,
|
||||
"srcMsgSendOpenDingTalkId": refSender,
|
||||
"replyMsgType": "text",
|
||||
"content": text,
|
||||
}
|
||||
contentJSON, err := jsonMarshal(replyContent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group rename ───────────────────────────────────────────
|
||||
|
||||
func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename",
|
||||
Short: "更新群名称",
|
||||
Example: ` dws chat group rename --id <openconversation_id> --name "新群名"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return apperrors.NewValidation("--name is required")
|
||||
return apperrors.NewInternal("marshal reply content: " + err.Error())
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
"group_name": name,
|
||||
"openConversationId": convID,
|
||||
"msgType": "reply",
|
||||
"content": contentJSON,
|
||||
"clawType": "wukong",
|
||||
}
|
||||
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "update_group_name", params,
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"group-chat",
|
||||
"send_personal_message",
|
||||
params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
@@ -771,160 +704,18 @@ func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
cmd.Flags().String("name", "", "新群名称 (必填)")
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
|
||||
cmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)")
|
||||
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
|
||||
cmd.Flags().String("text", "", "回复正文 (必填)")
|
||||
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members add ──────────────────────────────────────
|
||||
|
||||
func newChatGroupMemberAddCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加群成员",
|
||||
Example: ` dws chat group members add --id <openconversation_id> --users userId1,userId2`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
usersStr, _ := cmd.Flags().GetString("users")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
if strings.TrimSpace(usersStr) == "" {
|
||||
return apperrors.NewValidation("--users is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
"userId": splitCSV(usersStr),
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "add_group_member", params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
func jsonMarshal(v any) (string, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
cmd.Flags().String("users", "", "要添加的 userId 列表,逗号分隔 (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members remove ───────────────────────────────────
|
||||
|
||||
func newChatGroupMemberRemoveCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "移除群成员",
|
||||
Example: ` dws chat group members remove --id <openconversation_id> --users userId1,userId2`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
usersStr, _ := cmd.Flags().GetString("users")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
if strings.TrimSpace(usersStr) == "" {
|
||||
return apperrors.NewValidation("--users is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversationId": groupID,
|
||||
"userIdList": splitCSV(usersStr),
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "remove_group_member", params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "Group ID / openconversation_id (required)")
|
||||
cmd.Flags().String("users", "", "Comma-separated userId list to remove (required)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members add-bot ──────────────────────────────────
|
||||
|
||||
func newChatGroupMembersAddBotCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add-bot",
|
||||
Short: "Add bot to group",
|
||||
Example: ` dws chat group members add-bot --robot-code <robot-code> --id <openconversation_id>`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
robotCode, _ := cmd.Flags().GetString("robot-code")
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
if strings.TrimSpace(robotCode) == "" {
|
||||
return apperrors.NewValidation("--robot-code is required")
|
||||
}
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"robotCode": robotCode,
|
||||
"openConversationId": groupID,
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "bot", "add_robot_to_group", params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-code", "", "Bot code (required)")
|
||||
cmd.Flags().String("id", "", "Group openConversationId (required)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── bot search ─────────────────────────────────────────────
|
||||
|
||||
func newChatBotSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "Search my bots",
|
||||
Example: " dws chat bot search --page 1\n dws chat bot search --page 1 --size 10 --name \"日报\"",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
page, _ := cmd.Flags().GetInt("page")
|
||||
params := map[string]any{
|
||||
"currentPage": page,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("size"); v > 0 {
|
||||
params["pageSize"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("name"); v != "" {
|
||||
params["robotName"] = v
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "bot", "search_my_robots", params,
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().Int("page", 1, "Page number, starting from 1")
|
||||
cmd.Flags().Int("size", 0, "Items per page (default 50)")
|
||||
cmd.Flags().String("name", "", "Search by name")
|
||||
return cmd
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type captureRunner struct {
|
||||
@@ -60,28 +59,28 @@ func TestChatMessageSendRoutesByDestination(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "group",
|
||||
args: []string{"--group", "cid-xyz", "--text", "hello"},
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
|
||||
wantTool: "send_message_as_user",
|
||||
wantKey: "openConversation_id",
|
||||
wantValue: "cid-xyz",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
name: "positional-text",
|
||||
args: []string{"--group", "cid-xyz", "hello from positional"},
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "hello from positional"},
|
||||
wantTool: "send_message_as_user",
|
||||
wantKey: "text",
|
||||
wantValue: "hello from positional",
|
||||
@@ -132,6 +131,21 @@ func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
|
||||
args: []string{"--group", "cid-x"},
|
||||
wantErr: "--text (or positional argument) is required",
|
||||
},
|
||||
{
|
||||
name: "group-without-title",
|
||||
args: []string{"--group", "cid-x", "--text", "hi"},
|
||||
wantErr: "--title is required for group messages",
|
||||
},
|
||||
{
|
||||
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) {
|
||||
@@ -292,76 +306,6 @@ func equalAny(a, b any) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatGroupMembersListSubcommand pins the explicit `list` subcommand
|
||||
// added for issue #164: previously the bare `chat group members --id` was
|
||||
// the list path, but it shape-mismatched the dynamic envelope's `members`
|
||||
// leaf and got eaten by the merge layer. Now `dws chat group members list
|
||||
// --id <cid>` is a proper leaf siblings of add/remove/add-bot.
|
||||
func TestChatGroupMembersListSubcommand(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
groupCmd := newChatGroupCommand(runner)
|
||||
var members *cobra.Command
|
||||
for _, sub := range groupCmd.Commands() {
|
||||
if sub.Name() == "members" {
|
||||
members = sub
|
||||
break
|
||||
}
|
||||
}
|
||||
if members == nil {
|
||||
t.Fatalf("members subcommand missing under chat group")
|
||||
}
|
||||
|
||||
want := map[string]bool{"list": false, "add": false, "remove": false, "add-bot": false}
|
||||
for _, leaf := range members.Commands() {
|
||||
if _, ok := want[leaf.Name()]; ok {
|
||||
want[leaf.Name()] = true
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("expected `chat group members %s` subcommand, missing", name)
|
||||
}
|
||||
}
|
||||
|
||||
if members.Flags().Lookup("id") != nil {
|
||||
t.Errorf("members container should not declare --id (moved to `list` subcommand to avoid shape-mismatch with dynamic envelope)")
|
||||
}
|
||||
|
||||
var listCmd *cobra.Command
|
||||
for _, leaf := range members.Commands() {
|
||||
if leaf.Name() == "list" {
|
||||
listCmd = leaf
|
||||
break
|
||||
}
|
||||
}
|
||||
if listCmd == nil {
|
||||
t.Fatalf("`list` subcommand not found")
|
||||
}
|
||||
if listCmd.Flags().Lookup("id") == nil {
|
||||
t.Errorf("`list` subcommand must declare --id")
|
||||
}
|
||||
if listCmd.Flags().Lookup("cursor") == nil {
|
||||
t.Errorf("`list` subcommand must declare --cursor")
|
||||
}
|
||||
|
||||
// Drive execution via the group root so cobra resolves the subcommand
|
||||
// path properly (calling Execute() on a child directly would re-enter
|
||||
// the root help branch).
|
||||
var out bytes.Buffer
|
||||
groupCmd.SetOut(&out)
|
||||
groupCmd.SetErr(&out)
|
||||
groupCmd.SetArgs([]string{"members", "list", "--id", "cid-xyz"})
|
||||
if err := groupCmd.Execute(); err != nil {
|
||||
t.Fatalf("members list Execute error = %v\noutput: %s", err, out.String())
|
||||
}
|
||||
if got := runner.last.Tool; got != "get_group_members" {
|
||||
t.Fatalf("Tool = %q, want get_group_members", got)
|
||||
}
|
||||
if got := runner.last.Params["openconversation_id"]; got != "cid-xyz" {
|
||||
t.Fatalf("openconversation_id = %#v, want cid-xyz", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterPublic(func() Handler {
|
||||
return driveHandler{}
|
||||
})
|
||||
}
|
||||
|
||||
// driveHandler exposes the one drive subcommand that the service-discovery
|
||||
// envelope cannot express on its own:
|
||||
//
|
||||
// - upload — three-step composite: drive.get_upload_info → HTTP PUT to OSS →
|
||||
// drive.commit_upload. The envelope PipelineStep schema currently supports
|
||||
// type:"call" (MCP tool invocation) and type:"download" (HTTP GET sink),
|
||||
// but has no type:"upload" for streaming a local file to an OSS-signed
|
||||
// PUT URL with per-URL headers. Until the envelope schema grows that
|
||||
// capability, this helper is the canonical client-side glue.
|
||||
//
|
||||
// Other drive-vs-wukong gaps (list-spaces and delete) are covered purely via
|
||||
// envelope toolOverrides — list_spaces as a plain alias map, delete_document
|
||||
// via serverOverride to route to the doc MCP server. No Go code needed for
|
||||
// those two; see envelope/pre-discovery.pre.json drive entry.
|
||||
//
|
||||
// The dynamic envelope still owns the six base commands (list / info /
|
||||
// download / mkdir / upload-info / commit); pickCommands.MergeHardcodedLeaves
|
||||
// guarantees dynamic leaves win on collision so this helper only fills the
|
||||
// upload gap.
|
||||
type driveHandler struct{}
|
||||
|
||||
func (driveHandler) Name() string {
|
||||
return "drive"
|
||||
}
|
||||
|
||||
func (driveHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "drive",
|
||||
Short: "钉盘扩展命令(合并到 dws drive 命令树)",
|
||||
Long: "钉盘扩展子命令: upload 本地文件一键上传(三步合成)。其余命令均由服务发现 envelope 提供。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
root.AddCommand(newDriveUploadCommand(runner))
|
||||
return root
|
||||
}
|
||||
|
||||
// ── upload (three-step composite) ───────────────────────────
|
||||
|
||||
func newDriveUploadCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
Short: "上传本地文件到钉盘",
|
||||
Long: `将本地文件上传到钉盘(三步自动完成):
|
||||
|
||||
1. drive get_upload_info → 获取 OSS 上传凭证 (resourceUrl + uploadId + headers)
|
||||
2. HTTP PUT 文件二进制 → OSS
|
||||
3. drive commit_upload → 提交文件入库
|
||||
|
||||
--folder 指定父目录 dentryUuid,不传则上传到空间根目录。`,
|
||||
Example: ` dws drive upload --file ./report.pdf
|
||||
dws drive upload --file ./slides.pptx --file-name "Q1汇报.pptx"
|
||||
dws drive upload --file ./data.xlsx --folder <dentryUuid>`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runDriveUpload(cmd, runner)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||||
cmd.Flags().String("file-name", "", "文件显示名称 (默认使用文件名)")
|
||||
cmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」")
|
||||
cmd.Flags().String("mime-type", "", "文件 MIME 类型,不传则自动推断")
|
||||
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则上传到空间根目录")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
|
||||
filePath, _ := cmd.Flags().GetString("file")
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
return apperrors.NewValidation("--file is required")
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation("无法解析文件路径: " + err.Error())
|
||||
}
|
||||
fi, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return apperrors.NewValidation("文件不存在或无法读取: " + absPath)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return apperrors.NewValidation("--file 不能是目录: " + absPath)
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
if fileSize <= 0 {
|
||||
return apperrors.NewValidation("文件为空")
|
||||
}
|
||||
|
||||
fileName, _ := cmd.Flags().GetString("file-name")
|
||||
if strings.TrimSpace(fileName) == "" {
|
||||
fileName = filepath.Base(absPath)
|
||||
}
|
||||
|
||||
spaceID, _ := cmd.Flags().GetString("space-id")
|
||||
mimeType, _ := cmd.Flags().GetString("mime-type")
|
||||
if strings.TrimSpace(mimeType) == "" {
|
||||
mimeType = detectMIME(fileName)
|
||||
}
|
||||
parentID, _ := cmd.Flags().GetString("folder")
|
||||
if err := validateDriveParentID(parentID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1 params
|
||||
step1Params := map[string]any{
|
||||
"fileName": fileName,
|
||||
"fileSize": float64(fileSize),
|
||||
}
|
||||
if strings.TrimSpace(spaceID) != "" {
|
||||
step1Params["spaceId"] = spaceID
|
||||
}
|
||||
if strings.TrimSpace(mimeType) != "" {
|
||||
step1Params["mimeType"] = mimeType
|
||||
}
|
||||
if strings.TrimSpace(parentID) != "" {
|
||||
step1Params["parentId"] = parentID
|
||||
}
|
||||
|
||||
if commandDryRun(cmd) {
|
||||
return writeCommandPayload(cmd, map[string]any{
|
||||
"dry_run": true,
|
||||
"step_1_get_upload_info": executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "drive", "get_upload_info", step1Params,
|
||||
),
|
||||
"step_2_http_put_oss": "PUT file bytes to resourceUrls[0].url with returned headers",
|
||||
"step_3_commit_upload": "drive commit_upload with uploadId from step 1",
|
||||
"file": absPath,
|
||||
"size": fileSize,
|
||||
"name": fileName,
|
||||
})
|
||||
}
|
||||
|
||||
// Step 1: get_upload_info
|
||||
fmt.Fprintf(os.Stderr, "[1/3] 获取上传凭证 %s (%d 字节, %s)...\n", fileName, fileSize, mimeType)
|
||||
step1 := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "drive", "get_upload_info", step1Params,
|
||||
)
|
||||
step1Result, err := runner.Run(cmd.Context(), step1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取上传凭证失败: %w", err)
|
||||
}
|
||||
|
||||
resourceURL, uploadID, ossHeaders, err := parseDriveUploadInfo(step1Result.Response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2: HTTP PUT to OSS
|
||||
fmt.Fprintln(os.Stderr, "[2/3] 上传文件到 OSS...")
|
||||
if err := httpPutDriveFile(cmd.Context(), resourceURL, ossHeaders, absPath, fileSize, mimeType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: commit_upload
|
||||
fmt.Fprintln(os.Stderr, "[3/3] 提交文件入库...")
|
||||
step3Params := map[string]any{
|
||||
"fileName": fileName,
|
||||
"fileSize": float64(fileSize),
|
||||
"uploadId": uploadID,
|
||||
}
|
||||
if strings.TrimSpace(spaceID) != "" {
|
||||
step3Params["spaceId"] = spaceID
|
||||
}
|
||||
if strings.TrimSpace(parentID) != "" {
|
||||
step3Params["parentId"] = parentID
|
||||
}
|
||||
step3 := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "drive", "commit_upload", step3Params,
|
||||
)
|
||||
result, err := runner.Run(cmd.Context(), step3)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交文件入库失败: %w", err)
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
// validateDriveParentID rejects pure-numeric IDs (which are dentryId values
|
||||
// from the chat link namespace, not drive's dentryUuid).
|
||||
func validateDriveParentID(parentID string) error {
|
||||
value := strings.TrimSpace(parentID)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"invalid drive --folder %q: pure numeric IDs are usually dentryId values from chat links, not drive dentryUuid; use a parent folder dentryUuid from drive list, or omit --folder to use the space root",
|
||||
parentID,
|
||||
))
|
||||
}
|
||||
|
||||
// parseDriveUploadInfo extracts resourceUrl / uploadId / OSS headers from the
|
||||
// drive.get_upload_info response. The actual server payload is:
|
||||
//
|
||||
// {
|
||||
// "uploadId": "...",
|
||||
// "resourceUrls": [
|
||||
// { "url": "https://...", "headers": { ... } }
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// MCP gateway may wrap the payload with a "content" or "result" envelope, so we
|
||||
// peel one layer if present, and also accept legacy flat resourceUrl/uploadUrl
|
||||
// fields as a fallback.
|
||||
func parseDriveUploadInfo(resp map[string]any) (resourceURL, uploadID string, headers map[string]string, err error) {
|
||||
if resp == nil {
|
||||
err = apperrors.NewValidation("get_upload_info 返回为空")
|
||||
return
|
||||
}
|
||||
data := resp
|
||||
if content, ok := data["content"].(map[string]any); ok && len(content) > 0 {
|
||||
data = content
|
||||
}
|
||||
if result, ok := data["result"].(map[string]any); ok && len(result) > 0 {
|
||||
data = result
|
||||
}
|
||||
|
||||
uploadID, _ = data["uploadId"].(string)
|
||||
|
||||
if urls, ok := data["resourceUrls"].([]any); ok && len(urls) > 0 {
|
||||
if first, ok := urls[0].(map[string]any); ok {
|
||||
resourceURL, _ = first["url"].(string)
|
||||
headers = make(map[string]string)
|
||||
if h, ok := first["headers"].(map[string]any); ok {
|
||||
for k, v := range h {
|
||||
if s, ok := v.(string); ok {
|
||||
headers[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if resourceURL == "" {
|
||||
resourceURL, _ = data["resourceUrl"].(string)
|
||||
}
|
||||
if resourceURL == "" {
|
||||
resourceURL, _ = data["uploadUrl"].(string)
|
||||
}
|
||||
|
||||
if resourceURL == "" || uploadID == "" {
|
||||
err = apperrors.NewValidation(fmt.Sprintf(
|
||||
"get_upload_info 返回不完整: resourceUrl=%q, uploadId=%q", resourceURL, uploadID,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
if headers == nil {
|
||||
headers = make(map[string]string)
|
||||
if h, ok := data["headers"].(map[string]any); ok {
|
||||
for k, v := range h {
|
||||
if s, ok := v.(string); ok {
|
||||
headers[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func httpPutDriveFile(ctx context.Context, resourceURL string, headers map[string]string, filePath string, fileSize int64, fallbackMIME string) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法打开文件: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, resourceURL, f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建 OSS 上传请求失败: %w", err)
|
||||
}
|
||||
req.ContentLength = fileSize
|
||||
hasContentType := false
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
if strings.EqualFold(k, "Content-Type") {
|
||||
hasContentType = true
|
||||
}
|
||||
}
|
||||
if !hasContentType && fallbackMIME != "" {
|
||||
req.Header.Set("Content-Type", fallbackMIME)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OSS 上传失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("OSS 上传失败 HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -283,7 +283,19 @@ type CLIFlagOverride struct {
|
||||
// name and Alias, and reserved names ("json", "params") are skipped.
|
||||
// When any alias is set by the user, the binding's Required check is
|
||||
// satisfied and the value is written to params[Property].
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
// MapsTo redirects this flag's final value into a different MCP parameter
|
||||
// slot. When empty (default), the value is written to params[propertyName]
|
||||
// as today. When set, params[MapsTo] receives the (possibly transformed)
|
||||
// value and params[propertyName] is NOT written. This is what lets a
|
||||
// sibling CLI flag — e.g. --content-file with transform: file_read — feed
|
||||
// the same MCP parameter (markdown) as the existing literal --content
|
||||
// flag, without forcing one flag to do double-duty.
|
||||
//
|
||||
// Pair with the existing CLIToolOverride.MutuallyExclusive (tool-level
|
||||
// cobra MarkFlagsMutuallyExclusive) when two sibling flags map to the
|
||||
// same MCP slot but should not be set together.
|
||||
MapsTo string `json:"mapsTo,omitempty"`
|
||||
Transform string `json:"transform,omitempty"`
|
||||
TransformArgs map[string]any `json:"transformArgs,omitempty"`
|
||||
EnvDefault string `json:"envDefault,omitempty"`
|
||||
@@ -635,18 +647,24 @@ func NormalizeServers(response ListResponse, source string) []ServerDescriptor {
|
||||
descriptor.UpdatedAt = updatedAt
|
||||
}
|
||||
|
||||
existing, exists := bestByEndpoint[descriptor.Key]
|
||||
// Dedup key includes cli.id when present so that envelopes
|
||||
// intentionally splitting one MCP endpoint into multiple CLI command
|
||||
// trees (e.g. bot-root / bot-message / bot-group all serving
|
||||
// .../server/4717... but exposing different command roots) are not
|
||||
// collapsed by endpoint-only dedup. Without cli.id the key falls
|
||||
// back to descriptor.Key (= endpoint) for backwards compatibility.
|
||||
endpointKey := dedupKeyForEndpoint(descriptor)
|
||||
existing, exists := bestByEndpoint[endpointKey]
|
||||
if !exists || descriptorIsNewer(descriptor, existing) {
|
||||
bestByEndpoint[descriptor.Key] = descriptor
|
||||
bestByEndpoint[endpointKey] = descriptor
|
||||
}
|
||||
}
|
||||
|
||||
bestByName := make(map[string]ServerDescriptor)
|
||||
for _, descriptor := range bestByEndpoint {
|
||||
nameKey := normalizeDisplayNameKey(descriptor.DisplayName)
|
||||
if nameKey == "" {
|
||||
nameKey = descriptor.Key
|
||||
}
|
||||
// Same reasoning as endpoint dedup: append cli.id so three bot-*
|
||||
// entries with displayName "机器人消息" don't collapse into one.
|
||||
nameKey := dedupKeyForName(descriptor)
|
||||
existing, exists := bestByName[nameKey]
|
||||
if !exists || descriptorIsNewer(descriptor, existing) {
|
||||
bestByName[nameKey] = descriptor
|
||||
@@ -692,6 +710,33 @@ func normalizeDisplayNameKey(displayName string) string {
|
||||
return strings.ToLower(strings.TrimSpace(displayName))
|
||||
}
|
||||
|
||||
// dedupKeyForEndpoint returns the dedup key used when collapsing multiple
|
||||
// envelope entries that share an MCP endpoint. cli.id is appended so an
|
||||
// endpoint intentionally fronting multiple CLI command roots (bot-root /
|
||||
// bot-message / bot-group all served by the same MCP server) stays as
|
||||
// distinct descriptors. When cli.id is empty (or absent), the key is the
|
||||
// endpoint alone to preserve historical dedup behaviour.
|
||||
func dedupKeyForEndpoint(descriptor ServerDescriptor) string {
|
||||
if cliID := strings.TrimSpace(descriptor.CLI.ID); cliID != "" {
|
||||
return descriptor.Key + "#" + cliID
|
||||
}
|
||||
return descriptor.Key
|
||||
}
|
||||
|
||||
// dedupKeyForName mirrors dedupKeyForEndpoint for the second-pass name-based
|
||||
// dedup so two envelopes with the same displayName but distinct cli.id (the
|
||||
// bot-* trio shares displayName "机器人消息") remain separate.
|
||||
func dedupKeyForName(descriptor ServerDescriptor) string {
|
||||
nameKey := normalizeDisplayNameKey(descriptor.DisplayName)
|
||||
if nameKey == "" {
|
||||
nameKey = descriptor.Key
|
||||
}
|
||||
if cliID := strings.TrimSpace(descriptor.CLI.ID); cliID != "" {
|
||||
return nameKey + "#" + cliID
|
||||
}
|
||||
return nameKey
|
||||
}
|
||||
|
||||
func markDeprecatedCandidate(displayName string, lifecycle LifecycleInfo) LifecycleInfo {
|
||||
if lifecycle.DeprecatedCandidate {
|
||||
return lifecycle
|
||||
|
||||
@@ -101,6 +101,65 @@ func TestNormalizeServersDeduplicatesSameNameAcrossEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint guards the
|
||||
// bot-root / bot-message / bot-group split (issue: chat bot subtree vanished
|
||||
// from the CLI after NormalizeServers collapsed three envelopes that share a
|
||||
// single MCP endpoint and displayName but expose different cli.id values).
|
||||
func TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sharedURL := "https://example.com/server/4717"
|
||||
sharedName := "机器人消息"
|
||||
response := ListResponse{
|
||||
Servers: []ServerEnvelope{
|
||||
{
|
||||
Server: RegistryServer{
|
||||
Name: sharedName,
|
||||
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
|
||||
},
|
||||
Meta: EnvelopeMeta{
|
||||
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
|
||||
CLI: CLIOverlay{ID: "bot-root", Command: "bot"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Server: RegistryServer{
|
||||
Name: sharedName,
|
||||
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
|
||||
},
|
||||
Meta: EnvelopeMeta{
|
||||
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
|
||||
CLI: CLIOverlay{ID: "bot-message", Command: "message"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Server: RegistryServer{
|
||||
Name: sharedName,
|
||||
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
|
||||
},
|
||||
Meta: EnvelopeMeta{
|
||||
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
|
||||
CLI: CLIOverlay{ID: "bot-group", Command: "group"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
servers := NormalizeServers(response, "live_market")
|
||||
if len(servers) != 3 {
|
||||
t.Fatalf("NormalizeServers() len = %d, want 3 (one per cli.id)", len(servers))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, s := range servers {
|
||||
seen[s.CLI.ID] = true
|
||||
}
|
||||
for _, want := range []string{"bot-root", "bot-message", "bot-group"} {
|
||||
if !seen[want] {
|
||||
t.Errorf("NormalizeServers() missing descriptor with cli.id %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeServersMarksLegacyNameAsDeprecatedCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+25
-10
@@ -79,6 +79,12 @@ func RunPreParse(root *cobra.Command, engine *Engine) {
|
||||
|
||||
// FlagInfoFromCommand extracts FlagInfo entries from a Cobra
|
||||
// command's registered flags (both local and inherited).
|
||||
//
|
||||
// JSON Schema "format" and "enum" hints injected via pflag
|
||||
// annotations (x-cli-format / x-cli-enum, see
|
||||
// internal/compat/dynamic_commands.go) are surfaced on FlagInfo
|
||||
// so PreParse handlers can validate sticky-split candidates against
|
||||
// the actual schema, not just the pflag type.
|
||||
func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
@@ -92,11 +98,7 @@ func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
|
||||
return
|
||||
}
|
||||
seen[f.Name] = true
|
||||
infos = append(infos, FlagInfo{
|
||||
Name: f.Name,
|
||||
PropertyName: f.Name,
|
||||
Type: f.Value.Type(),
|
||||
})
|
||||
infos = append(infos, flagInfoFromPflag(f))
|
||||
})
|
||||
|
||||
cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
|
||||
@@ -104,12 +106,25 @@ func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
|
||||
return
|
||||
}
|
||||
seen[f.Name] = true
|
||||
infos = append(infos, FlagInfo{
|
||||
Name: f.Name,
|
||||
PropertyName: f.Name,
|
||||
Type: f.Value.Type(),
|
||||
})
|
||||
infos = append(infos, flagInfoFromPflag(f))
|
||||
})
|
||||
|
||||
return infos
|
||||
}
|
||||
|
||||
// flagInfoFromPflag builds a FlagInfo from a pflag.Flag, copying
|
||||
// schema metadata stashed in the flag's annotations map.
|
||||
func flagInfoFromPflag(f *pflag.Flag) FlagInfo {
|
||||
fi := FlagInfo{
|
||||
Name: f.Name,
|
||||
PropertyName: f.Name,
|
||||
Type: f.Value.Type(),
|
||||
}
|
||||
if v := f.Annotations["x-cli-format"]; len(v) > 0 {
|
||||
fi.Format = v[0]
|
||||
}
|
||||
if v := f.Annotations["x-cli-enum"]; len(v) > 0 {
|
||||
fi.Enum = append([]string{}, v...)
|
||||
}
|
||||
return fi
|
||||
}
|
||||
|
||||
@@ -32,76 +32,101 @@ func TestFullPreParsePipeline(t *testing.T) {
|
||||
ParamNameHandler{},
|
||||
)
|
||||
|
||||
// Numeric / boolean flag typing matters for the sticky guard. The
|
||||
// helper below declares known flags as int so digit-led suffixes
|
||||
// pass the suffixLooksLikeValue check.
|
||||
intSpecs := func(names ...string) []pipeline.FlagInfo {
|
||||
out := make([]pipeline.FlagInfo, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = pipeline.FlagInfo{Name: n, Type: "int"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
flags []string
|
||||
flags []pipeline.FlagInfo
|
||||
want string
|
||||
corrections int
|
||||
}{
|
||||
{
|
||||
name: "camelCase + sticky combined",
|
||||
args: []string{"--userId", "123", "--pageSize50"},
|
||||
flags: []string{"user-id", "page-size"},
|
||||
flags: intSpecs("user-id", "page-size"),
|
||||
want: "--user-id 123 --page-size 50",
|
||||
corrections: 2, // alias(userId) + sticky(pageSize50)
|
||||
},
|
||||
{
|
||||
name: "camelCase + typo combined",
|
||||
args: []string{"--userId", "123", "--limt", "10"},
|
||||
flags: []string{"user-id", "limit"},
|
||||
flags: intSpecs("user-id", "limit"),
|
||||
want: "--user-id 123 --limit 10",
|
||||
corrections: 2, // alias(userId) + fuzzy(limt)
|
||||
},
|
||||
{
|
||||
name: "triple error: case + sticky + typo",
|
||||
args: []string{"--UserName", "alice", "--limit100", "--offse", "0"},
|
||||
flags: []string{"user-name", "limit", "offset"},
|
||||
flags: append(flagSpecs("user-name"), intSpecs("limit", "offset")...),
|
||||
want: "--user-name alice --limit 100 --offset 0",
|
||||
corrections: 3,
|
||||
},
|
||||
{
|
||||
name: "snake_case + sticky",
|
||||
args: []string{"--user_id", "42", "--pageSize20"},
|
||||
flags: []string{"user-id", "page-size"},
|
||||
flags: intSpecs("user-id", "page-size"),
|
||||
want: "--user-id 42 --page-size 20",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "all correct — zero corrections",
|
||||
args: []string{"--user-id", "123", "--limit", "10"},
|
||||
flags: []string{"user-id", "limit"},
|
||||
flags: intSpecs("user-id", "limit"),
|
||||
want: "--user-id 123 --limit 10",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "UPPER case flags",
|
||||
args: []string{"--USER-ID", "999"},
|
||||
flags: []string{"user-id"},
|
||||
flags: intSpecs("user-id"),
|
||||
want: "--user-id 999",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "= syntax with camelCase",
|
||||
args: []string{"--userId=123", "--pageSize=50"},
|
||||
flags: []string{"user-id", "page-size"},
|
||||
flags: intSpecs("user-id", "page-size"),
|
||||
want: "--user-id=123 --page-size=50",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "camelCase sticky split with normalisation",
|
||||
args: []string{"--limitValue100"},
|
||||
flags: []string{"limit-value"},
|
||||
flags: intSpecs("limit-value"),
|
||||
want: "--limit-value 100",
|
||||
corrections: 1, // sticky handles both kebab-normalisation and split
|
||||
},
|
||||
|
||||
// Hardening: a mistyped flag whose name happens to start with
|
||||
// a real flag must NOT be split. The pipeline should leave the
|
||||
// token untouched so Cobra can raise "unknown flag".
|
||||
{
|
||||
name: "typo --starttime1 not split (date-time format)",
|
||||
args: []string{"--starttime1", "2026-02-07"},
|
||||
flags: []pipeline.FlagInfo{
|
||||
{Name: "start", Type: "string", Format: "date-time"},
|
||||
{Name: "end", Type: "string", Format: "date-time"},
|
||||
},
|
||||
want: "--starttime1 2026-02-07",
|
||||
corrections: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Args: append([]string{}, tt.args...),
|
||||
FlagSpecs: flagSpecs(tt.flags...),
|
||||
FlagSpecs: tt.flags,
|
||||
}
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("RunPhase error: %v", err)
|
||||
@@ -191,7 +216,11 @@ func TestFullPipelineEndToEnd(t *testing.T) {
|
||||
"--pageSize50",
|
||||
"--verbosetrue",
|
||||
},
|
||||
FlagSpecs: flagSpecs("user-id", "page-size", "verbose"),
|
||||
FlagSpecs: []pipeline.FlagInfo{
|
||||
{Name: "user-id", Type: "string"},
|
||||
{Name: "page-size", Type: "int"},
|
||||
{Name: "verbose", Type: "bool"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
@@ -287,7 +316,11 @@ func TestFullFivePhasePipeline(t *testing.T) {
|
||||
"--pageSize50",
|
||||
"--verbosetrue",
|
||||
}
|
||||
ctx.FlagSpecs = flagSpecs("user-id", "page-size", "verbose")
|
||||
ctx.FlagSpecs = []pipeline.FlagInfo{
|
||||
{Name: "user-id", Type: "string"},
|
||||
{Name: "page-size", Type: "int"},
|
||||
{Name: "verbose", Type: "bool"},
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("PreParse error: %v", err)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// StickyHandler detects glued flag-value pairs in raw argv and splits
|
||||
@@ -25,7 +26,12 @@ import (
|
||||
//
|
||||
// The handler only operates on tokens that start with "--" and do not
|
||||
// contain "=". It tries to match the longest known flag name prefix
|
||||
// and, if the remaining suffix is non-empty, splits the token.
|
||||
// and, if the remaining suffix is non-empty AND looks like a plausible
|
||||
// value for the matched flag's type/format, splits the token. The
|
||||
// type/format guard prevents misinterpreting mistyped flag names like
|
||||
// "--starttime1" as "--start time1" — when the suffix does not look
|
||||
// like a value, the original token is left untouched so Cobra can
|
||||
// report "unknown flag".
|
||||
type StickyHandler struct{}
|
||||
|
||||
func (StickyHandler) Name() string { return "sticky" }
|
||||
@@ -36,11 +42,11 @@ func (StickyHandler) Handle(ctx *pipeline.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
known := buildFlagSet(ctx.FlagSpecs)
|
||||
specByName := buildFlagSpecIndex(ctx.FlagSpecs)
|
||||
result := make([]string, 0, len(ctx.Args))
|
||||
|
||||
for _, arg := range ctx.Args {
|
||||
split, ok := trySplitSticky(arg, known)
|
||||
split, ok := trySplitSticky(arg, specByName)
|
||||
if ok {
|
||||
ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky")
|
||||
result = append(result, split.flag, split.value)
|
||||
@@ -65,12 +71,14 @@ type stickyPair struct {
|
||||
// - the prefix matches a known flag name (directly or after
|
||||
// kebab-case normalisation)
|
||||
// - the remaining suffix is non-empty
|
||||
// - the suffix looks like a plausible value for the matched flag's
|
||||
// declared type/format/enum (suffixLooksLikeValue)
|
||||
//
|
||||
// When multiple flag names match as prefixes, the longest one wins.
|
||||
// The handler also tries kebab-case normalisation of each prefix so
|
||||
// that camelCase+glued values like "--pageSize50" are correctly
|
||||
// split to "--page-size", "50".
|
||||
func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
|
||||
func trySplitSticky(arg string, specByName map[string]pipeline.FlagInfo) (stickyPair, bool) {
|
||||
if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
@@ -83,7 +91,10 @@ func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
|
||||
|
||||
// If the whole token is a known flag, it is not sticky — it is
|
||||
// a normal flag expecting a separate value token.
|
||||
if known[bare] || known[toKebabCase(bare)] {
|
||||
if _, ok := specByName[bare]; ok {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
if _, ok := specByName[toKebabCase(bare)]; ok {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
@@ -96,12 +107,14 @@ func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
|
||||
prefix := bare[:i]
|
||||
|
||||
matchedFlag := ""
|
||||
if known[prefix] {
|
||||
if _, ok := specByName[prefix]; ok {
|
||||
matchedFlag = prefix
|
||||
} else {
|
||||
kebab := toKebabCase(prefix)
|
||||
if kebab != "" && known[kebab] {
|
||||
matchedFlag = kebab
|
||||
if kebab != "" {
|
||||
if _, ok := specByName[kebab]; ok {
|
||||
matchedFlag = kebab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,14 +133,38 @@ func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
// Guard: only split if the suffix plausibly looks like a value
|
||||
// for this flag's declared type/format/enum. Otherwise leave the
|
||||
// token untouched so Cobra reports "unknown flag" instead of
|
||||
// silently corrupting the value.
|
||||
fi := specByName[bestFlag]
|
||||
if !cmdutil.SuffixLooksLikeValue(suffix, fi.Type, fi.Format, fi.Enum) {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
return stickyPair{
|
||||
flag: "--" + bestFlag,
|
||||
value: suffix,
|
||||
}, true
|
||||
}
|
||||
|
||||
// buildFlagSpecIndex creates an index of known flag names (without "--"
|
||||
// prefix) to their FlagInfo entries from the context's FlagSpecs.
|
||||
func buildFlagSpecIndex(specs []pipeline.FlagInfo) map[string]pipeline.FlagInfo {
|
||||
m := make(map[string]pipeline.FlagInfo, len(specs))
|
||||
for _, spec := range specs {
|
||||
if spec.Name != "" {
|
||||
m[spec.Name] = spec
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// buildFlagSet creates a set of known flag names (without "--" prefix)
|
||||
// from the context's FlagSpecs.
|
||||
//
|
||||
// Retained for other PreParse handlers (alias, paramname) that only
|
||||
// need name presence and do not consume the richer FlagInfo metadata.
|
||||
func buildFlagSet(specs []pipeline.FlagInfo) map[string]bool {
|
||||
m := make(map[string]bool, len(specs))
|
||||
for _, spec := range specs {
|
||||
|
||||
@@ -20,6 +20,12 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// flagSpecs is the legacy helper used by alias/paramname tests. It
|
||||
// produces string-typed FlagInfo entries which is fine for those
|
||||
// handlers because they don't consult the type.
|
||||
//
|
||||
// Sticky tests should use flagSpecsTyped (below) — the post-hardening
|
||||
// sticky decision depends on Type/Format/Enum.
|
||||
func flagSpecs(names ...string) []pipeline.FlagInfo {
|
||||
specs := make([]pipeline.FlagInfo, len(names))
|
||||
for i, name := range names {
|
||||
@@ -28,126 +34,254 @@ func flagSpecs(names ...string) []pipeline.FlagInfo {
|
||||
return specs
|
||||
}
|
||||
|
||||
// flagSpec is a compact builder used by sticky tests to declare typed
|
||||
// flags inline. Type defaults to "string" when unset.
|
||||
type flagSpec struct {
|
||||
name string
|
||||
typ string
|
||||
format string
|
||||
enum []string
|
||||
}
|
||||
|
||||
func specs(in ...flagSpec) []pipeline.FlagInfo {
|
||||
out := make([]pipeline.FlagInfo, len(in))
|
||||
for i, s := range in {
|
||||
t := s.typ
|
||||
if t == "" {
|
||||
t = "string"
|
||||
}
|
||||
out[i] = pipeline.FlagInfo{
|
||||
Name: s.name,
|
||||
Type: t,
|
||||
Format: s.format,
|
||||
Enum: s.enum,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestStickyHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
flags []string
|
||||
flags []pipeline.FlagInfo
|
||||
want string
|
||||
corrections int
|
||||
}{
|
||||
{
|
||||
name: "basic split --limit100",
|
||||
name: "basic split --limit100 (int)",
|
||||
args: []string{"--limit100"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--limit 100",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "no split when flag takes value separately",
|
||||
args: []string{"--limit", "100"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--limit 100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "no split when = syntax used",
|
||||
args: []string{"--limit=100"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--limit=100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "no split when flag name is not known",
|
||||
args: []string{"--unknown100"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--unknown100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "split with string value",
|
||||
args: []string{"--nameJohn"},
|
||||
flags: []string{"name"},
|
||||
want: "--name John",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "longest prefix wins",
|
||||
args: []string{"--user-id123"},
|
||||
flags: []string{"user", "user-id"},
|
||||
flags: specs(flagSpec{name: "user"}, flagSpec{name: "user-id", typ: "int"}),
|
||||
want: "--user-id 123",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple sticky args in one invocation",
|
||||
args: []string{"--limit100", "--offset50"},
|
||||
flags: []string{"limit", "offset"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}, flagSpec{name: "offset", typ: "int"}),
|
||||
want: "--limit 100 --offset 50",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "mixed sticky and normal args",
|
||||
args: []string{"--limit100", "--name", "test", "--offset50"},
|
||||
flags: []string{"limit", "name", "offset"},
|
||||
name: "mixed sticky and normal args",
|
||||
args: []string{"--limit100", "--name", "test", "--offset50"},
|
||||
flags: specs(
|
||||
flagSpec{name: "limit", typ: "int"},
|
||||
flagSpec{name: "name"},
|
||||
flagSpec{name: "offset", typ: "int"},
|
||||
),
|
||||
want: "--limit 100 --name test --offset 50",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "single dash prefix is ignored",
|
||||
args: []string{"-l100"},
|
||||
flags: []string{"l"},
|
||||
flags: specs(flagSpec{name: "l", typ: "int"}),
|
||||
want: "-l100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "empty args",
|
||||
args: []string{},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "bare double dash",
|
||||
args: []string{"--"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "no flag specs available",
|
||||
args: []string{"--limit100"},
|
||||
flags: []string{},
|
||||
flags: nil,
|
||||
want: "--limit100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "exact flag name is not split",
|
||||
args: []string{"--limit"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--limit",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "boolean-like value still splits",
|
||||
name: "boolean-like value splits when type is bool",
|
||||
args: []string{"--verbosetrue"},
|
||||
flags: []string{"verbose"},
|
||||
flags: specs(flagSpec{name: "verbose", typ: "bool"}),
|
||||
want: "--verbose true",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "hyphenated flag name with numeric suffix",
|
||||
args: []string{"--page-size50"},
|
||||
flags: []string{"page-size", "page"},
|
||||
flags: specs(flagSpec{name: "page-size", typ: "int"}, flagSpec{name: "page", typ: "int"}),
|
||||
want: "--page-size 50",
|
||||
corrections: 1,
|
||||
},
|
||||
|
||||
// --- Hardening cases: typo flags MUST NOT be misinterpreted ---
|
||||
{
|
||||
name: "typo --starttime1 not split (date-time format)",
|
||||
args: []string{"--starttime1", "2026-02-07"},
|
||||
flags: specs(flagSpec{
|
||||
name: "start", typ: "string", format: "date-time",
|
||||
}),
|
||||
want: "--starttime1 2026-02-07",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "glued ISO date splits cleanly (date-time format)",
|
||||
args: []string{"--start2026-02-07"},
|
||||
flags: specs(flagSpec{
|
||||
name: "start", typ: "string", format: "date-time",
|
||||
}),
|
||||
want: "--start 2026-02-07",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "int flag rejects alpha suffix",
|
||||
args: []string{"--limitabc"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--limitabc",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "bool flag rejects non-literal suffix",
|
||||
args: []string{"--verbosehello"},
|
||||
flags: specs(flagSpec{name: "verbose", typ: "bool"}),
|
||||
want: "--verbosehello",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "string + enum: suffix hits enum splits",
|
||||
args: []string{"--statusapproved"},
|
||||
flags: specs(flagSpec{
|
||||
name: "status", typ: "string", enum: []string{"approved", "pending"},
|
||||
}),
|
||||
want: "--status approved",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "string + enum: suffix not in enum refuses split",
|
||||
args: []string{"--statusunknown"},
|
||||
flags: specs(flagSpec{
|
||||
name: "status", typ: "string", enum: []string{"approved", "pending"},
|
||||
}),
|
||||
want: "--statusunknown",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "email format: suffix containing @ splits",
|
||||
args: []string{"--emailfoo@bar.com"},
|
||||
flags: specs(flagSpec{
|
||||
name: "email", typ: "string", format: "email",
|
||||
}),
|
||||
want: "--email foo@bar.com",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "email format: suffix without @ refuses split",
|
||||
args: []string{"--emailalice"},
|
||||
flags: specs(flagSpec{
|
||||
name: "email", typ: "string", format: "email",
|
||||
}),
|
||||
want: "--emailalice",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "stringSlice: refuses to split (ambiguous)",
|
||||
args: []string{"--tagsfoo,bar"},
|
||||
flags: specs(flagSpec{name: "tags", typ: "stringSlice"}),
|
||||
want: "--tagsfoo,bar",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "plain string + no metadata: alpha suffix refuses split",
|
||||
args: []string{"--nameJohn"},
|
||||
flags: specs(flagSpec{name: "name"}),
|
||||
want: "--nameJohn",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "plain string + no metadata: digit-led suffix splits",
|
||||
args: []string{"--name123"},
|
||||
flags: specs(flagSpec{name: "name"}),
|
||||
want: "--name 123",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "duration type: digit-led suffix splits",
|
||||
args: []string{"--timeout30s"},
|
||||
flags: specs(flagSpec{name: "timeout", typ: "duration"}),
|
||||
want: "--timeout 30s",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "duration type: alpha suffix refuses split",
|
||||
args: []string{"--timeoutever"},
|
||||
flags: specs(flagSpec{name: "timeout", typ: "duration"}),
|
||||
want: "--timeoutever",
|
||||
corrections: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Args: append([]string{}, tt.args...),
|
||||
FlagSpecs: flagSpecs(tt.flags...),
|
||||
FlagSpecs: tt.flags,
|
||||
}
|
||||
h := StickyHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
|
||||
@@ -120,8 +120,20 @@ type FlagInfo struct {
|
||||
// PropertyName is the original schema property key (e.g. "userId").
|
||||
PropertyName string
|
||||
|
||||
// Type is the JSON Schema type ("string", "integer", etc.).
|
||||
// Type is the JSON Schema / pflag type ("string", "integer",
|
||||
// "bool", "stringSlice", "duration", etc.).
|
||||
Type string
|
||||
|
||||
// Format carries the JSON Schema "format" hint when present —
|
||||
// e.g. "date", "date-time", "duration", "email", "uri", "ipv4".
|
||||
// PreParse handlers use this to decide whether a suffix in a
|
||||
// glued token (e.g. "--starttime1") looks like a plausible value.
|
||||
Format string
|
||||
|
||||
// Enum carries the JSON Schema "enum" string values when present.
|
||||
// PreParse handlers use this for sticky-split validation: a glued
|
||||
// suffix is accepted only if it matches one of the enum entries.
|
||||
Enum []string
|
||||
}
|
||||
|
||||
// Correction records a single input correction applied by a handler.
|
||||
|
||||
@@ -106,6 +106,7 @@ func (s *StdioClient) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Stop kills the subprocess and waits for it to exit.
|
||||
// A non-zero exit code after Kill is expected and not treated as an error.
|
||||
func (s *StdioClient) Stop() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -118,6 +119,9 @@ func (s *StdioClient) Stop() error {
|
||||
|
||||
if s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
_ = s.cmd.Wait()
|
||||
s.started = false
|
||||
return nil
|
||||
}
|
||||
err := s.cmd.Wait()
|
||||
s.started = false
|
||||
|
||||
@@ -97,10 +97,9 @@ func TestStdioClientEndToEnd(t *testing.T) {
|
||||
t.Error("CallTool with unknown tool should return error")
|
||||
}
|
||||
|
||||
// Stop
|
||||
// Stop — after kill, Stop should return nil (non-zero exit is suppressed)
|
||||
if err := client.Stop(); err != nil {
|
||||
// Process killed, expected to return an error
|
||||
_ = err
|
||||
t.Errorf("Stop after kill should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+78
-6
@@ -15,6 +15,7 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -75,6 +76,41 @@ func DetectNumericTypeError(err error) (flagName, badValue string, ok bool) {
|
||||
return rest[:endIdx], badVal, true
|
||||
}
|
||||
|
||||
// flagFixCandidate reports whether f should participate in unknown-flag
|
||||
// suggestion candidates and Flags: listings. Hidden flags (e.g. wukong's
|
||||
// MarkHidden compatibility aliases) and internal json/params merge flags
|
||||
// are skipped so the hint candidate set stays a subset of what --help shows.
|
||||
func flagFixCandidate(f *pflag.Flag) bool {
|
||||
if f == nil || f.Hidden {
|
||||
return false
|
||||
}
|
||||
switch f.Name {
|
||||
case "json", "params":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// VisibleFlagNames returns sorted candidate flag names for cmd.Flags()
|
||||
// using flagFixCandidate. Intended for agent-facing error recovery
|
||||
// (available_flags).
|
||||
func VisibleFlagNames(cmd *cobra.Command) []string {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var names []string
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !flagFixCandidate(f) || seen[f.Name] {
|
||||
return
|
||||
}
|
||||
seen[f.Name] = true
|
||||
names = append(names, f.Name)
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// SuggestFlagFix detects flag-value concatenation errors, common flag aliases,
|
||||
// and Levenshtein-close typos.
|
||||
func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
|
||||
@@ -92,6 +128,9 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
|
||||
|
||||
var bestFlag, bestValue string
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !flagFixCandidate(f) {
|
||||
return
|
||||
}
|
||||
name := f.Name
|
||||
if strings.HasPrefix(body, name) && len(body) > len(name) {
|
||||
if len(name) > len(bestFlag) {
|
||||
@@ -101,16 +140,28 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
|
||||
}
|
||||
})
|
||||
if bestFlag != "" {
|
||||
canAutoFix := len(bestValue) > 0
|
||||
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
|
||||
if canAutoFix {
|
||||
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
|
||||
lf := cmd.Flags().Lookup(bestFlag)
|
||||
if lf != nil {
|
||||
fmtStr := ""
|
||||
if v := lf.Annotations["x-cli-format"]; len(v) > 0 {
|
||||
fmtStr = v[0]
|
||||
}
|
||||
var enumCopy []string
|
||||
if v := lf.Annotations["x-cli-enum"]; len(v) > 0 {
|
||||
enumCopy = append([]string{}, v...)
|
||||
}
|
||||
if SuffixLooksLikeValue(bestValue, lf.Value.Type(), fmtStr, enumCopy) {
|
||||
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
|
||||
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
|
||||
}
|
||||
}
|
||||
return FlagFixResult{Suggestion: suggestion}
|
||||
}
|
||||
|
||||
bestName, bestDist := "", 999
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !flagFixCandidate(f) {
|
||||
return
|
||||
}
|
||||
d := LevenshteinDist(body, f.Name)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
@@ -119,12 +170,33 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
|
||||
})
|
||||
threshold := LevenshteinThreshold(len(body))
|
||||
if bestDist > 0 && bestDist <= threshold && bestName != "" {
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?", bestName)}
|
||||
suf := formatFlagHintSuffix(cmd.Flags().Lookup(bestName))
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?%s", bestName, suf)}
|
||||
}
|
||||
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Run '%s --help' to see available options", cmd.CommandPath())}
|
||||
}
|
||||
|
||||
func formatFlagHintSuffix(f *pflag.Flag) string {
|
||||
if f == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if u := strings.TrimSpace(f.Usage); u != "" {
|
||||
if len(u) > 100 {
|
||||
u = u[:97] + "..."
|
||||
}
|
||||
parts = append(parts, u)
|
||||
}
|
||||
if v := f.Annotations["x-cli-format"]; len(v) > 0 && v[0] != "" {
|
||||
parts = append(parts, "format="+v[0])
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " (" + strings.Join(parts, ", ") + ")"
|
||||
}
|
||||
|
||||
// LevenshteinThreshold returns the max edit distance allowed based on string length.
|
||||
func LevenshteinThreshold(nameLen int) int {
|
||||
if nameLen <= 3 {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSuggestFlagFix_falseGlue_starttime1(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
|
||||
err := errUnknownFlag("starttime1")
|
||||
fix := SuggestFlagFix(cmd, err)
|
||||
if strings.Contains(fix.Suggestion, "Space required") {
|
||||
t.Fatalf("should not treat as glued value, got %q", fix.Suggestion)
|
||||
}
|
||||
if fix.AutoFixFlag != "" {
|
||||
t.Fatalf("AutoFixFlag = %q, want empty", fix.AutoFixFlag)
|
||||
}
|
||||
if !strings.Contains(fix.Suggestion, "--help") {
|
||||
t.Fatalf("expected help fallback, got %q", fix.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestFlagFix_trueGlue_isoDateSuffix(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
|
||||
err := errUnknownFlag("start2026-02-07")
|
||||
fix := SuggestFlagFix(cmd, err)
|
||||
wantSub := "Space required between flag and value: --start 2026-02-07"
|
||||
if fix.Suggestion != wantSub {
|
||||
t.Fatalf("Suggestion = %q, want %q", fix.Suggestion, wantSub)
|
||||
}
|
||||
if fix.AutoFixFlag != "start" || fix.AutoFixValue != "2026-02-07" {
|
||||
t.Fatalf("AutoFix = %q/%q, want start/2026-02-07", fix.AutoFixFlag, fix.AutoFixValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestFlagFix_levenshteinAddsUsage(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "send"}
|
||||
cmd.Flags().String("conversation-id", "", "Conversation id")
|
||||
|
||||
err := errUnknownFlag("conversaton-id")
|
||||
fix := SuggestFlagFix(cmd, err)
|
||||
if !strings.HasPrefix(fix.Suggestion, "Did you mean --conversation-id?") {
|
||||
t.Fatalf("unexpected: %q", fix.Suggestion)
|
||||
}
|
||||
if !strings.Contains(fix.Suggestion, "Conversation id") {
|
||||
t.Fatalf("expected usage in hint, got %q", fix.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestFlagFix_skipsHiddenAlias(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
cmd.Flags().String("start-time", "", "")
|
||||
_ = cmd.Flags().MarkHidden("start-time")
|
||||
|
||||
fix := SuggestFlagFix(cmd, errUnknownFlag("starttime1"))
|
||||
if strings.Contains(fix.Suggestion, "start-time") {
|
||||
t.Fatalf("must not recommend hidden alias, got %q", fix.Suggestion)
|
||||
}
|
||||
if !strings.Contains(fix.Suggestion, "--help") {
|
||||
t.Fatalf("expected help fallback, got %q", fix.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleFlagNames_skipsHiddenAndInternal(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("alpha", "", "")
|
||||
cmd.Flags().String("json", "", "")
|
||||
cmd.Flags().String("beta", "", "")
|
||||
_ = cmd.Flags().MarkHidden("beta")
|
||||
|
||||
names := VisibleFlagNames(cmd)
|
||||
if len(names) != 1 || names[0] != "alpha" {
|
||||
t.Fatalf("got %v, want [alpha]", names)
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownFlag(body string) error {
|
||||
return &stubFlagErr{msg: "unknown flag: --" + body}
|
||||
}
|
||||
|
||||
type stubFlagErr struct{ msg string }
|
||||
|
||||
func (e *stubFlagErr) Error() string { return e.msg }
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// SuffixLooksLikeValue decides whether a candidate suffix from a glued
|
||||
// "--flagsuffix" token plausibly represents a value for a flag's
|
||||
// declared type/format/enum. Shared by StickyHandler (PreParse) and
|
||||
// SuggestFlagFix (unknown-flag recovery).
|
||||
//
|
||||
// typ is a pflag value type string (e.g. "int", "bool", "string");
|
||||
// format is JSON Schema "format" when present (e.g. "date-time");
|
||||
// enum is the schema enum list when present.
|
||||
func SuffixLooksLikeValue(suffix, typ, format string, enum []string) bool {
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
switch typ {
|
||||
case "int", "int8", "int16", "int32", "int64",
|
||||
"uint", "uint8", "uint16", "uint32", "uint64",
|
||||
"integer", "number", "float", "float32", "float64",
|
||||
"count":
|
||||
return startsWithNumericSuffix(suffix)
|
||||
|
||||
case "bool", "boolean":
|
||||
return isBoolLiteralSuffix(suffix)
|
||||
|
||||
case "duration":
|
||||
return startsWithDigitSuffix(suffix) || startsWithSignSuffix(suffix)
|
||||
|
||||
case "stringSlice", "stringArray", "intSlice", "boolSlice", "float32Slice",
|
||||
"float64Slice", "uintSlice", "durationSlice", "ipSlice", "array":
|
||||
return false
|
||||
|
||||
case "object":
|
||||
return false
|
||||
}
|
||||
|
||||
if len(enum) > 0 {
|
||||
return matchesEnumSuffix(suffix, enum)
|
||||
}
|
||||
|
||||
switch strings.ToLower(format) {
|
||||
case "date", "date-time", "datetime", "time":
|
||||
return startsWithDigitSuffix(suffix)
|
||||
case "duration":
|
||||
return startsWithDigitSuffix(suffix) || startsWithSignSuffix(suffix)
|
||||
case "email":
|
||||
return strings.Contains(suffix, "@")
|
||||
case "uri", "url":
|
||||
lower := strings.ToLower(suffix)
|
||||
return strings.HasPrefix(lower, "http") ||
|
||||
strings.HasPrefix(lower, "ftp") ||
|
||||
strings.HasPrefix(lower, "mailto:")
|
||||
case "ipv4", "ipv6", "hostname":
|
||||
return startsWithDigitSuffix(suffix)
|
||||
case "uuid":
|
||||
first, _ := utf8.DecodeRuneInString(suffix)
|
||||
return isHexRuneSuffix(first)
|
||||
}
|
||||
|
||||
first, _ := utf8.DecodeRuneInString(suffix)
|
||||
if first == utf8.RuneError || unicode.IsLetter(first) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func startsWithDigitSuffix(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
c := s[0]
|
||||
return c >= '0' && c <= '9'
|
||||
}
|
||||
|
||||
func startsWithSignSuffix(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
c := s[0]
|
||||
return c == '+' || c == '-'
|
||||
}
|
||||
|
||||
func startsWithNumericSuffix(s string) bool {
|
||||
if startsWithDigitSuffix(s) {
|
||||
return true
|
||||
}
|
||||
if startsWithSignSuffix(s) && len(s) > 1 {
|
||||
c := s[1]
|
||||
return c >= '0' && c <= '9'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBoolLiteralSuffix(s string) bool {
|
||||
switch strings.ToLower(s) {
|
||||
case "true", "false", "1", "0", "t", "f", "yes", "no", "on", "off", "y", "n":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesEnumSuffix(s string, enum []string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, e := range enum {
|
||||
if strings.ToLower(e) == lower {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHexRuneSuffix(r rune) bool {
|
||||
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSuffixLooksLikeValue_UTF8FirstRune locks in the UTF-8 first-rune
|
||||
// reading contract on SuffixLooksLikeValue. The function previously read
|
||||
// suffix[0] (a single byte) which produced incorrect splits / matches for
|
||||
// any multi-byte first rune — relevant because dws is a Chinese-language
|
||||
// CLI and value text often starts with CJK characters.
|
||||
func TestSuffixLooksLikeValue_UTF8FirstRune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
suffix string
|
||||
typ string
|
||||
format string
|
||||
enum []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
// --name张三 must NOT be split: with no metadata the fallback
|
||||
// branch should treat a CJK letter as "not a value-looking
|
||||
// suffix" so cobra reports unknown flag instead of cutting
|
||||
// the user's typo into --name + 张三.
|
||||
name: "plain string + CJK letter suffix refuses split",
|
||||
suffix: "张三",
|
||||
typ: "string",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// CJK leading rune is fine as long as the email-format guard
|
||||
// ('@' anywhere in suffix) still passes.
|
||||
name: "email format + CJK leading + @ allows split",
|
||||
suffix: "张三@example.com",
|
||||
typ: "string",
|
||||
format: "email",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// uuid format: first rune must be hex. CJK starting char is
|
||||
// not hex so the suffix must be rejected.
|
||||
name: "uuid format + CJK leading rejects split",
|
||||
suffix: "张abcd",
|
||||
typ: "string",
|
||||
format: "uuid",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Baseline: digit-led suffix on int still splits — guards
|
||||
// against accidental over-tightening of the fallback path.
|
||||
name: "int + digit-led baseline still splits",
|
||||
suffix: "100",
|
||||
typ: "int",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// Invalid UTF-8 (lone continuation byte 0x80) decodes as
|
||||
// utf8.RuneError; the RuneError guard makes the fallback
|
||||
// reject it instead of treating it as "non-letter, splittable".
|
||||
name: "plain string + invalid UTF-8 leading byte refuses split",
|
||||
suffix: "\x80abc",
|
||||
typ: "string",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := SuffixLooksLikeValue(tc.suffix, tc.typ, tc.format, tc.enum)
|
||||
if got != tc.want {
|
||||
t.Errorf("SuffixLooksLikeValue(%q, %q, %q, %v) = %v, want %v",
|
||||
tc.suffix, tc.typ, tc.format, tc.enum, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -74,23 +74,38 @@ const (
|
||||
DefaultPartition = "default/default"
|
||||
)
|
||||
|
||||
// EditionPartition returns the cache partition for a given edition name.
|
||||
// The open-source core (name == "" or "open") uses DefaultPartition; every
|
||||
// other edition gets its own namespace to prevent cross-edition data
|
||||
// leakage in the disk cache.
|
||||
// IsOpenEdition reports whether an edition name maps to the open-source core.
|
||||
//
|
||||
// This helper takes the edition name as a parameter instead of calling
|
||||
// edition.Get() so that pkg/config remains a leaf dependency — importable
|
||||
// from internal/cli, internal/app, internal/cache, etc. without risking
|
||||
// import cycles.
|
||||
func IsOpenEdition(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
return name == "" || name == "open"
|
||||
}
|
||||
|
||||
// EditionPartition returns the cache partition for a given edition name.
|
||||
// The open-source core (name == "" or "open") uses DefaultPartition; every
|
||||
// other edition gets its own namespace to prevent cross-edition data
|
||||
// leakage in the disk cache.
|
||||
func EditionPartition(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "open" {
|
||||
if IsOpenEdition(name) {
|
||||
return DefaultPartition
|
||||
}
|
||||
return name + "/default"
|
||||
}
|
||||
|
||||
// EditionFileName returns the edition-partitioned file name for base+ext.
|
||||
func EditionFileName(name, base, ext string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if IsOpenEdition(name) {
|
||||
return base + ext
|
||||
}
|
||||
return base + "-" + name + ext
|
||||
}
|
||||
|
||||
// ── Auth flow timeouts ──────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
|
||||
@@ -96,6 +96,48 @@ func TestEditionPartition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOpenEdition(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"empty is open", "", true},
|
||||
{"open is open", "open", true},
|
||||
{"whitespace open is open", " open ", true},
|
||||
{"wukong is sibling", "wukong", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsOpenEdition(tc.input); got != tc.want {
|
||||
t.Fatalf("IsOpenEdition(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditionFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
edition string
|
||||
want string
|
||||
}{
|
||||
{"empty uses legacy filename", "", "app.json"},
|
||||
{"open uses legacy filename", "open", "app.json"},
|
||||
{"whitespace is trimmed", " wukong ", "app-wukong.json"},
|
||||
{"sibling is suffixed", "wukong", "app-wukong.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := EditionFileName(tc.edition, "app", ".json"); got != tc.want {
|
||||
t.Fatalf("EditionFileName(%q, \"app\", \".json\") = %q, want %q", tc.edition, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualTokenExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
if ManualTokenExpiry <= 0 {
|
||||
|
||||
+7
-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(待办任务)
|
||||
@@ -99,6 +103,7 @@ Step 3 → 加 --yes 执行命令
|
||||
## 核心流程
|
||||
作为一个智能助手,你的首要任务是**理解用户的真实、完整的意图**,而不是简单地执行命令。在选择 `dws` 的产品命令前,必须严格遵循以下四步流程:
|
||||
|
||||
0. **URL 预检**:输入含 `alidocs.dingtalk.com` URL 时,该域名下存在多种路径格式(`/i/nodes/...`、`/i/p/...`、`/spreadsheetv2/...`、`/document/edit|preview?dentryKey=...` 等),每种的处理流程不同。**必须先读取 [url-patterns.md](./references/url-patterns.md) 中的「alidocs URL 分流决策」**,按其中规则识别 URL 类型后再选择对应产品。含 `shanji.dingtalk.com` URL 时直接路由到 `minutes`。URL 已识别后直接进入对应产品流程,无需后续步骤。
|
||||
1. 意图分类:首先,判断用户指令的核心 动词/动作 属于哪一类。这比关注名词更重要。
|
||||
2. 歧义处理与信息追问:如果用户指令模糊或包含多个产品的关键字,严禁猜测。必须主动向用户追问以澄清意图。这是你作为智能助手而非命令执行器的核心价值。
|
||||
3. 精准产品映射:在完成前两步,意图已经清晰后,参考产品总览和意图判断决策树 来选择产品。
|
||||
@@ -140,6 +145,7 @@ dws schema <path> --jq '.tool.required' # 只看必填字段
|
||||
|
||||
- [references/products/](./references/products/) — 各产品命令详细参考(flag 细节以 `--help` / `dws schema` 为准)
|
||||
- [references/intent-guide.md](./references/intent-guide.md) — 意图路由指南(易混淆场景对照)
|
||||
- [references/url-patterns.md](./references/url-patterns.md) — URL 格式规范 + alidocs URL 分流决策与类型探测流程(含钉盘 `document/edit|preview?dentryKey=` 链接)
|
||||
- [references/global-reference.md](./references/global-reference.md) — 全局标志、认证、输出格式
|
||||
- [references/field-rules.md](./references/field-rules.md) — AI表格字段类型规则
|
||||
- [references/error-codes.md](./references/error-codes.md) — 错误码 + 调试流程
|
||||
|
||||
@@ -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。`--title` 是消息标题,**群聊与单聊都必填**(API 强制要求;缺失时服务端返回误导性的 "发群服务窗会话消息失败",CLI 现在前置校验直接报错)。
|
||||
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
|
||||
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
|
||||
|
||||
@@ -191,15 +191,15 @@ Flags:
|
||||
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 --group <openconversation_id> "hello"
|
||||
dws chat message send --group <openconversation_id> --title "周报" --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 "通知" "hello"
|
||||
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
|
||||
dws chat message send --group <openconversation_id> --at-all "<@all> 请大家注意"
|
||||
dws chat message send --group <openconversation_id> --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
|
||||
dws chat message send --group <openconversation_id> --media-id <mediaId>
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --media-id <mediaId>
|
||||
dws chat message send --group <openconversation_id> --title "通知" --at-all "<@all> 请大家注意"
|
||||
dws chat message send --group <openconversation_id> --title "通知" --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
|
||||
dws chat message send --group <openconversation_id> --title "图片" --media-id <mediaId>
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "图片" --media-id <mediaId>
|
||||
Flags:
|
||||
--text string 消息内容(推荐使用,也可用位置参数)
|
||||
--group string 群聊 openconversation_id(群聊时必填)
|
||||
@@ -219,6 +219,7 @@ Flags:
|
||||
|
||||
注意:
|
||||
- --text 和位置参数二选一,--text 优先
|
||||
- --title 必填(群聊与单聊都必填,API 强制要求)
|
||||
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
|
||||
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
|
||||
- --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
|
||||
@@ -722,7 +723,7 @@ dws drive download --file-id <dentryUuid> --format json
|
||||
|
||||
# Step 5: 用 Markdown 图片语法发送
|
||||
dws chat message send --group <openconversation_id> \
|
||||
--text "" --format json
|
||||
--title "截图" --text "" --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
@@ -50,6 +50,8 @@ Usage:
|
||||
Example:
|
||||
dws doc info --node <DOC_ID>
|
||||
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
dws doc info --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>"
|
||||
dws doc info --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>"
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
```
|
||||
@@ -61,6 +63,8 @@ Usage:
|
||||
Example:
|
||||
dws doc read --node <DOC_ID>
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>"
|
||||
dws doc read --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>"
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
```
|
||||
@@ -328,12 +332,14 @@ Flags:
|
||||
|------|------|----------------|
|
||||
| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段:`9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` |
|
||||
| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段:`abc123` |
|
||||
| `alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | `https://alidocs.dingtalk.com/document/edit?dentryKey=wo1g3x54FzVEJ5yE` | **不要提取 `dentryKey` 单独使用**,必须将完整 URL 原样传给 `--node` |
|
||||
|
||||
### 提取规则
|
||||
|
||||
1. 匹配 URL 中 `alidocs.dingtalk.com` 域名
|
||||
2. 取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
|
||||
3. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
|
||||
2. 路径为 `/i/nodes/{id}` 时,取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
|
||||
3. 路径为 `/document/edit` 或 `/document/preview` 且 query 含 `dentryKey` 时,**禁止**提取 `dentryKey` 当 DOC_ID;将整段 URL 原样传给 `--node`,CLI 会自动解析(追踪参数如 `utm_source`、`chInfo` 也不必清理)
|
||||
4. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
|
||||
|
||||
### 处理流程
|
||||
|
||||
@@ -532,18 +538,23 @@ dws doc rename --node <DOC_ID> --name "项目周报 v2" --format json
|
||||
| `file create` | `nodeId` | 后续 read / update / block 操作的 --node(仅 adoc 支持 read/update,axls/amind 等类型用各自产品的命令) |
|
||||
| `copy` / `move` | 新 `nodeId`(copy)或原 nodeId(move) | 后续 read / info 等的 --node |
|
||||
|
||||
## nodeId 双格式说明
|
||||
## nodeId 多格式说明
|
||||
|
||||
所有 `--node` 参数同时支持两种格式,系统自动识别:
|
||||
所有 `--node` 参数同时支持以下格式,系统自动识别:
|
||||
- **文档 ID**: 字母数字字符串,如 `9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
|
||||
- **文档 URL**: `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}`,如 `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
|
||||
- **文档链接(edit/preview)**: `https://alidocs.dingtalk.com/document/{edit|preview}?...&dentryKey={key}`(必须传入完整 URL,不要提取其中的 query 参数单独使用)
|
||||
|
||||
两种方式等价,以下命令效果相同:
|
||||
以下命令效果相同:
|
||||
```bash
|
||||
dws doc read --node 9E05BDRVQePjzLkZt2p2vE7kV63zgkYA
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA"
|
||||
dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=wo1g3x54FzVEJ5yE"
|
||||
dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=74993670680&type=d&docKey=Pd6l2Z7V8ZWydl7M&dentryKey=rBGBr2r1HmwanAGW"
|
||||
```
|
||||
|
||||
> **注意**:`document/edit` 和 `document/preview` 格式 URL 中的 `dentryKey` 参数值不是合法的独立 nodeId,禁止提取后单独使用,必须传入完整 URL。URL 中可能包含 `utm_source`、`chInfo` 等追踪参数,无需手动去除,直接传入完整 URL 即可。
|
||||
|
||||
`--folder` 参数同样支持文件夹 URL 或 ID。
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -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) — 钉盘文件存储/上传/下载
|
||||
@@ -0,0 +1,127 @@
|
||||
# URL 格式与处理规范
|
||||
|
||||
## alidocs URL 分流决策(必须首先执行)
|
||||
|
||||
收到 `alidocs.dingtalk.com` URL 时,**必须按以下顺序判断,禁止跳过**:
|
||||
|
||||
1. URL 路径含 `/i/p/` → **分享短链**,禁止调用 `dws doc` 任何子命令 → 按下方 [分享短链处理](#分享短链处理) 执行
|
||||
2. URL 路径含 `/i/nodes/` → **节点链接**,需探测类型 → 按下方 [alidocs URL 类型探测流程](#alidocs-url-类型探测流程) 执行
|
||||
3. URL 路径含 `/spreadsheetv2/` → **电子表格直链**,直接路由到 `sheet`,将完整 URL 原样传给 `--node` 参数
|
||||
4. URL 路径含 `/document/edit` 或 `/document/preview` 且 query 参数包含 `dentryKey` → **文档链接**,直接路由到 `doc`,将完整 URL 原样传给 `--node` 参数(URL 中不一定有 `type=d`,只需匹配路径和 `dentryKey` 参数即可)
|
||||
5. 其他 alidocs URL 格式 → 告知用户当前暂不支持该链接格式
|
||||
|
||||
---
|
||||
|
||||
## 已知 URL 格式
|
||||
|
||||
需要自行拼接链接时,只能使用以下模板:
|
||||
|
||||
| 产品 | 用途 | URL 格式 | ID 来源 |
|
||||
|------|------|----------|---------|
|
||||
| `aitable` | AI表格 Base 链接 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` | `base list/search/create/get` 返回的 `baseId` |
|
||||
| `aitable` | AI表格模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` | `template search` 返回的 `templateId` |
|
||||
| `doc` | 文档链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `doc` 命令返回的 `dentryUuid` |
|
||||
| `sheet` | 电子表格链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `sheet create` 返回的 `dentryUuid` |
|
||||
| `sheet` | 电子表格直链 | `https://alidocs.dingtalk.com/spreadsheetv2/{key}/...?dentryKey={key}&type=s` | 用户提供的完整 URL,直接传给 `--node` |
|
||||
| `doc` | 文档链接(edit/preview) | `https://alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | 用户提供的完整 URL,直接传给 `--node` |
|
||||
| `minutes` | 听记链接 | `https://shanji.dingtalk.com/app/transcribes/{taskUuid}` | `list mine/shared` 返回的 `taskUuid` |
|
||||
|
||||
不在此表中的产品,禁止自行拼接 URL。命令返回中包含完整链接时直接使用,否则告知用户无法提供。
|
||||
|
||||
## 分享短链处理
|
||||
|
||||
`alidocs.dingtalk.com/i/p/{shortKey}` 是钉钉文档的**对外分享短链**,`dws doc` 命令无法解析此格式。
|
||||
|
||||
### 识别规则
|
||||
|
||||
URL 路径中包含 `/i/p/` 即为分享短链(无论后面是否还有子路径),例如:
|
||||
- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2`
|
||||
- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7`
|
||||
- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234`
|
||||
- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234/sheets/XYZ789`
|
||||
|
||||
> **关键**:只要 URL 中出现 `/i/p/`,无论后面跟什么子路径(`/docs/...`、`/sheets/...` 等),都属于分享短链,一律禁止调用 `dws doc`。
|
||||
|
||||
### 处理方式
|
||||
|
||||
**不要调用 `dws doc` 任何子命令**(包括 `doc info`、`doc read` 等),`dws` 无法解析此格式。
|
||||
|
||||
- **需要获取文档内容时**:使用 `read_url` 工具直接读取该链接
|
||||
- **其他操作(如移动、复制、权限管理等)**:告知用户此链接为分享短链,无法直接执行复制、移动、权限管理等操作。如需保存该文档内容,建议用户在钉钉客户端中打开该页面,手动复制文本内容,然后可通过 `dws doc create` 创建一篇新文档并将内容写入
|
||||
|
||||
```
|
||||
# 需要读取文档内容时(无论 /i/p/ 后面有没有子路径,都用 read_url)
|
||||
read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2")
|
||||
read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7")
|
||||
|
||||
# 禁止(以下全部会失败,dws 无法解析任何含 /i/p/ 的 URL)
|
||||
dws doc info --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2" --format json
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7" --format json
|
||||
```
|
||||
|
||||
### 当 `read_url` 返回内容不完整时
|
||||
|
||||
钉钉文档分享页是动态渲染的,`read_url` 可能只能获取到页面标题等有限信息,无法获取文档正文。此时**禁止猜测原因**(如"权限不足""文档为空""文档已删除"等),**禁止建议用户"提供 `/i/nodes/` 格式链接"**(分享短链和节点链接是不同体系,普通用户无法自行转换)。应直接告知用户:
|
||||
|
||||
> 这个链接是钉钉文档的分享短链,由于页面是动态渲染的,我无法通过该链接直接获取文档的完整正文内容。
|
||||
>
|
||||
> 你可以:
|
||||
> 1. 在钉钉客户端中打开该文档,将正文内容复制粘贴给我
|
||||
> 2. 如果文档已保存在你的文档空间中,可以告诉我文档名称,我通过 `dws doc search` 搜索后再读取
|
||||
|
||||
---
|
||||
|
||||
## alidocs URL 类型探测流程
|
||||
|
||||
`alidocs.dingtalk.com/i/nodes/{id}` 是钉钉文档空间的统一 URL,可能指向**文档、电子表格、多维表、文件、文件夹**等不同类型。**禁止仅凭 URL 就假定为文档**,必须先探测类型再路由到正确的产品。
|
||||
|
||||
### 探测步骤
|
||||
|
||||
```
|
||||
Step 1 → dws doc info --node "<URL>" --format json
|
||||
Step 2 → 从返回中提取 contentType、extension、nodeType 字段
|
||||
Step 3 → 按下方路由规则映射到对应产品
|
||||
```
|
||||
|
||||
### 路由映射表
|
||||
|
||||
| 条件 | 路由到产品 | 后续操作 |
|
||||
|------|-----------|---------|
|
||||
| `contentType=ALIDOC`, `extension=adoc` | `doc` | 按 [doc.md](./products/doc.md) 操作 |
|
||||
| `contentType=ALIDOC`, `extension=axls` | `sheet` | 按 [sheet.md](./products/sheet.md) 操作(仅 `axls` 在线电子表格) |
|
||||
| `contentType=ALIDOC`, `extension=able` | `aitable` | 将 nodeId 作为 baseId,按 [aitable.md](./products/aitable.md) 操作 |
|
||||
| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws doc download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) |
|
||||
| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws doc download` 下载,返回文件下载链接 |
|
||||
| `nodeType=folder` | `doc` | 调用 `dws doc list --folder <ID>` 列出指定文件夹直接子节点列表 |
|
||||
| 以上均不匹配 | — | 告知用户当前暂不支持该类型 |
|
||||
|
||||
> axls vs xlsx 关键区分:
|
||||
> - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作)
|
||||
> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错)
|
||||
> - 用户想把在线表格导出为 xlsx 文件 → 用 `dws sheet submit_export_job` 提交导出任务(输入是 `axls`,输出是 xlsx,这是 axls → xlsx 的格式转换,不属于 xlsx 读取场景)
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 用户传入: https://alidocs.dingtalk.com/i/nodes/abc123
|
||||
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json
|
||||
|
||||
# 返回 contentType=ALIDOC, extension=axls → 在线电子表格,路由到 sheet
|
||||
dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json
|
||||
|
||||
# 返回 contentType≠ALIDOC, extension=xlsx/xls/csv → 本地表格文件,必须下载处理(禁止走 sheet)
|
||||
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456"
|
||||
|
||||
# 返回 contentType≠ALIDOC, nodeType=file → 普通文件,下载
|
||||
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/def456"
|
||||
|
||||
# 返回 nodeType=folder → 文件夹,列出子节点
|
||||
dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format json
|
||||
```
|
||||
|
||||
### 何时可跳过探测
|
||||
|
||||
当用户指令中已明确指定产品(如"帮我读这个文档"、"看下这个表格的数据"),可结合用户意图**跳过探测**直接路由。仅在以下情况**必须执行探测**:
|
||||
- 用户只粘贴 URL,无其他上下文
|
||||
- 用户指令与 URL 实际类型可能不一致(如说"文档"但实际是表格)
|
||||
- 用户直接粘贴的是原始 `alidocs` URL,且没有上游命令返回来确认类型
|
||||
Reference in New Issue
Block a user