Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
574d9aa2f7 | ||
|
|
1aaaef0274 | ||
|
|
00c037b5be | ||
|
|
9e15115ad4 | ||
|
|
25bf3d12f2 | ||
|
|
f78cc5c846 | ||
|
|
72fe795f3f | ||
|
|
0e892c7d75 | ||
|
|
8995bf65d6 | ||
|
|
91af2bc3b8 | ||
|
|
e2e8b3bf52 | ||
|
|
a652b90fd4 | ||
|
|
7a868ddf39 | ||
|
|
89d7c5f11b | ||
|
|
efb61cae02 | ||
|
|
fb88c6ace9 | ||
|
|
5258959a14 | ||
|
|
c515fc1001 |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.8%"><title>coverage: 52.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">52.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.8%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
+113
@@ -4,6 +4,119 @@ 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
|
||||
|
||||
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands.
|
||||
|
||||
### Added
|
||||
|
||||
- **`CLIToolOverride.CLIAliases` envelope field** (#246) — lets a single MCP tool register additional cobra command aliases via envelope JSON (e.g. `range read` also accepts `range get`, `member list` accepts `member ls`). Plumbed through the existing `Route.Aliases → cobra.Command.Aliases` path; sibling conflicts are silently dropped by cobra. Lives in `internal/market/registry.go` + `internal/compat/dynamic_commands.go`.
|
||||
- **`json_parse_strict` transform** (#246) — strict-JSON variant of `json_parse` that does **not** fall back to YAML. Use when the upstream tool requires a structured array/object and silently coercing a malformed input to a scalar string would mask a real user error (observed: `filter-view --criteria 'NOT_VALID_JSON'` was being accepted and quietly creating an empty-criteria view). In `internal/compat/transform.go`.
|
||||
- **`CLIToolOverride.Pipeline` + pipeline executor** (#247) — a single CLI command can now orchestrate an ordered sequence of MCP tool calls plus optional HTTP-download sinks, declared entirely in envelope JSON. Motivating use case: the "submit-job → poll-status → download-result" pattern (e.g. sheet export) that previously required per-product hardcoded helpers.
|
||||
- `PipelineStep` supports `type:"call"` (with optional `PollUntilField` / `PollUntilValue` / `PollIntervalSec` / `PollTimeoutSec` for polling loops) and `type:"download"` (resolves `DownloadURLField`, HTTP GETs the body, writes to the path from `OutputFlag`, infers filename for directory paths).
|
||||
- Template language: `$flag.<name>` resolves a user CLI flag by alias; `$step.<idx>.<dotPath>` walks a prior step's response (works through wrapped MCP envelopes); literals pass through.
|
||||
- `CLIFlagOverride.PipelineLocal` marks a flag as CLI-side only so `CollectBindings` skips it (value never reaches MCP params); the pipeline executor still reads it via `extractFlagValuesByAlias`.
|
||||
- Download step emits machine-parseable plain-text lines (`jobId: <id>\n`, `downloadUrl: <url>\n`) alongside the standard JSON envelope, so shell pipelines and regex-based tests can extract key values without JSON parsing.
|
||||
|
||||
## [1.0.24] - 2026-05-09
|
||||
|
||||
Three small but user-visible safety/usability changes: the embedded distribution now refuses to self-upgrade, the `dws auth login` help text finally matches the actual default flow (loopback, not device), and the release workflow gains a manual fallback trigger.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws upgrade` is blocked in embedded distributions** (#248) — when the CLI is shipped as an embedded asset (e.g. inside another product), `dws upgrade` would happily overwrite the host-managed binary. The upgrade entry point now detects the embedded build flag and exits early with a clear message; covered by `internal/app/upgrade_embedded_guard_test.go`.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`dws auth login` help text reflects the real default** (#238, fixes #226) — the long help previously claimed "OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback listener and only switches to device flow when `--device` is passed. SSH-into-headless-Linux users following the old text hit a dead end (remote-side 127.0.0.1 is unreachable from the local browser). Help and two `flagErrorWithSuggestions` messages in `root.go` are realigned: each method is named after its real flag (`OAuth Loopback 流 (默认)` / `OAuth 设备流 (--device)` / `直接提供 Token (--token)`), with an explicit `--device` example for SSH/headless. No behaviour change.
|
||||
|
||||
### CI
|
||||
|
||||
- **`workflow_dispatch` trigger added to release workflow as a fallback** (#261) — GitHub occasionally drops tag-push events; the release job can now be re-run manually against any tag ref without having to delete and re-push the tag.
|
||||
|
||||
## [1.0.23] - 2026-05-08
|
||||
|
||||
A single fix for HTTP proxy support across the CLI's custom HTTP transports. No behaviour changes elsewhere.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`HTTP_PROXY` / `HTTPS_PROXY` environment variables silently ignored by all custom transports** (#237, fixes #236) — the three custom `http.Transport` instances built by the CLI (`internal/transport/client.go` MCP transport, `internal/apiclient/client.go` DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry client) all set `DialContext` / `TLSClientConfig` / timeouts but omitted the `Proxy` field. Per Go's `net/http` contract, a non-nil Transport without an explicit `Proxy` means "no proxy" — env vars are silently ignored, breaking sandboxed or air-gapped deployments that route outbound through `HTTP_PROXY` / `HTTPS_PROXY`. All three transports now set `Proxy: http.ProxyFromEnvironment`.
|
||||
|
||||
### Tests
|
||||
|
||||
- Per-package regression test that pointer-compares the Transport's `Proxy` func against `http.ProxyFromEnvironment`, avoiding flakiness from Go's `envProxyOnce` memoisation when running alongside tests that read proxy env early. (#237)
|
||||
|
||||
## [1.0.22] - 2026-05-07
|
||||
|
||||
Two release-blocking bug fixes: `dws attendance summary` now exposes the server-required `--stats-type` flag (without it, every call returned C0002), and the install scripts finally populate `~/.hermes/skills/dws/` for users who already have Hermes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws attendance summary` returned C0002 (统计类型错误) on every call** (#228, fixes #227) — the DingTalk MCP tool `get_attendance_summary` requires `statsType` at the business layer even though the schema marks it optional. The CLI did not expose any way to set it, so the command was 100% unusable. A new `--stats-type` flag (`week` / `month`) is now plumbed through to `QueryUserAttendVO.statsType`; the flag is documented as required in the long help, flag description, and `skills/references/products/attendance.md`.
|
||||
- **Install scripts skipped `.hermes/skills/` when populating skill directories** (#221, fixes #188) — the `AGENT_DIRS` lists across `build/npm/install.js`, `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh` and the four upgrade-path mirrors (8 sources total once review feedback was addressed) did not include `.hermes/skills`, so users with Hermes installed were not getting `~/.hermes/skills/dws/` populated automatically. The existing parent-directory gate keeps this zero-side-effect for users without Hermes.
|
||||
|
||||
### Tests
|
||||
|
||||
- New `--stats-type` regression coverage in `test/cli_compat/attendance_test.go` — verifies `statsType` is written to `QueryUserAttendVO` when set to `month` or `week`, and is omitted when not provided. (#228)
|
||||
|
||||
## [1.0.21] - 2026-05-05
|
||||
|
||||
A single critical routing fix for `dws drive` commands. No new commands or behaviour changes elsewhere.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws drive mkdir` / `dws drive download` silently routed to the doc MCP server** (#220, fixes #219) — when two MCP servers register tools with the same name (e.g. both `drive` and `doc` expose `create_folder`), the tool-level endpoint map used last-writer-wins, so drive-side calls landed on the doc endpoint and returned mock-shaped responses (`success: true` with a fake `folderId`) without actually creating anything. `directRuntimeEndpoint` now resolves product-level first when the caller already knows the productID, and only falls back to the tool-level lookup when productID is empty. The wrong-server collision and the resulting "succeeded but didn't" behaviour are gone.
|
||||
|
||||
## [1.0.20] - 2026-05-04
|
||||
|
||||
Documentation polish and a login regression fix. No behaviour changes outside the login MCP refresh path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Login no longer reuses stale `clientId` from an old MCP cache** (#213) — `dws login` now unconditionally re-fetches the MCP descriptor, so a previously cached client id can't keep producing auth errors after the server rotates it.
|
||||
|
||||
### Docs
|
||||
|
||||
- **`dws chat message list` pagination** (#218, fixes #195) — clarifies that `nextCursor` is opaque and must be passed back as `--cursor` exactly; warns against parsing or reusing it as an offset.
|
||||
- **`dws contact search` examples** (#209) — switched from the removed `--keyword` flag to the current `--query`.
|
||||
- **`dws todo` help text** (#205) — expanded field semantics so MCP wrappers generate accurate schemas.
|
||||
- **`dws chat message send-by-bot` and `dws report create` help** (#217, #106, #107) — `--robot-code` / `--title` / `--text` now carry the `(必填)` marker; `report create --contents` documents the `key=field_name` requirement and rewrites examples as a `template detail → create` two-step pipeline.
|
||||
- **CHANGELOG backfill for 1.0.19** (#204).
|
||||
|
||||
## [1.0.19] - 2026-04-30
|
||||
|
||||
Discovery hardening for edition overlays: `edition.SupplementServers` / `FallbackServers` hooks now consistently surface through the **runtime catalog loader**, not just the static command tree, so overlay products that live outside the Portal envelope (e.g. Wukong gray-release `conference`) resolve an endpoint on both the cold-cache and tool-not-in-catalog paths. Ships with per-edition cache partitioning to stop cross-edition disk-cache leakage, plus a small todo fix.
|
||||
|
||||
### Added
|
||||
|
||||
- **`pkg/config.EditionPartition(name)`** (#197) — returns the cache partition key for a given edition. Open-source core (`""` / `"open"`) keeps using `DefaultPartition` (`default/default`); every other edition gets its own namespace (`<edition>/default`), preventing cross-edition data leakage in the shared `~/.dws` disk cache. Lives in `pkg/config` as a leaf helper so `internal/cli`, `internal/app`, and `internal/cache` can all call it without risking import cycles.
|
||||
- **`internal/editionmerge` shared package** (#197) — single source of truth for converting `edition.ServerInfo` into `market.ServerDescriptor` (`ToDescriptor`) and for merging `SupplementServers` / `FallbackServers` into a descriptor list. Both `internal/cli` (command tree) and `internal/app` (runtime catalog) now apply the edition hooks against the same discovery pipeline.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`EnvironmentLoader.loadFromCache` honors `SupplementServers` even on empty registry** (#197) — when the Portal registry cache is missing or empty, the catalog loader still materialises the edition's `SupplementServers` as endpoint-only `discovery.RuntimeServer` entries (source: `edition_supplement`), so hardcoded overlay commands for supplement-only products can still resolve an endpoint via the catalog path. Previously `loadFromCache` short-circuited to an empty catalog whenever the registry snapshot was empty, silently dropping gray-release products.
|
||||
- **Cache loader switches from `DefaultPartition` to `EditionPartition(edition.Get().Name)`** (#197) — the runtime catalog, registry snapshot, and tools snapshot are now partitioned per edition instead of all editions sharing `default/default`.
|
||||
- **`loadFromCache` appends supplement servers alongside fresh-cache servers** (#197) — supplement entries whose `CLI.ID` / `Key` are already present in the cached registry are skipped, so the hook never shadows Portal-published servers; only new products are added.
|
||||
- **`runtimeRunner.Run` falls through to `directRuntimeEndpoint` for supplement products** (#197) — when the catalog contains the product (e.g. supplied by `SupplementServers`) but the specific tool is not declared, the runner now trusts `directRuntimeEndpoint` to resolve a working endpoint for the tool before returning the explicit catalog-miss error. Supplement entries intentionally carry no tool list, so this is the path that makes overlay-only tools executable.
|
||||
- **Legacy `mergeSupplementServers` / `fallbackToDescriptors` moved out of `internal/app/legacy.go`** (#197) — relocated into `internal/editionmerge` and reused by the catalog loader, eliminating the duplicate `edition.ServerInfo → market.ServerDescriptor` logic that previously only ran on the static command-tree path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws todo task get` returns empty** (#202) — the helper was calling `query_todo_detail`, which is not a valid MCP tool and returns empty. Switched to `get_todo_detail` as declared in `discovery.json`, restoring correct task-detail behaviour.
|
||||
- **Conference and other Wukong gray-release products miss endpoint on cold cache** (#197) — products registered only via `edition.SupplementServers` (not yet in the Portal envelope) now resolve an endpoint through the catalog path in both cold-start and tool-not-declared scenarios.
|
||||
|
||||
### Tests
|
||||
|
||||
- `internal/editionmerge/merge_test.go` — descriptor conversion + supplement/fallback merge semantics.
|
||||
- `internal/cli/loader_partition_test.go` + `loader_supplement_test.go` — edition-partitioned cache reads and supplement hook surfacing from `loadFromCache` (including empty-registry cold path and existing-ID deduplication).
|
||||
- `internal/app/legacy_wukong_partition_e2e_test.go` — end-to-end cache partition isolation for the Wukong edition.
|
||||
- `internal/app/runner_supplement_fallback_test.go` — runner falls through to `directRuntimeEndpoint` when the tool isn't declared by a supplement-sourced catalog entry.
|
||||
- `pkg/config/constants_test.go` — `EditionPartition` name handling (`""`, `"open"`, custom edition).
|
||||
|
||||
### Docs
|
||||
|
||||
- **CHANGELOG v1.0.18 rewrite** (#193) — previous release notes expanded to call out the PAT host-owned A-core flow, exit-code contract change (auth `4`, Discovery/cache/protocol `6`), `dws pat chmod` / `pat browser-policy` entry points, stderr-JSON classifier updates, and host-control metadata injection.
|
||||
|
||||
## [1.0.18] - 2026-04-28
|
||||
|
||||
Raw DingTalk OpenAPI access lands as a new `dws api` surface for both `api.dingtalk.com` and `oapi.dingtalk.com`, backed by app-level token caching and guarded host allowlists. PAT enters the host-owned **A-core** loop: agent hosts can own authorization UI through `DINGTALK_DWS_AGENTCODE`, parse single-line stderr JSON, call `dws pat chmod`, and replay the original command. Chat helper regressions are fixed, skill references are brought back in line with shipped commands, and the v1.0.17 Mail release notes are backfilled into README / CHANGELOG.
|
||||
|
||||
@@ -207,7 +207,7 @@ dws is designed as an AI-native CLI. Complete [Installation](#installation) and
|
||||
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
|
||||
|
||||
# Use --dry-run to preview operations (safe execution)
|
||||
dws contact user search --keyword "engineering" --dry-run
|
||||
dws contact user search --query "engineering" --dry-run
|
||||
|
||||
# Use --jq to extract precisely (save tokens)
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
@@ -332,7 +332,7 @@ Built-in pipeline engine that normalizes flag names, splits sticky arguments, an
|
||||
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # auto-corrected to --base-id --table-id
|
||||
|
||||
# Sticky argument splitting
|
||||
dws contact user search --keyword "engineering" --timeout30 # auto-split to --timeout 30
|
||||
dws contact user search --query "engineering" --timeout30 # auto-split to --timeout 30
|
||||
|
||||
# Fuzzy flag name matching
|
||||
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id -> --table-id
|
||||
|
||||
+2
-2
@@ -207,7 +207,7 @@ dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开
|
||||
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
|
||||
|
||||
# 使用 --dry-run 预览操作(安全执行)
|
||||
dws contact user search --keyword "张三" --dry-run
|
||||
dws contact user search --query "张三" --dry-run
|
||||
|
||||
# 使用 --jq 精确提取(节省 token)
|
||||
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
|
||||
@@ -332,7 +332,7 @@ dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq 过滤
|
||||
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # 自动纠正为 --base-id --table-id
|
||||
|
||||
# 粘连参数自动拆分
|
||||
dws contact user search --keyword "张三" --timeout30 # 自动拆分为 --timeout 30
|
||||
dws contact user search --query "张三" --timeout30 # 自动拆分为 --timeout 30
|
||||
|
||||
# 拼写错误模糊匹配
|
||||
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id → --table-id
|
||||
|
||||
@@ -53,6 +53,7 @@ __KEG_ONLY_LINE__
|
||||
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
|
||||
]
|
||||
|
||||
targets.each_with_index do |dest, index|
|
||||
|
||||
@@ -22,6 +22,7 @@ const AGENT_DIRS = [
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
".openclaw/skills",
|
||||
".hermes/skills",
|
||||
];
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
|
||||
+4
-4
@@ -30,9 +30,9 @@ With `-f json`, error responses include structured payloads: `category`, `reason
|
||||
## Output Formats / 输出格式
|
||||
|
||||
```bash
|
||||
dws contact user search --keyword "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --keyword "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --keyword "Alice" -f raw # Raw API response / 原始响应
|
||||
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
|
||||
dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
|
||||
```
|
||||
|
||||
@@ -45,7 +45,7 @@ dws todo task list --dry-run # Preview MCP call without executing / 预览但
|
||||
## Output to File / 输出到文件
|
||||
|
||||
```bash
|
||||
dws contact user search --keyword "Alice" -o result.json
|
||||
dws contact user search --query "Alice" -o result.json
|
||||
```
|
||||
|
||||
## Schema Introspection / Schema 查询
|
||||
|
||||
@@ -210,6 +210,8 @@ func NormalisePath(path, baseURL string) string {
|
||||
// defaultTransport returns a tuned http.Transport matching the project conventions.
|
||||
func defaultTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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 apiclient
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDefaultTransportHonoursHTTPProxyEnv is the regression guard for #236
|
||||
// on the apiclient transport. Same rationale as transport/proxy_env_test.go:
|
||||
// a custom Transport without an explicit Proxy field silently bypasses
|
||||
// HTTP_PROXY/HTTPS_PROXY.
|
||||
//
|
||||
// We pointer-compare against http.ProxyFromEnvironment instead of invoking
|
||||
// it, because http.ProxyFromEnvironment memoises the env on first call;
|
||||
// other tests that read proxy env early would make a value-based assertion
|
||||
// flaky.
|
||||
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tr := defaultTransport()
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
|
||||
}
|
||||
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
|
||||
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
|
||||
if gotPC != wantPC {
|
||||
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
|
||||
}
|
||||
}
|
||||
@@ -68,16 +68,21 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
Long: `登录钉钉并获取认证凭证。
|
||||
|
||||
支持的登录方式:
|
||||
- OAuth 设备流 (默认): 通过钉钉扫码授权登录
|
||||
- 直接提供 Token: 通过 --token 参数传入已有 token
|
||||
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
|
||||
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
|
||||
- 直接提供 Token (--token): 跳过授权,使用已有 token
|
||||
|
||||
不支持的登录方式:
|
||||
- 邮箱/密码登录
|
||||
- 手机号/验证码登录
|
||||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||||
|
||||
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
|
||||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||||
|
||||
示例:
|
||||
dws auth login # 扫码登录
|
||||
dws auth login # 本机扫码登录 (loopback 流)
|
||||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||||
dws auth login --token xxx # 使用指定 token`,
|
||||
DisableAutoGenTag: true,
|
||||
|
||||
@@ -227,14 +227,11 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
|
||||
// Priority 1: tool-level endpoint (resolves multi-endpoint products).
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: product-level endpoint.
|
||||
// Priority 1: product-level endpoint.
|
||||
// When the caller already knows the productID (e.g. "drive"), the product
|
||||
// endpoint is authoritative. This prevents cross-product tool name
|
||||
// collisions (e.g. both "drive" and "doc" register "create_folder") from
|
||||
// routing the request to the wrong MCP server. See issue #219.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
if candidate == "" {
|
||||
continue
|
||||
@@ -246,6 +243,16 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: tool-level endpoint (fallback for unknown productID).
|
||||
// This path is used when the caller does not know the productID but has a
|
||||
// tool name, e.g. in helper invocations or plugin routes where only the
|
||||
// tool name is available.
|
||||
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
|
||||
if endpoint, ok := te[tool]; ok {
|
||||
return endpoint, true
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: built-in PAT fallback for cold-start paths that run before
|
||||
// discovery/plugin registration has populated the dynamic registry.
|
||||
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
|
||||
|
||||
@@ -181,3 +181,114 @@ func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Issue #219 regression tests: cross-product tool name collision ---
|
||||
//
|
||||
// When two different products register tools with the same name (e.g. drive
|
||||
// and doc both have "create_folder"), the product-level endpoint must win
|
||||
// when the caller already knows the productID. Otherwise the tool-level map
|
||||
// (last-writer-wins) routes the invocation to the wrong MCP server.
|
||||
|
||||
const (
|
||||
testDriveEndpoint = "https://mcp-gw.dingtalk.com/server/drive-hash"
|
||||
testDocEndpoint = "https://mcp-gw.dingtalk.com/server/doc-hash"
|
||||
)
|
||||
|
||||
func driveDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testDriveEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "drive",
|
||||
Command: "drive",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_folder": {CLIName: "mkdir"},
|
||||
"list_files": {CLIName: "list"},
|
||||
"download_file": {CLIName: "download"},
|
||||
"get_upload_info": {CLIName: "upload-info"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func docDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testDocEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"create_folder": {CLIName: "create", Group: "folder"},
|
||||
"download_file": {CLIName: "download"},
|
||||
"search_documents": {CLIName: "search"},
|
||||
"list_nodes": {CLIName: "list"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel verifies
|
||||
// that when productID is known and has a registered endpoint, the product-level
|
||||
// endpoint is used even if the tool-level map points to a different server
|
||||
// (due to same-name tool collision). This is the core fix for issue #219.
|
||||
func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
servers []market.ServerDescriptor
|
||||
}{
|
||||
{
|
||||
name: "drive first, doc second",
|
||||
servers: []market.ServerDescriptor{driveDescriptor(), docDescriptor()},
|
||||
},
|
||||
{
|
||||
name: "doc first, drive second",
|
||||
servers: []market.ServerDescriptor{docDescriptor(), driveDescriptor()},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers(tc.servers)
|
||||
|
||||
// Drive tools must always route to drive's endpoint regardless of
|
||||
// registration order — productID "drive" is known.
|
||||
assertEndpoint(t, "drive", "create_folder", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "download_file", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "list_files", testDriveEndpoint)
|
||||
assertEndpoint(t, "drive", "get_upload_info", testDriveEndpoint)
|
||||
|
||||
// Doc tools must always route to doc's endpoint.
|
||||
assertEndpoint(t, "doc", "create_folder", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "download_file", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "search_documents", testDocEndpoint)
|
||||
assertEndpoint(t, "doc", "list_nodes", testDocEndpoint)
|
||||
|
||||
// Product-level fallback (no tool name) still works.
|
||||
assertEndpoint(t, "drive", "", testDriveEndpoint)
|
||||
assertEndpoint(t, "doc", "", testDocEndpoint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
func TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
SetDynamicServers([]market.ServerDescriptor{driveDescriptor(), docDescriptor()})
|
||||
|
||||
// When productID is empty, tool-level endpoint is the only option.
|
||||
// The actual endpoint depends on registration order (last-writer-wins),
|
||||
// but the lookup must succeed.
|
||||
endpoint, ok := directRuntimeEndpoint("", "create_folder")
|
||||
if !ok {
|
||||
t.Fatal("directRuntimeEndpoint(\"\", \"create_folder\") returned ok=false, want ok=true")
|
||||
}
|
||||
if endpoint != testDriveEndpoint && endpoint != testDocEndpoint {
|
||||
t.Fatalf("directRuntimeEndpoint(\"\", \"create_folder\") = %q, want one of drive/doc endpoints", endpoint)
|
||||
}
|
||||
|
||||
// Unique tools (no collision) still resolve via tool-level.
|
||||
assertEndpoint(t, "", "search_documents", testDocEndpoint)
|
||||
assertEndpoint(t, "", "get_upload_info", testDriveEndpoint)
|
||||
}
|
||||
|
||||
@@ -464,6 +464,8 @@ func ipv4HTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, "tcp4", addr)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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 (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIPv4HTTPClientHonoursHTTPProxyEnv guards the fix for #236 on the
|
||||
// IPv4-forcing client used by the legacy registry / discovery path. The
|
||||
// custom Transport overrides DialContext to force IPv4 — without an
|
||||
// explicit Proxy field it would also drop env-var proxy support.
|
||||
//
|
||||
// We can't reliably invoke tr.Proxy(req) here because http.ProxyFromEnvironment
|
||||
// memoises the env vars on first call (Go's envProxyOnce); ordering with other
|
||||
// tests that read proxy env early would make this flaky. Asserting that the
|
||||
// Transport's Proxy func points at http.ProxyFromEnvironment is sufficient to
|
||||
// catch the regression — the runtime takes care of reading HTTP_PROXY/HTTPS_PROXY
|
||||
// at process boot.
|
||||
func TestIPv4HTTPClientHonoursHTTPProxyEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := ipv4HTTPClient(5 * time.Second)
|
||||
tr, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("ipv4HTTPClient transport is %T, want *http.Transport", client.Transport)
|
||||
}
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("ipv4HTTPClient transport.Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
|
||||
}
|
||||
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
|
||||
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
|
||||
if gotPC != wantPC {
|
||||
t.Errorf("ipv4HTTPClient transport.Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,8 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
// Common flag aliases and suggestions
|
||||
suggestions := map[string]string{
|
||||
"--json": "提示: 请使用 --format json 或 -f json 来输出 JSON 格式",
|
||||
"--method": "提示: dws auth login 默认使用 OAuth 设备流登录,无需指定 --method",
|
||||
"--device-flow": "提示: dws auth login 默认已使用设备流,无需 --device-flow 参数",
|
||||
"--method": "提示: dws auth login 默认使用 OAuth loopback 流;SSH/无头环境请加 --device 走设备流",
|
||||
"--device-flow": "提示: 设备流的标志名是 --device(不是 --device-flow),SSH/无头环境登录请用 dws auth login --device",
|
||||
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
|
||||
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
|
||||
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -57,6 +58,14 @@ func newUpgradeCommand() *cobra.Command {
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if h := edition.Get(); h != nil && h.IsEmbedded {
|
||||
name := h.Name
|
||||
if name == "" {
|
||||
name = "embedded"
|
||||
}
|
||||
return fmt.Errorf("当前运行在嵌入模式(%s),dws upgrade 已禁用;请通过宿主完成升级", name)
|
||||
}
|
||||
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"check", []string{"--check"}},
|
||||
{"list", []string{"--list"}},
|
||||
{"rollback", []string{"--rollback"}},
|
||||
{"plain", []string{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newUpgradeCommand()
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errBuf)
|
||||
cmd.SetArgs(tc.args)
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("upgrade %v in embedded mode must return error, got nil", tc.args)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "嵌入模式") {
|
||||
t.Errorf("error message should mention 嵌入模式, got: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "embedded") {
|
||||
t.Errorf("error message should include edition name, got: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "dws upgrade") {
|
||||
t.Errorf("error message should reference dws upgrade for clarity, got: %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeCommand_NotBlockedInOpenSourceMode(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: false, Name: "open"})
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cmd := newUpgradeCommand()
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&errBuf)
|
||||
cmd.SetArgs([]string{"--check"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err != nil && strings.Contains(err.Error(), "嵌入模式") {
|
||||
t.Errorf("open-source mode must not be blocked by embedded guard, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -161,31 +161,22 @@ func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
// Defensive reset: clear stale credential state from previous login methods,
|
||||
// but preserve user-provided --client-id if present.
|
||||
userClientID := p.clientID
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP.
|
||||
// This ensures --device login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if userClientID != "" && userClientID != DefaultClientID {
|
||||
// User provided --client-id flag: use it directly, skip MCP fetch.
|
||||
p.clientID = userClientID
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("using user-provided client ID, skipping MCP fetch", "clientID", userClientID)
|
||||
}
|
||||
} else {
|
||||
// No user-provided client ID: fetch from MCP server.
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
|
||||
const maxAttempts = 3
|
||||
|
||||
@@ -109,31 +109,22 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
// Defensive reset: clear stale credential state from previous login methods,
|
||||
// but preserve user-provided --client-id if present.
|
||||
userClientID := p.clientID
|
||||
// Defensive reset: clear any stale credential state from previous login
|
||||
// methods so we always re-fetch clientID from MCP. This ensures
|
||||
// --force login works regardless of what app.json contains.
|
||||
p.resetCredentialState()
|
||||
|
||||
if userClientID != "" && userClientID != DefaultClientID {
|
||||
// User provided --client-id flag: use it directly, skip MCP fetch.
|
||||
p.clientID = userClientID
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("using user-provided client ID, skipping MCP fetch", "clientID", userClientID)
|
||||
}
|
||||
} else {
|
||||
// No user-provided client ID: fetch from MCP server.
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
|
||||
}
|
||||
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
|
||||
if mcpErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
|
||||
}
|
||||
p.clientID = mcpClientID
|
||||
SetClientIDFromMCP(mcpClientID)
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
|
||||
}
|
||||
|
||||
// Find a free port for the callback server.
|
||||
|
||||
@@ -163,9 +163,12 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
}
|
||||
|
||||
route := Route{
|
||||
Use: cliName,
|
||||
Short: short,
|
||||
Long: long,
|
||||
Use: cliName,
|
||||
// CLIAliases register additional cobra command aliases for the
|
||||
// same MCP tool. Empty / nil means no extra names.
|
||||
Aliases: append([]string(nil), override.CLIAliases...),
|
||||
Short: short,
|
||||
Long: long,
|
||||
// Preserve left-side indentation: cobra's Examples template
|
||||
// renders {{.Example}} verbatim, and hardcoded helper commands
|
||||
// rely on a 2-space prefix to look indented under "Examples:".
|
||||
@@ -176,7 +179,12 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
CanonicalProduct: canonicalProduct,
|
||||
Tool: toolName,
|
||||
},
|
||||
Bindings: bindings,
|
||||
Bindings: bindings,
|
||||
// §pipeline: when the envelope declares a multi-step
|
||||
// orchestration, NewDirectCommand reroutes RunE into the
|
||||
// pipeline executor instead of the single-tool flow. The
|
||||
// CLIName / Group / Flags surface above still applies.
|
||||
Pipeline: append([]market.PipelineStep(nil), override.Pipeline...),
|
||||
Normalizer: normalizer,
|
||||
}
|
||||
|
||||
@@ -612,10 +620,16 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
binding := FlagBinding{
|
||||
FlagName: flagName,
|
||||
Aliases: extraAliases,
|
||||
Short: strings.TrimSpace(flagOverride.Shorthand),
|
||||
Property: paramName,
|
||||
Kind: kindFromTypeName(flagOverride.Type),
|
||||
Usage: usage,
|
||||
// §pipeline: PipelineLocal flags (e.g. `--output` in the
|
||||
// sheet export pipeline) are CLI-side only — they appear in
|
||||
// --help and are bindable, but CollectBindings skips them so
|
||||
// the value never reaches MCP params. The pipeline executor
|
||||
// reads them via extractFlagValuesByAlias.
|
||||
PipelineLocal: flagOverride.PipelineLocal,
|
||||
Short: strings.TrimSpace(flagOverride.Shorthand),
|
||||
Property: paramName,
|
||||
Kind: kindFromTypeName(flagOverride.Type),
|
||||
Usage: usage,
|
||||
// §P1: Required is preserved for positional bindings too. For
|
||||
// pure positional, cobra arity (MinimumNArgs) enforces presence
|
||||
// at parse time. For dual-mode positional (positional + alias),
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package compat — pipeline executor for CLIToolOverride.Pipeline.
|
||||
//
|
||||
// A pipeline turns a single CLI command into an ordered sequence of MCP
|
||||
// tool calls plus optional HTTP-download sinks, declared entirely in the
|
||||
// envelope JSON. Use cases:
|
||||
//
|
||||
// 1. submit-job + poll-status + download-result patterns (the canonical
|
||||
// example: `dws sheet export --node X --output PATH` calls
|
||||
// submit_export_job → query_export_job (poll until status=done) →
|
||||
// HTTP GET downloadUrl → write to PATH).
|
||||
// 2. compose-then-update flows where step 2's args reference step 1's
|
||||
// response.
|
||||
//
|
||||
// Templates supported in PipelineStep.Args / DownloadURLField:
|
||||
//
|
||||
// $flag.<aliasName> — value of the user's CLI flag whose alias
|
||||
// equals <aliasName>
|
||||
// $step.<idx>.<dotPath> — field from a prior step's response
|
||||
// literal string — passed through unchanged
|
||||
//
|
||||
// Limitations (intentional, to keep the executor small):
|
||||
// - No conditional branching: steps run unconditionally in order.
|
||||
// - No retry-on-error: the pipeline aborts on the first runner error.
|
||||
// - PollUntil compares as strings; numeric/boolean comparisons stringify.
|
||||
// - Download step uses the standard library net/http with no custom
|
||||
// timeout (relies on the user's Ctrl-C).
|
||||
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
)
|
||||
|
||||
// pipelineCtx carries flag values + accumulated step responses through
|
||||
// the executor. Unexported because callers always interact via runPipeline.
|
||||
type pipelineCtx struct {
|
||||
flags map[string]string
|
||||
stepOutputs []map[string]any
|
||||
}
|
||||
|
||||
// runPipeline executes route.Pipeline against runner, returning the last
|
||||
// "call"-type step's response (or a synthesized success payload if the
|
||||
// pipeline ends with a "download" step). The map is the shape returned to
|
||||
// the user via the standard output formatter.
|
||||
func runPipeline(
|
||||
ctx context.Context,
|
||||
cmd *cobra.Command,
|
||||
runner executor.Runner,
|
||||
route Route,
|
||||
flagValues map[string]string,
|
||||
) (map[string]any, error) {
|
||||
pctx := &pipelineCtx{
|
||||
flags: flagValues,
|
||||
stepOutputs: make([]map[string]any, 0, len(route.Pipeline)),
|
||||
}
|
||||
|
||||
var lastCallResponse map[string]any
|
||||
for i, step := range route.Pipeline {
|
||||
stepType := strings.TrimSpace(step.Type)
|
||||
if stepType == "" {
|
||||
stepType = "call"
|
||||
}
|
||||
switch stepType {
|
||||
case "call":
|
||||
resp, err := executePipelineCall(ctx, runner, route, step, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline step %d (%s): %w", i, step.Tool, err)
|
||||
}
|
||||
pctx.stepOutputs = append(pctx.stepOutputs, resp)
|
||||
lastCallResponse = resp
|
||||
case "download":
|
||||
resp, err := executePipelineDownload(cmd, step, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline step %d (download): %w", i, err)
|
||||
}
|
||||
pctx.stepOutputs = append(pctx.stepOutputs, resp)
|
||||
default:
|
||||
return nil, apperrors.NewValidation(
|
||||
fmt.Sprintf("pipeline step %d: unsupported type %q (allowed: call, download)", i, stepType),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if lastCallResponse != nil {
|
||||
return lastCallResponse, nil
|
||||
}
|
||||
return map[string]any{"success": true}, nil
|
||||
}
|
||||
|
||||
// executePipelineCall resolves args templates, then either polls or fires
|
||||
// a single MCP tool invocation via runner. PollUntilField + PollUntilValue
|
||||
// non-empty enable polling.
|
||||
func executePipelineCall(
|
||||
ctx context.Context,
|
||||
runner executor.Runner,
|
||||
route Route,
|
||||
step market.PipelineStep,
|
||||
pctx *pipelineCtx,
|
||||
) (map[string]any, error) {
|
||||
if strings.TrimSpace(step.Tool) == "" {
|
||||
return nil, apperrors.NewValidation("pipeline call step requires non-empty `tool`")
|
||||
}
|
||||
args, err := resolveArgs(step.Args, pctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
invoke := func() (map[string]any, error) {
|
||||
invocation := executor.NewCompatibilityInvocation(
|
||||
route.Use,
|
||||
route.Target.CanonicalProduct,
|
||||
step.Tool,
|
||||
args,
|
||||
)
|
||||
result, err := runner.Run(ctx, invocation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Response == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
// Fail-fast on MCP business errors. Pre-execution validation (e.g.
|
||||
// cobra MarkFlagRequired) only checks that the flag was set, not
|
||||
// that the value is non-empty — so a `--required-flag ""` reaches
|
||||
// here and the upstream tool rejects with errorCode. Without this
|
||||
// check the pipeline would happily proceed to poll/download and
|
||||
// either spin until PollTimeout or burn through retries.
|
||||
if errCode := getDotPath(result.Response, "content.errorCode"); errCode != nil && fmt.Sprint(errCode) != "" {
|
||||
msg := getDotPath(result.Response, "content.errorMessage")
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"%s rejected: %s — %v", step.Tool, errCode, msg,
|
||||
))
|
||||
}
|
||||
return result.Response, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(step.PollUntilField) == "" {
|
||||
return invoke()
|
||||
}
|
||||
|
||||
// Polling loop.
|
||||
interval := time.Duration(step.PollIntervalSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 2 * time.Second
|
||||
}
|
||||
timeoutSec := step.PollTimeoutSec
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 300
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
|
||||
|
||||
for {
|
||||
resp, err := invoke()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actual := getDotPath(resp, step.PollUntilField)
|
||||
if actual != nil && fmt.Sprint(actual) == step.PollUntilValue {
|
||||
return resp, nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"pipeline poll timeout after %ds: field %q never reached value %q (last seen: %v)",
|
||||
timeoutSec, step.PollUntilField, step.PollUntilValue, actual,
|
||||
))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executePipelineDownload resolves the URL template, fetches the body via
|
||||
// HTTP GET, and writes it to the path supplied by OutputFlag's user value.
|
||||
// Empty output path → print URL to stdout (terminal-friendly mode).
|
||||
func executePipelineDownload(
|
||||
cmd *cobra.Command,
|
||||
step market.PipelineStep,
|
||||
pctx *pipelineCtx,
|
||||
) (map[string]any, error) {
|
||||
urlAny, err := resolveTemplate(step.DownloadURLField, pctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urlStr := strings.TrimSpace(fmt.Sprint(urlAny))
|
||||
if urlStr == "" {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"pipeline download: URL template %q resolved to empty value",
|
||||
step.DownloadURLField,
|
||||
))
|
||||
}
|
||||
|
||||
outputPath := strings.TrimSpace(pctx.flags[step.OutputFlag])
|
||||
jobID := fmt.Sprint(inferJobIDFromContext(pctx))
|
||||
|
||||
// Always print machine-parseable "key: value" lines. Tests and shell
|
||||
// pipelines that consume the pipeline output (regex / awk) rely on
|
||||
// this exact format. The structured JSON output follows via
|
||||
// output.WriteCommandPayload, so AI / SDK callers still get a typed
|
||||
// response.
|
||||
if jobID != "" {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "jobId: %s\n", jobID)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "downloadUrl: %s\n", urlStr)
|
||||
|
||||
if outputPath == "" {
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": urlStr,
|
||||
"jobId": jobID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// If outputPath is a directory, infer filename from URL basename.
|
||||
if info, statErr := os.Stat(outputPath); statErr == nil && info.IsDir() {
|
||||
filename := inferFilenameFromURL(urlStr)
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("export_%d", time.Now().Unix())
|
||||
}
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
|
||||
resp, err := http.Get(urlStr) //nolint:gosec // user-supplied URL via MCP discovery is expected
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP GET %s: %w", urlStr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP GET %s: status %d", urlStr, resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create %s: %w", outputPath, err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
written, err := io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "导出完成: %s (%d bytes)\n", outputPath, written)
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"downloadUrl": urlStr,
|
||||
"jobId": jobID,
|
||||
"output": outputPath,
|
||||
"size": written,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveArgs applies resolveTemplate to every value in the map.
|
||||
func resolveArgs(args map[string]string, pctx *pipelineCtx) (map[string]any, error) {
|
||||
out := make(map[string]any, len(args))
|
||||
for k, tmpl := range args {
|
||||
v, err := resolveTemplate(tmpl, pctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("arg %q: %w", k, err)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveTemplate evaluates a single template string. Returns the literal
|
||||
// when input does not start with '$'.
|
||||
func resolveTemplate(tmpl string, pctx *pipelineCtx) (any, error) {
|
||||
s := strings.TrimSpace(tmpl)
|
||||
if !strings.HasPrefix(s, "$") {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Split on the first dot: head ("$flag" / "$step") + tail (rest).
|
||||
dot := strings.Index(s, ".")
|
||||
if dot <= 0 || dot == len(s)-1 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("malformed template %q (expected $flag.<name> or $step.<idx>.<path>)", tmpl))
|
||||
}
|
||||
head := s[:dot]
|
||||
tail := s[dot+1:]
|
||||
|
||||
switch head {
|
||||
case "$flag":
|
||||
// tail is a flag alias name (no nested path supported)
|
||||
return pctx.flags[tail], nil
|
||||
case "$step":
|
||||
// tail format: <idx>.<dotPath>
|
||||
secondDot := strings.Index(tail, ".")
|
||||
if secondDot <= 0 || secondDot == len(tail)-1 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("malformed $step template %q (expected $step.<idx>.<dotPath>)", tmpl))
|
||||
}
|
||||
idxStr := tail[:secondDot]
|
||||
dotPath := tail[secondDot+1:]
|
||||
idx, err := strconv.Atoi(idxStr)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q has non-numeric index", tmpl))
|
||||
}
|
||||
if idx < 0 || idx >= len(pctx.stepOutputs) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q references step %d, but only %d step(s) executed so far", tmpl, idx, len(pctx.stepOutputs)))
|
||||
}
|
||||
return getDotPath(pctx.stepOutputs[idx], dotPath), nil
|
||||
default:
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("unknown template prefix %q in %q (allowed: $flag, $step)", head, tmpl))
|
||||
}
|
||||
}
|
||||
|
||||
// getDotPath walks dotPath through nested map[string]any. Returns nil if
|
||||
// any segment is missing or the value isn't a map at an intermediate step.
|
||||
func getDotPath(m map[string]any, dotPath string) any {
|
||||
parts := strings.Split(dotPath, ".")
|
||||
var current any = m
|
||||
for _, p := range parts {
|
||||
nested, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
current = nested[p]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// inferFilenameFromURL extracts the basename from a URL's path component,
|
||||
// stripping query string + fragment. Returns "" if the URL doesn't parse
|
||||
// or has no useful basename.
|
||||
func inferFilenameFromURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
base := path.Base(u.Path)
|
||||
if base == "" || base == "/" || base == "." {
|
||||
return ""
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// inferJobIDFromContext walks prior step outputs looking for a `jobId`
|
||||
// field at top level or one level under common MCP wrappers ("content" /
|
||||
// "result"), so the synthetic download response can echo it back to the
|
||||
// user. Returns "" when no jobId is present anywhere in prior responses.
|
||||
func inferJobIDFromContext(pctx *pipelineCtx) any {
|
||||
candidates := []string{"jobId", "content.jobId", "result.jobId"}
|
||||
for i := len(pctx.stepOutputs) - 1; i >= 0; i-- {
|
||||
for _, p := range candidates {
|
||||
if v := getDotPath(pctx.stepOutputs[i], p); v != nil && fmt.Sprint(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractFlagValuesByAlias reads the cobra command's flag values keyed by
|
||||
// the FlagBinding's primary CLI flag name, so the pipeline executor can
|
||||
// resolve "$flag.<name>" templates in O(1). Pipeline-local flags are
|
||||
// always included (they are the whole point of the lookup).
|
||||
//
|
||||
// Note on key choice: buildOverrideBindings populates FlagName from the
|
||||
// envelope's `alias` field (or kebab-case of the MCP property name when
|
||||
// alias is empty), and leaves the FlagBinding.Alias struct field empty —
|
||||
// so $flag templates reference the user-visible CLI flag name, e.g.
|
||||
// "$flag.node" matches `--node`.
|
||||
func extractFlagValuesByAlias(cmd *cobra.Command, bindings []FlagBinding) map[string]string {
|
||||
flags := cmd.Flags()
|
||||
out := make(map[string]string, len(bindings))
|
||||
for _, b := range bindings {
|
||||
primary := strings.TrimSpace(b.FlagName)
|
||||
if primary == "" {
|
||||
primary = strings.TrimSpace(b.Alias)
|
||||
}
|
||||
if primary == "" {
|
||||
continue
|
||||
}
|
||||
// Try the primary flag name first, then any of the extra aliases.
|
||||
// Whichever the user actually set wins; if none was set, the
|
||||
// cobra-level default value is returned.
|
||||
candidates := make([]string, 0, 2+len(b.Aliases))
|
||||
candidates = append(candidates, primary)
|
||||
if a := strings.TrimSpace(b.Alias); a != "" && a != primary {
|
||||
candidates = append(candidates, a)
|
||||
}
|
||||
for _, a := range b.Aliases {
|
||||
if a = strings.TrimSpace(a); a != "" {
|
||||
candidates = append(candidates, a)
|
||||
}
|
||||
}
|
||||
var value string
|
||||
for _, c := range candidates {
|
||||
f := flags.Lookup(c)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
value = f.Value.String()
|
||||
if f.Changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
out[primary] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonRoundTrip marshals + unmarshals so user-provided strings come out
|
||||
// the other side as Go primitives where appropriate. Unused for now —
|
||||
// the resolveTemplate path returns strings as-is to keep the contract
|
||||
// simple; tools that need JSON-shaped values can use the existing
|
||||
// `transform: "json_parse_strict"` on the relevant flag (post-pipeline
|
||||
// composition is not in scope for the MVP).
|
||||
var _ = json.Unmarshal
|
||||
+58
-14
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -60,12 +61,16 @@ type FlagBinding struct {
|
||||
// parameter. Any of them being set satisfies Required, and the value
|
||||
// is resolved via firstChangedFlag(FlagName, Alias, Aliases...).
|
||||
// Mirrors cmdutil.ValidateRequiredFlagWithAliases / FlagOrFallback.
|
||||
Aliases []string
|
||||
Short string
|
||||
Property string
|
||||
Kind ValueKind
|
||||
Usage string
|
||||
Required bool
|
||||
Aliases []string
|
||||
// PipelineLocal, when true, marks this binding as CLI-side only — its
|
||||
// value is consumed by the pipeline executor (e.g. as an HTTP
|
||||
// download destination) and NOT forwarded to any MCP tool's params.
|
||||
PipelineLocal bool
|
||||
Short string
|
||||
Property string
|
||||
Kind ValueKind
|
||||
Usage string
|
||||
Required bool
|
||||
// Default is the cobra-level flag default value as a string. Parsed
|
||||
// into the Kind-appropriate primitive at registration time. Empty
|
||||
// string keeps the existing zero-value default. This only affects
|
||||
@@ -82,14 +87,19 @@ type FlagBinding struct {
|
||||
type Normalizer func(cmd *cobra.Command, params map[string]any) error
|
||||
|
||||
type Route struct {
|
||||
Use string
|
||||
Aliases []string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Hidden bool
|
||||
Target Target
|
||||
Bindings []FlagBinding
|
||||
Use string
|
||||
Aliases []string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Hidden bool
|
||||
Target Target
|
||||
Bindings []FlagBinding
|
||||
// Pipeline, when non-empty, replaces the single-tool dispatch with a
|
||||
// multi-step orchestration. NewDirectCommand sees this and wires the
|
||||
// pipeline executor into RunE instead of the standard
|
||||
// invoke-then-output flow. See internal/compat/pipeline.go.
|
||||
Pipeline []market.PipelineStep
|
||||
Normalizer Normalizer
|
||||
// OutputTransform, when non-nil, post-processes the MCP response payload
|
||||
// (rename / drop / columns) before the formatter emits it. Wired up from
|
||||
@@ -277,6 +287,33 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
delete(params, "_blocked")
|
||||
}
|
||||
|
||||
// §pipeline: when the override declares a multi-step pipeline,
|
||||
// dispatch via the pipeline executor instead of the single-tool
|
||||
// invoke-then-output flow. The executor reads flag values by
|
||||
// alias (so $flag.<alias> templates resolve), walks each step,
|
||||
// handles polling + downloads, and returns the last "call"
|
||||
// step's response as the payload to the formatter.
|
||||
if len(route.Pipeline) > 0 {
|
||||
flagValues := extractFlagValuesByAlias(cmd, route.Bindings)
|
||||
resp, err := runPipeline(cmd.Context(), cmd, runner, route, flagValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := executor.Result{
|
||||
Invocation: executor.NewCompatibilityInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
route.Target.CanonicalProduct,
|
||||
"pipeline",
|
||||
params,
|
||||
),
|
||||
Response: resp,
|
||||
}
|
||||
if route.OutputTransform != nil && result.Response != nil {
|
||||
result.Response = route.OutputTransform(result.Response)
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
|
||||
}
|
||||
|
||||
invocation := executor.NewCompatibilityInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
route.Target.CanonicalProduct,
|
||||
@@ -706,6 +743,13 @@ func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[st
|
||||
}
|
||||
params := make(map[string]any)
|
||||
for _, binding := range bindings {
|
||||
// Pipeline-local flags exist purely for the pipeline executor
|
||||
// (e.g. --output destination paths) and must never be forwarded
|
||||
// to MCP tools as params, otherwise the upstream API would
|
||||
// either reject the unknown field or silently store junk.
|
||||
if binding.PipelineLocal {
|
||||
continue
|
||||
}
|
||||
if binding.Positional {
|
||||
// Pure positional (no flag aliases) is handled by
|
||||
// collectPositionalBindings. Dual-mode positional bindings
|
||||
|
||||
@@ -26,7 +26,8 @@ import (
|
||||
)
|
||||
|
||||
// ApplyTransform applies a named transform rule to a value.
|
||||
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse, enum_map.
|
||||
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
|
||||
// json_parse_strict, enum_map.
|
||||
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
|
||||
switch strings.TrimSpace(transform) {
|
||||
case "":
|
||||
@@ -37,6 +38,8 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
|
||||
return transformCSVToArray(value)
|
||||
case "json_parse":
|
||||
return transformJSONParse(value)
|
||||
case "json_parse_strict":
|
||||
return transformJSONParseStrict(value)
|
||||
case "enum_map":
|
||||
return transformEnumMap(value, args)
|
||||
default:
|
||||
@@ -151,6 +154,30 @@ func transformJSONParse(value any) (any, error) {
|
||||
)
|
||||
}
|
||||
|
||||
// transformJSONParseStrict is the strict variant of json_parse: only accepts
|
||||
// well-formed JSON, rejecting input that the YAML fallback would otherwise
|
||||
// silently coerce to a scalar string. Use when the upstream tool requires a
|
||||
// structured array/object value and "garbage in → empty out" is unacceptable.
|
||||
func transformJSONParseStrict(value any) (any, error) {
|
||||
s, ok := toString(value)
|
||||
if !ok {
|
||||
return value, nil
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return value, nil
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
|
||||
return nil, apperrors.NewValidation(
|
||||
"json_parse_strict: input is not valid JSON; " +
|
||||
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
|
||||
"as strict JSON (e.g. '[{\"key\":\"value\"}]') or use `json_parse` for YAML-tolerant parsing",
|
||||
)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func transformEnumMap(value any, args map[string]any) (any, error) {
|
||||
s, ok := toString(value)
|
||||
if !ok {
|
||||
|
||||
@@ -268,15 +268,20 @@ func newAttendanceShiftListCommand(runner executor.Runner) *cobra.Command {
|
||||
|
||||
func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "summary",
|
||||
Short: "查询某个人的考勤统计摘要",
|
||||
Long: "查询某个人的考勤统计摘要。--user 与 --date 均必填。",
|
||||
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"`,
|
||||
Use: "summary",
|
||||
Short: "查询某个人的考勤统计摘要",
|
||||
Long: `查询某个人的考勤统计摘要。
|
||||
|
||||
--user、--date、--stats-type 均必填。
|
||||
钉钉服务端业务层强制要求 --stats-type(week/month),不填会返回 C0002 统计类型错误。`,
|
||||
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
|
||||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
userID, _ := cmd.Flags().GetString("user")
|
||||
workDateStr, _ := cmd.Flags().GetString("date")
|
||||
statsType, _ := cmd.Flags().GetString("stats-type")
|
||||
if userID == "" {
|
||||
return apperrors.NewValidation("--user is required, provide DingTalk user ID")
|
||||
}
|
||||
@@ -287,10 +292,17 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
|
||||
if err != nil {
|
||||
return apperrors.NewValidation("--date format error, use yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
if statsType == "" {
|
||||
return apperrors.NewValidation(`--stats-type is required (week|month), enforced by DingTalk server`)
|
||||
}
|
||||
if statsType != "week" && statsType != "month" {
|
||||
return apperrors.NewValidation(`--stats-type must be "week" or "month"`)
|
||||
}
|
||||
// Build nested structure QueryUserAttendVO
|
||||
vo := map[string]any{
|
||||
"userId": userID,
|
||||
"queryDate": workDateStr,
|
||||
"statsType": statsType,
|
||||
}
|
||||
params := map[string]any{
|
||||
"QueryUserAttendVO": vo,
|
||||
@@ -311,6 +323,7 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
|
||||
}
|
||||
cmd.Flags().String("user", "", "钉钉用户 ID(必填)")
|
||||
cmd.Flags().String("date", "", "工作日期,格式 yyyy-MM-dd HH:mm:ss,如 2026-03-12 15:00:00(必填)")
|
||||
cmd.Flags().String("stats-type", "", "统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求)")
|
||||
preferLegacyLeaf(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -252,9 +252,9 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("group", "", "群会话 openConversationId (群聊必填)")
|
||||
cmd.Flags().String("robot-code", "", "机器人 Code")
|
||||
cmd.Flags().String("text", "", "消息内容 (Markdown)")
|
||||
cmd.Flags().String("title", "", "消息标题")
|
||||
cmd.Flags().String("robot-code", "", "机器人 Code (必填)")
|
||||
cmd.Flags().String("text", "", "消息内容 Markdown (必填)")
|
||||
cmd.Flags().String("title", "", "消息标题 (必填)")
|
||||
cmd.Flags().String("users", "", "接收者 userId 列表,逗号分隔,最多 20 个 (单聊必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -204,8 +204,18 @@ func newReportCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
Use: "create",
|
||||
Short: "创建日志",
|
||||
Long: `按模版创建一条日志。--contents 为 JSON 数组,每项需含 key、sort、content、contentType、type,
|
||||
与远程 create_report 一致;可先通过 report template list / template detail 取得 templateId 与控件定义。`,
|
||||
Example: ` dws report create --template-id TPL_ID --contents '[{"content":"完成开发","sort":"0","key":"今日完成","contentType":"markdown","type":"1"}]'
|
||||
与远程 create_report 一致;可先通过 report template list / template detail 取得 templateId 与控件定义。
|
||||
|
||||
注意:每个 contents 项的 key 必须精确等于模板的 field_name(中文/英文逐字匹配,不是控件 ID 或别名)。
|
||||
key 与 field_name 不一致时钉钉 API 会返回 SYSTEM_ERROR (success=false),CLI 层不会预先拦截。
|
||||
请先用 report template detail 查到 report_template_fields[].field_name 后再填。`,
|
||||
Example: ` # Step 1:查模板,取 report_template_fields[].field_name 当作 contents[].key
|
||||
dws report template detail --name "周报"
|
||||
|
||||
# Step 2:用上一步拿到的 field_name 作为 key 创建日志
|
||||
dws report create --template-id TPL_ID --contents '[{"key":"<field_name>","sort":"0","content":"完成开发","contentType":"markdown","type":"1"}]'
|
||||
|
||||
# 同时通知到接收人单聊
|
||||
dws report create --template-id TPL_ID --contents '[...]' --to-chat --to-user-ids userId1,userId2`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -251,7 +261,7 @@ func newReportCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("template-id", "", "日志模版 ID (必填)")
|
||||
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填),每项含 key/sort/content/contentType/type")
|
||||
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填),每项含 key/sort/content/contentType/type;key 必须精确等于模板 field_name (用 report template detail --name <模板名> 查询)")
|
||||
cmd.Flags().String("dd-from", "dws", "创建来源标识")
|
||||
cmd.Flags().Bool("to-chat", false, "是否发送到日志接收人单聊")
|
||||
cmd.Flags().String("to-user-ids", "", "接收人 userId,逗号分隔 (可选)")
|
||||
|
||||
@@ -83,7 +83,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
Example: ` dws todo task create --title "修复线上Bug" --executors userId1,userId2 --priority 40
|
||||
dws todo task create --title "提交报告" --executors userId1 --due "2026-03-10T18:00:00+08:00"
|
||||
|
||||
# 查询 userId: dws contact user search --keyword "姓名"`,
|
||||
# 查询 userId: dws contact user search --query "姓名"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -135,7 +135,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("title", "", i18n.T("待办标题 (必填)"))
|
||||
cmd.Flags().String("executors", "", i18n.T("执行者 userId 列表 (必填)"))
|
||||
cmd.Flags().String("executors", "", i18n.T("执行者 userId 列表,逗号分隔 (必填)。注意: 此处是通讯录 userId,可通过 dws contact user search --query 姓名 查询"))
|
||||
cmd.Flags().String("due", "", i18n.T("截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)"))
|
||||
cmd.Flags().String("priority", "", i18n.T("优先级: 10低/20普通/30较高/40紧急"))
|
||||
cmd.Flags().String("recurrence", "", i18n.T("循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1"))
|
||||
@@ -152,8 +152,20 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
|
||||
func newTodoTaskListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("查询待办列表"),
|
||||
Use: "list",
|
||||
Short: i18n.T("查询待办列表"),
|
||||
Long: i18n.T(`查询当前用户在当前企业的待办列表。
|
||||
|
||||
覆盖范围:
|
||||
返回当前用户作为"执行者"(executor) 的待办。
|
||||
仅参与但不执行的待办、自己创建但交给他人执行的待办不在返回范围内。
|
||||
|
||||
当前列表能力面向"个人待办",即钉钉待办模块中展示的待办任务,
|
||||
不包含 OA 审批流待办、Teambition 项目任务等其他业务线的待办。
|
||||
|
||||
分页:
|
||||
默认每页 20 条。--size 超过 20 时,CLI 会自动进行多次 API 调用
|
||||
并合并结果(自动分页),无需手动翻页。`),
|
||||
Example: ` dws todo task list --page 1 --size 20 --status false`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -362,6 +374,23 @@ func newTodoTaskGetCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: i18n.T("待办详情"),
|
||||
Long: i18n.T(`查看待办任务详情。
|
||||
|
||||
返回字段说明:
|
||||
creatorId / executorIds / participantIds / modifierId
|
||||
待办系统内部人员标识(短数字 ID,如 6380165826),
|
||||
不是通讯录 userId(如 035551044606950179)或 unionid。
|
||||
这些 ID 在待办系统内对同一用户稳定,但无法直接用于通讯录 API 查询。
|
||||
如需获取人员姓名,可参考返回中的 creatorInfo / executorInfos /
|
||||
participantInfos 字段(包含 name 属性)。
|
||||
|
||||
bizTag / source
|
||||
底层待办引擎的实现标识。即使是在钉钉客户端直接创建的普通个人待办,
|
||||
也会返回 "teambition",这是内核实现细节,不代表来自 Teambition 产品。
|
||||
|
||||
tenantId / tenantType
|
||||
待办所属的租户标识,非企业 corpId。tenantType 为 "user" 时
|
||||
tenantId 是用户维度标识;为 "org" 时是组织维度标识。`),
|
||||
Example: ` dws todo task get --task-id <taskId>
|
||||
|
||||
# 查询 taskId: dws todo task list`,
|
||||
|
||||
@@ -173,8 +173,16 @@ type CLIOutputFormat struct {
|
||||
|
||||
// CLIToolOverride maps an MCP tool to a CLI command with flag aliases and transforms.
|
||||
type CLIToolOverride struct {
|
||||
CLIName string `json:"cliName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CLIName string `json:"cliName"`
|
||||
// CLIAliases registers additional cobra command aliases for the same MCP
|
||||
// tool, so the leaf command can be invoked under multiple names without
|
||||
// duplicating the override. Mirrors cobra.Command.Aliases. Each alias is
|
||||
// added to the cobra Aliases slice; conflicts with existing siblings are
|
||||
// silently ignored by cobra. Use for command-name normalisation (e.g.
|
||||
// `range read` accepts `range get` as an alias) or hardcoded-command
|
||||
// migration paths. Empty / nil means no extra aliases.
|
||||
CLIAliases []string `json:"cliAliases,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// Example, when non-empty, is wired to cobra.Command.Example to render
|
||||
// the "Examples:" section in --help. Mirrors hardcoded helper commands'
|
||||
// Example field (e.g. wukong/products/oa.go list-forms). Empty value
|
||||
@@ -212,6 +220,56 @@ type CLIToolOverride struct {
|
||||
// fields (Flags / BodyWrapper / IsSensitive / ServerOverride) are
|
||||
// ignored. Use for deprecated leaf commands that moved to a new path.
|
||||
RedirectTo string `json:"redirectTo,omitempty"`
|
||||
// Pipeline declares a multi-step orchestration: each step calls one
|
||||
// MCP tool, with subsequent steps able to reference prior step outputs
|
||||
// in their argument templates. PollUntilField/Value turn a step into a
|
||||
// polling loop (for async jobs); type:"download" turns a step into an
|
||||
// HTTP download sink that writes to a CLI-local --output flag. When
|
||||
// Pipeline is non-empty, dispatch ignores the parent toolOverrides map
|
||||
// key (no single "primary tool"); the executor walks the steps in
|
||||
// order. CLI surface (CLIName / Group / Flags) still comes from the
|
||||
// parent override; flags can be marked PipelineLocal=true to be
|
||||
// consumed by the executor without being forwarded to MCP tools.
|
||||
//
|
||||
// See internal/compat/pipeline.go for the executor + envelope examples.
|
||||
Pipeline []PipelineStep `json:"pipeline,omitempty"`
|
||||
}
|
||||
|
||||
// PipelineStep declares one step in a multi-step CLIToolOverride.Pipeline.
|
||||
// Templates supported in Args / DownloadURLField:
|
||||
//
|
||||
// $flag.<aliasName> — value of the user's CLI flag whose alias is
|
||||
// <aliasName> (resolved at dispatch time).
|
||||
// $step.<idx>.<dotPath> — field from a prior step's response, e.g.
|
||||
// "$step.0.jobId" or "$step.1.result.url".
|
||||
// literal value — passed through unchanged.
|
||||
type PipelineStep struct {
|
||||
// Type controls dispatch. Empty / "call" invokes Tool as an MCP tool.
|
||||
// "download" treats this step as an HTTP GET sink (no MCP tool is
|
||||
// invoked); URL is resolved from DownloadURLField.
|
||||
Type string `json:"type,omitempty"`
|
||||
// Tool is the MCP tool name to invoke for type=="call".
|
||||
Tool string `json:"tool,omitempty"`
|
||||
// Args maps MCP tool parameter names to template strings.
|
||||
Args map[string]string `json:"args,omitempty"`
|
||||
// PollUntilField, when non-empty (with PollUntilValue), turns this
|
||||
// step into a polling loop: invoke repeatedly with the same Args
|
||||
// until response[<PollUntilField>] equals PollUntilValue (string
|
||||
// compare). Use for async-job patterns where a status field
|
||||
// transitions to a terminal value (e.g. "done" / "succeeded").
|
||||
PollUntilField string `json:"pollUntilField,omitempty"`
|
||||
PollUntilValue string `json:"pollUntilValue,omitempty"`
|
||||
PollIntervalSec int `json:"pollIntervalSec,omitempty"` // default 2 when polling
|
||||
PollTimeoutSec int `json:"pollTimeoutSec,omitempty"` // default 300 when polling
|
||||
// DownloadURLField (type=="download") is a $step.X.field template
|
||||
// that resolves to an HTTP URL. The body is fetched via GET and
|
||||
// written to the path given by OutputFlag's value. If OutputFlag's
|
||||
// value is empty, the URL is printed to stdout for the user.
|
||||
DownloadURLField string `json:"downloadURLField,omitempty"`
|
||||
// OutputFlag (type=="download") names the CLI flag (alias) whose
|
||||
// user-supplied value is the local destination path. When the path
|
||||
// is a directory, the filename is inferred from the URL's basename.
|
||||
OutputFlag string `json:"outputFlag,omitempty"`
|
||||
}
|
||||
|
||||
// CLIFlagOverride describes how to map an MCP parameter to a CLI flag.
|
||||
@@ -261,6 +319,12 @@ type CLIFlagOverride struct {
|
||||
// Resolution comes from edition.Hooks.RuntimeDefaults; open-source core
|
||||
// only recognises the placeholder set. See schema v3 §2.3.
|
||||
RuntimeDefault string `json:"runtimeDefault,omitempty"`
|
||||
// PipelineLocal, when true, marks this flag as CLI-side only — its
|
||||
// value is consumed by the pipeline executor (e.g. as an HTTP
|
||||
// download destination) and NOT forwarded to any MCP tool's params.
|
||||
// Only meaningful when the enclosing CLIToolOverride.Pipeline is set.
|
||||
// Use for flags like `--output` that describe local destination paths.
|
||||
PipelineLocal bool `json:"pipelineLocal,omitempty"`
|
||||
}
|
||||
|
||||
type CLITool struct {
|
||||
|
||||
@@ -211,6 +211,11 @@ func (r *ToolCallResult) UnmarshalJSON(data []byte) error {
|
||||
// "accepted but never responded" servers faster, and explicit TLS/dial timeouts.
|
||||
func defaultTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars; a custom
|
||||
// Transport without an explicit Proxy field would otherwise bypass
|
||||
// proxies entirely, which breaks sandboxed/air-gapped deployments
|
||||
// that rely on an outbound proxy (#236).
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDefaultTransportHonoursHTTPProxyEnv guards the fix for issue #236:
|
||||
// the MCP HTTP transport must honour HTTP_PROXY / HTTPS_PROXY env vars.
|
||||
// A custom Transport built without an explicit Proxy field defaults to
|
||||
// "no proxy" — sandboxed deployments behind an outbound proxy would then
|
||||
// silently bypass the proxy and fail.
|
||||
//
|
||||
// We assert tr.Proxy points at http.ProxyFromEnvironment rather than invoking
|
||||
// it, because http.ProxyFromEnvironment memoises env on first call (Go's
|
||||
// envProxyOnce). Test ordering with anything else that reads proxy env early
|
||||
// would make a value-based assertion flaky. The runtime reads env at process
|
||||
// boot — pointing at the stdlib resolver is the contract we need.
|
||||
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tr := defaultTransport()
|
||||
if tr.Proxy == nil {
|
||||
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY/HTTPS_PROXY env will be ignored (regression of #236)")
|
||||
}
|
||||
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
|
||||
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
|
||||
if gotPC != wantPC {
|
||||
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,15 @@ const (
|
||||
)
|
||||
|
||||
// knownSkillDirs lists all known Agent skill directories (relative to $HOME).
|
||||
// Kept in sync with build/npm/install.js AGENT_DIRS.
|
||||
// Kept in sync with:
|
||||
// - build/npm/install.js AGENT_DIRS
|
||||
// - scripts/install.sh for-in list
|
||||
// - scripts/install.ps1 $AgentDirs
|
||||
// - scripts/install-skills.sh for-in list
|
||||
// - build/homebrew.rb.tmpl targets
|
||||
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
|
||||
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
|
||||
//
|
||||
// The first entry (.agents/skills) is always updated; subsequent entries are
|
||||
// only updated when their parent directory already exists.
|
||||
var knownSkillDirs = []string{
|
||||
@@ -36,6 +44,7 @@ var knownSkillDirs = []string{
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
".openclaw/skills",
|
||||
".hermes/skills",
|
||||
}
|
||||
|
||||
// skillDirBlacklist contains parent directories whose skills are managed by
|
||||
|
||||
@@ -116,7 +116,8 @@ install_skills_to_root() {
|
||||
".amp/skills" \
|
||||
".kiro/skills" \
|
||||
".trae/skills" \
|
||||
".openclaw/skills"
|
||||
".openclaw/skills" \
|
||||
".hermes/skills"
|
||||
do
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
|
||||
+2
-1
@@ -44,7 +44,8 @@ $AgentDirs = @(
|
||||
".amp\skills",
|
||||
".kiro\skills",
|
||||
".trae\skills",
|
||||
".openclaw\skills"
|
||||
".openclaw\skills",
|
||||
".hermes\skills"
|
||||
)
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
+2
-1
@@ -201,7 +201,8 @@ install_skills_to_homes() {
|
||||
".amp/skills" \
|
||||
".kiro/skills" \
|
||||
".trae/skills" \
|
||||
".openclaw/skills"
|
||||
".openclaw/skills" \
|
||||
".hermes/skills"
|
||||
do
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
|
||||
@@ -37,6 +37,7 @@ HOME_AGENT_PARENTS="
|
||||
.kiro
|
||||
.trae
|
||||
.openclaw
|
||||
.hermes
|
||||
"
|
||||
HOME_SKILL_TARGETS="
|
||||
.agents/skills/dws
|
||||
@@ -52,6 +53,7 @@ HOME_SKILL_TARGETS="
|
||||
.kiro/skills/dws
|
||||
.trae/skills/dws
|
||||
.openclaw/skills/dws
|
||||
.hermes/skills/dws
|
||||
"
|
||||
cleanup() {
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
```bash
|
||||
# 1. 搜索同事 userId
|
||||
dws contact user search --keyword "张三" --format json
|
||||
dws contact user search --query "张三" --format json
|
||||
|
||||
# 2. 创建日程
|
||||
dws calendar event create --title "会议" \
|
||||
@@ -107,7 +107,7 @@ dws calendar participant add --event <EVENT_ID> --users <USER_ID> --format json
|
||||
|
||||
```bash
|
||||
# 1. 搜索同事 userId
|
||||
dws contact user search --keyword "张三" --format json
|
||||
dws contact user search --query "张三" --format json
|
||||
|
||||
# 2. 创建待办
|
||||
dws todo task create --title "任务内容" --executors <USER_ID> --format json
|
||||
|
||||
@@ -32,12 +32,16 @@ Flags:
|
||||
Usage:
|
||||
dws attendance summary [flags]
|
||||
Example:
|
||||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"
|
||||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
|
||||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week
|
||||
Flags:
|
||||
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
|
||||
--user string 钉钉用户 ID (必填)
|
||||
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
|
||||
--stats-type string 统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求;CLI 层会直接拒绝缺失/非法值)
|
||||
--user string 钉钉用户 ID (必填)
|
||||
```
|
||||
|
||||
> ⚠️ **重要**:`--stats-type` 在钉钉 schema 中标记为 `required: []`(看似可选),但服务端业务层**强制要求**,不传服务端会回 `C0002 / 统计类型错误`。CLI 已在客户端层做了 fail-fast:缺失或非 `week`/`month` 的取值会直接被 CLI 拒绝,不会发出请求。
|
||||
|
||||
### 查询考勤组与考勤规则
|
||||
```
|
||||
Usage:
|
||||
|
||||
@@ -232,31 +232,63 @@ Flags:
|
||||
|
||||
拉取指定群聊或单聊的会话消息内容。
|
||||
|
||||
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
|
||||
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws chat message list [flags]
|
||||
Example:
|
||||
# 拉取群聊中某个时间点之后的消息
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||||
# 拉取单聊消息(通过 userId)
|
||||
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||||
# 拉取单聊消息(通过 openDingTalkId)
|
||||
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
|
||||
# 拉取某个时间点之前的消息(向过去翻页)
|
||||
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
|
||||
Flags:
|
||||
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
|
||||
--group string 群聊 openconversation_id(群聊时必填)
|
||||
--limit int 返回数量,不传则不限制
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(不传则默认拉取最新消息)
|
||||
--user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
|
||||
--open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
|
||||
|
||||
注意:
|
||||
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
|
||||
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
|
||||
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
|
||||
- 如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息,需要调用 dws chat message list-topic-replies 拉取话题的回复内容列表,openConvThreadId 作为 --topic-id 参数
|
||||
```
|
||||
|
||||
### 分页翻页说明(重要)
|
||||
|
||||
`message list` 的翻页方式与 `message list-all` **完全不同**,请勿混淆:
|
||||
|
||||
| 命令 | 翻页参数 | 翻页值来源 | 值格式 |
|
||||
|------|---------|-----------|--------|
|
||||
| `message list` | `--time` | 上一页结果中**最后一条消息的 `createTime` 字段** | `yyyy-MM-dd HH:mm:ss`(如 `"2025-03-01 14:30:00"`) |
|
||||
| `message list-all` | `--cursor` | 上一页响应中的 `nextCursor` 字段 | 字符串(如 `"abc123token"`) |
|
||||
|
||||
**翻页步骤(message list):**
|
||||
|
||||
1. **首次请求**:指定起始时间
|
||||
```bash
|
||||
dws chat message list --group <id> --time "2025-03-01 00:00:00" --limit 50 --format json
|
||||
```
|
||||
2. **检查响应**:查看 `hasMore` 字段
|
||||
- `hasMore: false` → 没有更多消息,翻页结束
|
||||
- `hasMore: true` → 还有更多消息,继续下一步
|
||||
3. **获取翻页时间**:取返回结果中**最后一条消息**的 `createTime` 字段值(如 `"2025-03-01 14:30:00"`)
|
||||
4. **下一页请求**:将该 `createTime` 作为 `--time` 传入
|
||||
```bash
|
||||
dws chat message list --group <id> --time "2025-03-01 14:30:00" --limit 50 --format json
|
||||
```
|
||||
5. 重复步骤 2-4 直到 `hasMore: false`
|
||||
|
||||
> ⚠️ **常见错误**:
|
||||
> - **不要把 `nextCursor` 传给 `--time`**:响应中的 `nextCursor` 字段(纯数字时间戳如 `1776684611219`)**不是给 `--time` 用的**。`--time` 只接受 `yyyy-MM-dd HH:mm:ss` 格式。将 `nextCursor` 传给 `--time` 会导致返回相同页面,陷入死循环。`nextCursor` 仅用于 `message list-all` 的 `--cursor` 参数。
|
||||
> - **不要把 `nextCursor` 传给 `--forward`**:`--forward` 只接受 `true`(拉给定时间之后的消息)或 `false`(拉给定时间之前的消息),不是时间戳或游标参数。
|
||||
|
||||
---
|
||||
|
||||
## message list-all — 拉取指定时间范围内当前用户的所有会话消息
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package cli_compat_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -146,7 +148,11 @@ func TestAttendanceShiftList_should_filter_empty_user_ids(t *testing.T) {
|
||||
func TestAttendanceSummary_should_call_tool_with_user_and_date(t *testing.T) {
|
||||
cap := setupTestDeps(t, "attendance")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "month",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -157,8 +163,9 @@ func TestAttendanceSummary_should_pass_user_and_date_flags(t *testing.T) {
|
||||
cap := setupTestDeps(t, "attendance")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "month",
|
||||
})
|
||||
last := cap.last()
|
||||
if last == nil {
|
||||
@@ -200,10 +207,14 @@ func TestAttendanceSummary_should_error_when_user_missing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
|
||||
func TestAttendanceSummary_should_pass_user_id_through_vo(t *testing.T) {
|
||||
cap := setupTestDeps(t, "attendance")
|
||||
root := buildRoot()
|
||||
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U002", "date": "2026-03-12 15:00:00"})
|
||||
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U002",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "month",
|
||||
})
|
||||
last := cap.last()
|
||||
if last == nil {
|
||||
t.Fatal("no call captured")
|
||||
@@ -220,7 +231,11 @@ func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
|
||||
func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
|
||||
cap := setupTestDepsWithDryRun(t, "attendance")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "month",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -229,6 +244,97 @@ func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// execSummaryDryRun runs attendance summary with --dry-run and returns the
|
||||
// parsed QueryUserAttendVO map from the helper_invocation payload. This is
|
||||
// necessary because the attendance handler is a custom helper (not a dynamic
|
||||
// MCP-route), so the test framework's mcpCallCapture cannot intercept the
|
||||
// call in non-dry-run mode.
|
||||
func execSummaryDryRun(t *testing.T, flags map[string]string) map[string]any {
|
||||
t.Helper()
|
||||
root := buildRoot()
|
||||
_ = setupTestDeps(t, "attendance")
|
||||
|
||||
cliArgs := []string{"-f", "json", "attendance", "summary", "--dry-run"}
|
||||
for k, v := range flags {
|
||||
if v != "" {
|
||||
cliArgs = append(cliArgs, "--"+k, v)
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(cliArgs)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected CLI error: %v", err)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Params map[string]any `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("failed to parse dry-run output as JSON: %v\noutput: %s", err, out.String())
|
||||
}
|
||||
vo, ok := payload.Params["QueryUserAttendVO"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected QueryUserAttendVO map in dry-run params, got %T: %v", payload.Params["QueryUserAttendVO"], payload.Params)
|
||||
}
|
||||
return vo
|
||||
}
|
||||
|
||||
func TestAttendanceSummary_should_pass_stats_type_when_provided(t *testing.T) {
|
||||
vo := execSummaryDryRun(t, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "month",
|
||||
})
|
||||
if vo["statsType"] != "month" {
|
||||
t.Errorf("expected statsType=month in VO, got %v", vo["statsType"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceSummary_should_pass_stats_type_week(t *testing.T) {
|
||||
vo := execSummaryDryRun(t, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "week",
|
||||
})
|
||||
if vo["statsType"] != "week" {
|
||||
t.Errorf("expected statsType=week in VO, got %v", vo["statsType"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceSummary_should_error_when_stats_type_missing(t *testing.T) {
|
||||
_ = setupTestDeps(t, "attendance")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --stats-type is missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stats-type") {
|
||||
t.Errorf("expected error message to mention stats-type, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceSummary_should_error_when_stats_type_invalid(t *testing.T) {
|
||||
_ = setupTestDeps(t, "attendance")
|
||||
root := buildRoot()
|
||||
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
|
||||
"user": "U001",
|
||||
"date": "2026-03-12 15:00:00",
|
||||
"stats-type": "foobar",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --stats-type is neither week nor month")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "week") || !strings.Contains(err.Error(), "month") {
|
||||
t.Errorf("expected error message to mention week and month, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── attendance rules ───────────────────────────────────────
|
||||
|
||||
func TestAttendanceRules_should_call_tool_with_date(t *testing.T) {
|
||||
|
||||
@@ -23,6 +23,7 @@ var expectedPackagedSkillTargets = []string{
|
||||
".kiro/skills/dws",
|
||||
".trae/skills/dws",
|
||||
".openclaw/skills/dws",
|
||||
".hermes/skills/dws",
|
||||
}
|
||||
|
||||
// seedDistArtifacts creates fake goreleaser output archives (empty tar.gz/zip
|
||||
|
||||
+8
-8
@@ -776,13 +776,13 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**contact_contact_dept_search_001**
|
||||
- Prompt: 搜索部门,keyword 为 技术部
|
||||
- Expected: `dws contact dept search --keyword 技术部 --format json`
|
||||
- Flags: `--keyword` = `技术部`
|
||||
- Expected: `dws contact dept search --query 技术部 --format json`
|
||||
- Flags: `--query` = `技术部`
|
||||
|
||||
**contact_contact_dept_search_002**
|
||||
- Prompt: 搜一下技术部的部门信息
|
||||
- Expected: `dws contact dept search --keyword "技术部" --format json`
|
||||
- Flags: `--keyword` = `技术部`
|
||||
- Expected: `dws contact dept search --query "技术部" --format json`
|
||||
- Flags: `--query` = `技术部`
|
||||
|
||||
#### `dws contact user get`
|
||||
|
||||
@@ -810,13 +810,13 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
|
||||
|
||||
**contact_contact_user_search_001**
|
||||
- Prompt: 按关键词搜索用户,keyword 为 张三
|
||||
- Expected: `dws contact user search --keyword 张三 --format json`
|
||||
- Flags: `--keyword` = `张三`
|
||||
- Expected: `dws contact user search --query 张三 --format json`
|
||||
- Flags: `--query` = `张三`
|
||||
|
||||
**contact_contact_user_search_002**
|
||||
- Prompt: 帮我找一下张三的联系方式
|
||||
- Expected: `dws contact user search --keyword "张三" --format json`
|
||||
- Flags: `--keyword` = `张三`
|
||||
- Expected: `dws contact user search --query "张三" --format json`
|
||||
- Flags: `--query` = `张三`
|
||||
|
||||
#### `dws contact user search-mobile`
|
||||
|
||||
|
||||
@@ -1014,7 +1014,7 @@
|
||||
**contact_contact_dept_search_001** ✅ PASS
|
||||
|
||||
- Prompt: 搜索部门,keyword 为 技术部
|
||||
- Expected: `dws contact dept search --keyword 技术部 --format json`
|
||||
- Expected: `dws contact dept search --query 技术部 --format json`
|
||||
- Skill Reference: references/products/contact.md
|
||||
- Command path: PASS (contact dept search)
|
||||
- Flags: PASS (1 flags validated)
|
||||
@@ -1022,7 +1022,7 @@
|
||||
**contact_contact_dept_search_002** ✅ PASS
|
||||
|
||||
- Prompt: 搜一下技术部的部门信息
|
||||
- Expected: `dws contact dept search --keyword "技术部" --format json`
|
||||
- Expected: `dws contact dept search --query "技术部" --format json`
|
||||
- Skill Reference: references/products/contact.md
|
||||
- Command path: PASS (contact dept search)
|
||||
- Flags: PASS (1 flags validated)
|
||||
@@ -1062,7 +1062,7 @@
|
||||
**contact_contact_user_search_001** ✅ PASS
|
||||
|
||||
- Prompt: 按关键词搜索用户,keyword 为 张三
|
||||
- Expected: `dws contact user search --keyword 张三 --format json`
|
||||
- Expected: `dws contact user search --query 张三 --format json`
|
||||
- Skill Reference: references/products/contact.md
|
||||
- Command path: PASS (contact user search)
|
||||
- Flags: PASS (1 flags validated)
|
||||
@@ -1070,7 +1070,7 @@
|
||||
**contact_contact_user_search_002** ✅ PASS
|
||||
|
||||
- Prompt: 帮我找一下张三的联系方式
|
||||
- Expected: `dws contact user search --keyword "张三" --format json`
|
||||
- Expected: `dws contact user search --query "张三" --format json`
|
||||
- Skill Reference: references/products/contact.md
|
||||
- Command path: PASS (contact user search)
|
||||
- Flags: PASS (1 flags validated)
|
||||
|
||||
Reference in New Issue
Block a user