Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
355a1460d9 | ||
|
|
c6edc84e40 | ||
|
|
f497047fff | ||
|
|
a9de7d3ca4 | ||
|
|
1c200d883f | ||
|
|
d268524084 | ||
|
|
ed4673e7d2 | ||
|
|
de723914a5 | ||
|
|
649801e479 | ||
|
|
49c5bea4f3 | ||
|
|
6707e56f9c |
@@ -4,6 +4,52 @@ 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.29] - 2026-05-17
|
||||
|
||||
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws aiapp` / `dws live` / `dws aisearch` — three new products discovered via envelope** (no public issue; pre-Diamond rollout) — open-source `dws` now exposes:
|
||||
- **`dws aiapp`** — AI application lifecycle: `create --prompt <p> [--attachments <json>] [--skills <csv>]` / `query --task-id <id>` / `modify --prompt <p> --thread-id <id> [--skills <csv>]`. Backed by upstream `create_ai_app` / `query_ai_app` / `modify_ai_app` MCP tools.
|
||||
- **`dws live stream list`** — list my DingTalk live streams. Backed by upstream `get_my_lives`.
|
||||
- **`dws aisearch person`** — enterprise people search by keyword + multi-dimension filter. Dimensions: `all` (default) / `name` / `department` / `position` / `duty` / `supervisor` / `subordinate` / `phone` / `jobNumber` — multiple comma-separated (`--dimension name,department`). Backed by upstream `enterprise_person_search`.
|
||||
- The `aisearch` envelope additionally registers `-w` / `-d` short flags (keyword / dimension); hidden flag aliases `--query` / `--name` / `--q` / `--text` / `--find` all routing to `keyword`; and cobra subcommand aliases `search` / `find` / `query` / `user` / `people` / `search-person` / `search-user` / `user-search` / `lookup` / `ask` / `contact` all routing to `person`. This closes the F-class model-tolerance regression cases in `dws-wukong/auto-test/cli_to_mcp/testcases/aisearch/test_90_aisearch_param_regression.py` (50/50 pass for aiapp + live + aisearch on the pre-mcp build).
|
||||
- **Users must run `dws cache refresh` once** to pick up the new envelopes; no binary upgrade is required, but pairs naturally with the v1.0.29 client (see Fixed below for the envelope-leaf-Args change).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Envelope-generated leaf commands now tolerate trailing positional args** (#306, no public issue) — `NewDirectCommand` in `internal/compat/registry.go` was hard-coding `cobra.NoArgs` for leaves without positional bindings (`totalMax == 0`). This is stricter than cobra's own `legacyArgs` (cobra `args.go:30-32` returns `nil` for any command without subcommands), and surfaced as `unknown command "<word>" for "<leaf>"` whenever an AI agent passed trailing positional words after a leaf — e.g. `dws aisearch person search --keyword "张"` or `dws aisearch person user search --keyword "张"`. Switching the `totalMax == 0` branch (and the initial value) from `cobra.NoArgs` to `cobra.ArbitraryArgs` restores cobra's natural leaf behavior: trailing positional args are silently ignored. Existing positional-binding paths (`MinimumNArgs` / `RangeArgs` / `MaximumNArgs`) are unchanged. Verified against `dws-wukong/auto-test/cli_to_mcp/testcases` — aiapp (9/9) + live (3/3) + aisearch (38/38) = **50/50** pass, vs 48/50 before this patch.
|
||||
|
||||
### Security
|
||||
|
||||
- **App credential files are partitioned by edition to prevent cross-edition credential leakage** (#300, no public issue; found during internal review) — different `dws` editions sharing the same config directory previously read and wrote the same `app.json`. A sibling edition that pinned its OAuth client ID could persist that ID through the shared post-login path, and the open-source build could later adopt it from the same file. Open-source/empty edition keeps the legacy `app.json` path for compatibility; sibling editions now use `app-<edition>.json`, matching the existing cache partitioning strategy. This prevents new cross-edition app credential writes and reads from colliding. After a sibling edition saves its new partitioned file, it also best-effort removes a legacy `~/.dws/app.json` only when that file's `clientId` matches the sibling edition being saved; a different, unparsable, or otherwise unowned `app.json` is left untouched to avoid deleting open-source credentials. If you previously ran multiple editions in one shared `~/.dws`, remove any confirmed-stale orphan manually with `rm ~/.dws/app.json` after verifying it is not the open-source credential file you still need.
|
||||
|
||||
## [1.0.28] - 2026-05-14
|
||||
|
||||
A single symmetric follow-up to 1.0.26's #250: `dws chat message send --group <cid>` now refuses an empty `--title` at the CLI layer instead of letting the call fall through to the API and surface a misleading `发群服务窗会话消息失败` error. No other behaviour changes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` rejects missing `--title` on group messages** (#294, completes #250) — `send_message_as_user`'s schema marks `title` as required (just like `send_direct_message_as_user`), but `buildChatMessageSendInvocation` only had the pre-validation on the direct-message branches. Group sends without a title were falling through to the API and returning the same misleading `发群服务窗会话消息失败` that #250 already fixed for direct messages. The check now covers both branches: missing `--title` on `--group` returns `--title is required for group messages (--group)` with exit code 2; missing on `--user` / `--open-dingtalk-id` keeps the original `--title is required for direct messages (--user / --open-dingtalk-id)`. The `Long` help, `--title` flag description, the first `Example`, and `skills/references/products/chat.md` (including the drive→chat workflow example) are realigned to "title is required for both direct and group messages" — the docs previously contradicted themselves (the prose said 群聊可选 while the flag listing said 必填). `internal/helpers/chat_test.go` adds a `group-without-title` rejection case; the existing `group` / `positional-text` success cases now pass `--title` to stay aligned with the new validation. No API request shape change — the server has always required `title`; the CLI now matches.
|
||||
|
||||
## [1.0.27] - 2026-05-14
|
||||
|
||||
Two user-visible fixes plus the schema primitive they're built on. `dws doc update` now reads Markdown from a file or stdin, so long / multi-line / table-heavy content no longer gets mangled by shell escaping; `dws sheet find --query` stops returning `unknown flag` on the open-source build, restoring copy-paste from internal wukong docs. Underneath, schema/discovery envelopes get a generic `file_read` transform and a `CLIFlagOverride.MapsTo` field that lets two sibling CLI flags route into the same MCP parameter slot. Also suppresses a noisy WARN on normal stdio-plugin shutdown.
|
||||
|
||||
### Added
|
||||
|
||||
- **`file_read` transform + `CLIFlagOverride.MapsTo` field** (#291, closes #277 #278 #282 #288) — discovery envelopes can now declare a path-typed CLI flag that performs the "file path → file contents string" conversion client-side before the value reaches the upstream MCP parameter.
|
||||
- `transform: "file_read"` (`internal/compat/transform.go`) — reads the file at the flag's value with UTF-8 validation; `-` means stdin. Any IO / encoding failure is surfaced as a validation error (exit 2), distinct from the generic transient-failure path (exit 1).
|
||||
- `CLIFlagOverride.MapsTo` (`internal/market/registry.go`) — redirects the flag's final value (post-transform or literal) into a named MCP parameter slot instead of the default `params[propertyName]`. This lets a single MCP parameter (e.g. `markdown`) be fed by two sibling CLI flags — a literal `--content` and a file-reading `--content-file` — paired with the existing tool-level `MutuallyExclusive` / `RequireOneOf` to express "exclusive, at least one".
|
||||
- Wired into the `internal/compat/dynamic_commands.go` normalizer via a separate `mapsToRoutes` collection + routing pass; empty `MapsTo` preserves the legacy `params[propertyName] = value` semantics, so every pre-existing dynamic_commands test passes unchanged. Pre-prod end-to-end verified across 6 cases (see PR #291's Validation table).
|
||||
- **`dws doc update --content-file <path>` (envelope rollout)** — fixes "long Markdown can't reach the doc". The old command only accepted `--content "..."`, so long / multi-line / table-heavy Markdown got mangled by shell escaping and AI agents writing >2KB of content were stuck. The envelope now maps both `--content` (literal) and `--content-file` (`file_read` transform) to the `markdown` parameter, makes them mutually exclusive via cobra's `MarkFlagsMutuallyExclusive`, and requires at least one via `RequireOneOf`. `--content-file -` reads from stdin, so `cat long.md | dws doc update --content-file -` works directly. **Existing users must run `dws cache refresh` once** to pick up the new envelope.
|
||||
- **`dws sheet find --query` hidden alias (envelope rollout)** — fixes "unknown flag when copy-pasting commands across editions". Users copying `dws sheet find --query "..."` from internal wukong docs onto open-source `dws` got `unknown flag: --query`, because the open-source primary flag is named `--find`. The envelope now registers `--query` as a hidden alias of `--find` via `CLIFlagOverride.Aliases` (the field shipped in 1.0.26) — it doesn't show up in `--help`, but accepts values and writes to the same MCP parameter. `--find` behaviour is unchanged. Also requires `dws cache refresh` once.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Noisy `failed to stop stdio client: exit status 1` WARN on normal stdio-plugin shutdown** (#285) — when `Stop()` explicitly `Kill`s the subprocess, the non-zero exit code returned by `cmd.Wait()` is expected behaviour, but it was being propagated as an error and logged to stderr on every CLI exit, polluting agent log parsing. `Stop()` now returns `nil` after Kill + Wait; the error path is reserved for "process exited on its own with non-zero" (e.g. stdin close without an explicit Kill). `internal/transport/stdio.go` + `stdio_integration_test.go` assert "Stop() returns nil after kill".
|
||||
|
||||
## [1.0.26] - 2026-05-12
|
||||
|
||||
Platform-stability round: Windows PAT-auth browser opener no longer truncates URLs at `&userCode=`, macOS sandbox hosts get an opt-in keychain fallback, and `dws doc download` rejects `axls` nodes before requesting `drive:download` consent. Two new global output formats `-f ndjson` and `-f csv` (matching `larksuite/cli`) land as first-class citizens with real-traffic-verified list detection. The `dws doc comment *` regression tracked in #240 is also resolved — fix is in the market metadata, users just need `dws cache refresh` once.
|
||||
@@ -12,6 +58,14 @@ Platform-stability round: Windows PAT-auth browser opener no longer truncates UR
|
||||
|
||||
- **`-f ndjson` and `-f csv` global output formats** (#259, closes #252) — `ndjson` emits one compact JSON record per line (works straight with `jq -c` / `while read` / log pipelines); `csv` goes through `encoding/csv` (RFC-4180 — quoting, embedded newlines, CJK all handled by stdlib) and reuses the existing `-f table` column resolver (`normalizePayload` / `unwrapPrimaryObject` / `extractRowsFromMap` / `rowsFromSlice` / `formatValue`) so table and csv stay visually aligned. After a 7-product real-traffic sweep (contact / chat / doc / mail / todo / minutes / schema), the `preferredListKeys` whitelist was extended to cover the actual DingTalk envelope shapes — `contact user search` (`result`), `chat search` (`result.value`), `doc search` (`documents`), `mail mailbox list` (`emailAccounts`), `todo task list` (`result.todoCards`) — so these commands now degrade into a proper row stream instead of collapsing to a single-line `key,value` blob. Lives in `internal/output/ndjson.go` + `internal/output/csv.go`; `--format` help in `internal/app/flags.go` now lists `ndjson|csv` alongside `json|table|raw|pretty`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sticky flag splitting is now schema-aware** (#272) — PreParse `StickyHandler` 此前会把任何前缀命中已知 flag 的 `--flagsuffix` 一律切成 `--flag suffix`,于是 `--starttime20260507` 这类拼错被静默改写成 `--start time20260507`,把假值传到下游。新行为按 flag 的 pflag 类型 / JSON Schema `format` / `enum` 校验 suffix 是否像合法 value(共享逻辑见 `pkg/cmdutil/sticky_suffix.go`),不像就保留原 token 让 cobra 报 `unknown flag`。slice/array/object 类型的 flag 永不切分。首 rune 读取使用 `utf8.DecodeRuneInString`,对中文等多字节 value 安全。
|
||||
|
||||
### Added
|
||||
|
||||
- **`available_flags` field on unknown-flag errors** (#272) — `dws -f json` 的 unknown-flag 错误体里新增 `available_flags`(已排序、过滤掉 hidden 与内部 `json` / `params`),方便 agent 不解析 `--help` 就能恢复。Human-readable 输出会附 `Flags: ...` 行,截断在 200 字节内。
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message send` 单聊缺 `--title` 时前置校验** (#250) — 单聊(`--user` / `--open-dingtalk-id`)的底层工具 `send_direct_message_as_user` 在 API 层强制要求 title,缺失时返回误导性的 `发群服务窗会话消息失败`。CLI 现在在 `buildChatMessageSendInvocation` 里前置校验,直接返回 `--title is required for direct messages (--user / --open-dingtalk-id)`;同时把 `Long` help、`--title` flag 描述、Example 和 `skills/references/products/chat.md` 全部对齐为「单聊必填,群聊可选」。群聊行为不变。
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>Table of Contents</strong></summary>
|
||||
@@ -416,16 +416,19 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Sheet | `sheet` | 34 | `range` `filter-view` (top-level: `create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read/write/append, dimension ops, cell merge, find/replace, named filter views + sheet-level filters, image write, async export (`submit_export_job` + `query_export_job` — no consolidated `export` in v1.0.25) |
|
||||
| Wiki | `wiki` | 7 | `space` `member` | Knowledge base management: space `create` / `get` / `list` / `search` + member `add` / `list` / `update` |
|
||||
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
|
||||
| AI Search | `aisearch` | 1 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
|
||||
| AI App | `aiapp` | 3 | — | AI application lifecycle: `create` (with prompt / attachments / skills) / `query` (by task ID) / `modify` (by thread ID) |
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **204 commands across 16 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> **209 commands across 19 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
<details>
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
`conference` (video) · `aiapp` (AI apps) · `live` (streaming)
|
||||
`conference` (video meetings)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-3
@@ -21,7 +21,7 @@
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
<details>
|
||||
<summary><strong>目录</strong></summary>
|
||||
@@ -416,16 +416,19 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 在线电子表格 | `sheet` | 34 | `range` `filter-view`(顶层:`create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并、查找替换、命名筛选视图 + 表级筛选、写入图片、异步导出(`submit_export_job` + `query_export_job`,v1.0.25 暂无合并的 `export` 命令) |
|
||||
| 知识库 | `wiki` | 7 | `space` `member` | 知识库管理:空间 `create` / `get` / `list` / `search` + 成员 `add` / `list` / `update` |
|
||||
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
|
||||
| AI 搜问 | `aisearch` | 1 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
|
||||
| AI 应用 | `aiapp` | 3 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) |
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> **16 个产品,204 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> **19 个产品,209 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
<details>
|
||||
<summary>即将推出</summary>
|
||||
|
||||
`conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)
|
||||
`conference`(视频会议)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -310,7 +310,9 @@ func AppendDynamicServer(server market.ServerDescriptor) {
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
if _, exists := dynamicEndpoints[cmd]; !exists {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
}
|
||||
dynamicProducts[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
|
||||
@@ -270,6 +270,68 @@ func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
// --- Command field first-writer-wins regression test ---
|
||||
//
|
||||
// When two plugins declare the same CLI.Command but different CLI.ID values,
|
||||
// AppendDynamicServer must NOT let the second registration overwrite the
|
||||
// command → endpoint mapping established by the first. The fix uses a simple
|
||||
// "if not exists" guard on dynamicEndpoints[cmd].
|
||||
|
||||
const (
|
||||
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
|
||||
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
|
||||
)
|
||||
|
||||
func firstPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testFirstEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-alpha",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func secondPluginDescriptor() market.ServerDescriptor {
|
||||
return market.ServerDescriptor{
|
||||
Endpoint: testSecondEndpoint,
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "plugin-beta",
|
||||
Command: "shared-cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
|
||||
// two plugins declare the same Command (but different IDs), only the first
|
||||
// registration takes effect for the command → endpoint mapping. The second
|
||||
// plugin's own id-based endpoint is unaffected.
|
||||
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
|
||||
withCleanDynamicRegistry(t)
|
||||
|
||||
AppendDynamicServer(firstPluginDescriptor())
|
||||
AppendDynamicServer(secondPluginDescriptor())
|
||||
|
||||
// The command "shared-cmd" must resolve to the first plugin's endpoint.
|
||||
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
|
||||
|
||||
// Each plugin's own id-based endpoint is always unconditionally written.
|
||||
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
|
||||
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
|
||||
|
||||
// Command must appear in dynamicProducts (discovery) regardless.
|
||||
ids := DirectRuntimeProductIDs()
|
||||
if !ids["shared-cmd"] {
|
||||
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-alpha"] {
|
||||
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
if !ids["plugin-beta"] {
|
||||
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
|
||||
// tool-level routing still works as a fallback when productID is empty or has
|
||||
// no registered endpoint (the original design intent for tool-level Priority 1).
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestFlagErrorWithSuggestions_authStructured(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "login", Run: func(*cobra.Command, []string) {}}
|
||||
orig := fmt.Errorf("unknown flag: --json")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Message != orig.Error() {
|
||||
t.Fatalf("Message = %q, want %q", ae.Message, orig.Error())
|
||||
}
|
||||
if ae.Reason != "unknown_flag" {
|
||||
t.Fatalf("Reason = %q, want unknown_flag", ae.Reason)
|
||||
}
|
||||
if ae.Hint == "" || !strings.Contains(ae.Hint, "format json") {
|
||||
t.Fatalf("Hint = %q", ae.Hint)
|
||||
}
|
||||
if ae.Cause != orig {
|
||||
t.Fatalf("Cause = %v, want orig", ae.Cause)
|
||||
}
|
||||
if !stderrors.Is(err, orig) {
|
||||
t.Fatal("errors.Is(err, orig) should hold via unwrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
orig := fmt.Errorf("unknown flag: --starttime1")
|
||||
err := flagErrorWithSuggestions(cmd, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != "unknown_flag" {
|
||||
t.Fatalf("Reason = %q", ae.Reason)
|
||||
}
|
||||
if strings.Contains(ae.Hint, "Space required") {
|
||||
t.Fatalf("false glue must not suggest space: %q", ae.Hint)
|
||||
}
|
||||
if !strings.Contains(ae.Hint, "help") {
|
||||
t.Fatalf("expected help fallback in hint, got %q", ae.Hint)
|
||||
}
|
||||
if len(ae.AvailableFlags) != 1 || ae.AvailableFlags[0] != "start" {
|
||||
t.Fatalf("AvailableFlags = %v, want [start]", ae.AvailableFlags)
|
||||
}
|
||||
}
|
||||
@@ -624,7 +624,7 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
if !retryHasKey {
|
||||
t.Fatal("expected retry context to have patRetryingKey")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err != nil {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(configDir)); err != nil {
|
||||
t.Fatalf("expected approved PAT flow to persist app.json, stat error = %v", err)
|
||||
}
|
||||
// Verify SetClientIDFromMCP was called with the PAT response clientId.
|
||||
@@ -700,7 +700,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
@@ -759,7 +759,7 @@ func TestHandlePatAuthCheck_HostControlledEmptyFlowID_StillReturnsContract(t *te
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in host mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
patOut, ok := err.(*apperrors.PATError)
|
||||
@@ -855,7 +855,7 @@ func TestHandlePatAuthCheck_JSONModeReturnsStructuredPATErrorWithoutRetry(t *tes
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
|
||||
@@ -918,7 +918,7 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
|
||||
if got := strings.TrimSpace(buf.String()); got != "" {
|
||||
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
|
||||
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
|
||||
}
|
||||
if opened != rawURI {
|
||||
|
||||
+23
-1
@@ -47,6 +47,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -133,7 +134,28 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
|
||||
for flag, suggestion := range suggestions {
|
||||
if strings.Contains(errMsg, "unknown flag: "+flag) {
|
||||
return fmt.Errorf("%w\n%s", err, suggestion)
|
||||
return apperrors.NewValidation(
|
||||
errMsg,
|
||||
apperrors.WithHint(suggestion),
|
||||
apperrors.WithReason("unknown_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(errMsg, "unknown flag:") {
|
||||
fix := cmdutil.SuggestFlagFix(cmd, err)
|
||||
if fix.Suggestion != "" {
|
||||
return apperrors.NewValidation(
|
||||
errMsg,
|
||||
apperrors.WithHint(fix.Suggestion),
|
||||
apperrors.WithReason("unknown_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,21 +16,31 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
configpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
const (
|
||||
// appConfigFile is the filename for storing app credentials.
|
||||
appConfigFile = "app.json"
|
||||
// appConfigFile is the filename for the open-source edition's app
|
||||
// credentials store. Sibling editions get a name-suffixed file via
|
||||
// config.EditionFileName so two dws binaries sharing the same config
|
||||
// directory (~/.dws or DWS_CONFIG_DIR) cannot read/write each other's
|
||||
// credentials. See GetAppConfigPath for the path derivation contract.
|
||||
appConfigBase = "app"
|
||||
appConfigExt = ".json"
|
||||
appConfigFile = appConfigBase + appConfigExt
|
||||
)
|
||||
|
||||
// AppConfig represents the application credentials configuration.
|
||||
// This is stored in ~/.dws/app.json with the client secret securely stored in keychain.
|
||||
// This is stored in the edition-specific app config file, with the client
|
||||
// secret securely stored in keychain when present.
|
||||
type AppConfig struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret SecretInput `json:"clientSecret"`
|
||||
@@ -53,9 +63,14 @@ var (
|
||||
cachedResolvedMu sync.RWMutex
|
||||
)
|
||||
|
||||
// GetAppConfigPath returns the path to the app config file.
|
||||
// GetAppConfigPath returns the path to the app config file for the
|
||||
// currently-active edition. The filename is partitioned by edition so that
|
||||
// two dws binaries from different editions sharing the same configDir
|
||||
// (typically ~/.dws or DWS_CONFIG_DIR) cannot read or overwrite each
|
||||
// other's credentials. Open-source stays on "app.json" for backwards
|
||||
// compatibility; sibling editions land on "app-<edition>.json".
|
||||
func GetAppConfigPath(configDir string) string {
|
||||
return filepath.Join(configDir, appConfigFile)
|
||||
return filepath.Join(configDir, configpkg.EditionFileName(edition.Get().Name, appConfigBase, appConfigExt))
|
||||
}
|
||||
|
||||
// LoadAppConfig loads the app configuration from disk.
|
||||
@@ -105,6 +120,7 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
|
||||
return fmt.Errorf("writing app config: %w", err)
|
||||
}
|
||||
cleanupLegacySiblingAppConfig(configDir, config)
|
||||
|
||||
// Update cache
|
||||
cachedAppConfigMu.Lock()
|
||||
@@ -121,6 +137,34 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
|
||||
if config == nil || config.ClientID == "" || configpkg.IsOpenEdition(edition.Get().Name) {
|
||||
return
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
if legacyPath == GetAppConfigPath(configDir) {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var legacy AppConfig
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return
|
||||
}
|
||||
if legacy.ClientID != config.ClientID {
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
|
||||
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAppConfig removes the app configuration and associated keychain secrets.
|
||||
func DeleteAppConfig(configDir string) error {
|
||||
// Load existing config to clean up keychain
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Verifies that two dws binaries from different editions sharing the same
|
||||
// configDir (e.g. ~/.dws via DWS_CONFIG_DIR) read and write disjoint
|
||||
// app.json files. Without partitioning, a sibling edition's post-login
|
||||
// persistence path could leak its pinned ClientID into the open-source
|
||||
// build by reading the shared file.
|
||||
|
||||
func TestGetAppConfigPath_OpenEditionUsesLegacyName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
for _, name := range []string{"", "open"} {
|
||||
edition.Override(&edition.Hooks{Name: name})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", "app.json")
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_SiblingEditionUsesSuffixedName(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
cases := []struct {
|
||||
editionName string
|
||||
wantFile string
|
||||
}{
|
||||
{"wukong", "app-wukong.json"},
|
||||
{"dev", "app-dev.json"},
|
||||
{"embedded", "app-embedded.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
edition.Override(&edition.Hooks{Name: tc.editionName})
|
||||
got := GetAppConfigPath("/tmp/cfg")
|
||||
want := filepath.Join("/tmp/cfg", tc.wantFile)
|
||||
if got != want {
|
||||
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", tc.editionName, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAppConfigPath_OpenAndSiblingAreDisjoint(t *testing.T) {
|
||||
// End-to-end invariant: when the same configDir is observed from two
|
||||
// different editions, the resulting app.json paths must NOT collide.
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(prev) })
|
||||
|
||||
const cfg = "/tmp/shared-cfg"
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
openPath := GetAppConfigPath(cfg)
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(cfg)
|
||||
|
||||
if openPath == wukongPath {
|
||||
t.Fatalf("open and wukong editions share path %q; cross-edition leakage possible", openPath)
|
||||
}
|
||||
if filepath.Dir(openPath) != filepath.Dir(wukongPath) {
|
||||
t.Fatalf("paths landed in different directories (%q vs %q); partitioning should only differ by filename", filepath.Dir(openPath), filepath.Dir(wukongPath))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigIO_OpenEditionDoesNotReadSiblingCredentials(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
wukongPath := GetAppConfigPath(configDir)
|
||||
if err := os.WriteFile(wukongPath, []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}`+"\n"), 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
got, err := LoadAppConfig(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAppConfig(open) error = %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("open edition read sibling app config: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionRemovesMatchingLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("matching legacy app config should be removed, stat error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "app-wukong.json")); err != nil {
|
||||
t.Fatalf("sibling app config not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsDifferentLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"open-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("different legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsMalformedLegacyAppConfig(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid"`)
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing malformed legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("malformed legacy app config should be preserved: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("malformed legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_OpenEditionDoesNotCleanSiblingAppConfigs(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
siblingFiles := map[string][]byte{
|
||||
"app-wukong.json": []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
"app-dev.json": []byte(`{"clientId":"dev-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
|
||||
}
|
||||
for name, data := range siblingFiles {
|
||||
if err := os.WriteFile(filepath.Join(configDir, name), data, 0600); err != nil {
|
||||
t.Fatalf("writing sibling app config %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "open"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "open-cid"}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(open) error = %v", err)
|
||||
}
|
||||
|
||||
for name, want := range siblingFiles {
|
||||
got, err := os.ReadFile(filepath.Join(configDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("open edition should preserve sibling app config %s: %v", name, err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("sibling app config %s changed: got %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAppConfig_SiblingEditionKeepsLegacyAppConfigWhenClientIDEmpty(t *testing.T) {
|
||||
prev := edition.Get()
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
resetAppConfigCache()
|
||||
})
|
||||
|
||||
configDir := t.TempDir()
|
||||
legacyPath := filepath.Join(configDir, appConfigFile)
|
||||
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
|
||||
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
|
||||
t.Fatalf("writing legacy app config: %v", err)
|
||||
}
|
||||
|
||||
edition.Override(&edition.Hooks{Name: "wukong"})
|
||||
if err := SaveAppConfig(configDir, &AppConfig{}); err != nil {
|
||||
t.Fatalf("SaveAppConfig(wukong empty client ID) error = %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(legacyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy app config should be preserved when client ID is empty: %v", err)
|
||||
}
|
||||
if string(got) != string(legacyJSON) {
|
||||
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
|
||||
}
|
||||
}
|
||||
@@ -283,10 +283,12 @@ type toolRequestSchema struct {
|
||||
}
|
||||
|
||||
type toolRequestProp struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
}
|
||||
|
||||
// buildFlagsFromDetailSchema adds properly-typed cobra flags to cmd based on
|
||||
@@ -371,6 +373,17 @@ func buildFlagsFromDetailSchema(cmd *cobra.Command, schemaJSON string, flagOverr
|
||||
cmd.Flags().String(flagName, defaultVal, help)
|
||||
}
|
||||
|
||||
// Carry schema "format" / "enum" hints onto the cobra flag via
|
||||
// pflag annotations so PreParse handlers (e.g. StickyHandler)
|
||||
// can reason about whether a glued suffix looks like a real
|
||||
// value. The annotation keys are read by FlagInfoFromCommand.
|
||||
if prop.Format != "" {
|
||||
_ = cmd.Flags().SetAnnotation(flagName, "x-cli-format", []string{prop.Format})
|
||||
}
|
||||
if len(prop.Enum) > 0 {
|
||||
_ = cmd.Flags().SetAnnotation(flagName, "x-cli-enum", append([]string{}, prop.Enum...))
|
||||
}
|
||||
|
||||
if requiredSet[key] {
|
||||
_ = cmd.MarkFlagRequired(flagName)
|
||||
}
|
||||
@@ -532,10 +545,19 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
var bindings []FlagBinding
|
||||
type transformEntry struct {
|
||||
paramName string
|
||||
mapsTo string
|
||||
transform string
|
||||
transformArgs map[string]any
|
||||
}
|
||||
var transforms []transformEntry
|
||||
// mapsToRoutes captures flags that only need value-routing (no transform)
|
||||
// — e.g. a literal --content flag that mapsTo "markdown". The dispatch
|
||||
// loop moves params[paramName] → params[mapsTo] after CLI binding.
|
||||
type mapsToRoute struct {
|
||||
paramName string
|
||||
mapsTo string
|
||||
}
|
||||
var mapsToRoutes []mapsToRoute
|
||||
type envDefaultEntry struct {
|
||||
paramName string
|
||||
envVar string
|
||||
@@ -664,9 +686,19 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
if flagOverride.Transform != "" {
|
||||
transforms = append(transforms, transformEntry{
|
||||
paramName: paramName,
|
||||
mapsTo: strings.TrimSpace(flagOverride.MapsTo),
|
||||
transform: flagOverride.Transform,
|
||||
transformArgs: flagOverride.TransformArgs,
|
||||
})
|
||||
} else if mt := strings.TrimSpace(flagOverride.MapsTo); mt != "" {
|
||||
// mapsTo without transform: just route the literal value into a
|
||||
// different MCP parameter slot. Common case is --content (literal
|
||||
// string) mapping to MCP parameter markdown, alongside a sibling
|
||||
// --content-file (transform: file_read) mapping to the same slot.
|
||||
mapsToRoutes = append(mapsToRoutes, mapsToRoute{
|
||||
paramName: paramName,
|
||||
mapsTo: mt,
|
||||
})
|
||||
}
|
||||
if flagOverride.EnvDefault != "" {
|
||||
envDefaults = append(envDefaults, envDefaultEntry{
|
||||
@@ -699,12 +731,15 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
}
|
||||
}
|
||||
bodyWrapper := strings.TrimSpace(override.BodyWrapper)
|
||||
if len(transforms) == 0 && len(envDefaults) == 0 && len(defaultInjects) == 0 && len(runtimeDefaults) == 0 && len(omits) == 0 && !needsDottedNesting && bodyWrapper == "" {
|
||||
if len(transforms) == 0 && len(envDefaults) == 0 && len(defaultInjects) == 0 && len(runtimeDefaults) == 0 && len(omits) == 0 && len(mapsToRoutes) == 0 && !needsDottedNesting && bodyWrapper == "" {
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// Build a normalizer that applies default injections + env defaults + runtime defaults
|
||||
// + transforms + omitWhen + nesting + body wrap.
|
||||
// Build a normalizer that applies default injections + env defaults +
|
||||
// runtime defaults + transforms + mapsTo routing + omitWhen + nesting +
|
||||
// body wrap. Tool-level cobra constraints (MutuallyExclusive /
|
||||
// RequireOneOf) are wired separately via applyFlagConstraints and don't
|
||||
// belong in this closure.
|
||||
normalizer := func(cmd *cobra.Command, params map[string]any) error {
|
||||
// §v3.2: Apply envelope flag.default for parameters not explicitly set.
|
||||
// Coerce by Kind so number-typed schemas don't reject string defaults.
|
||||
@@ -756,14 +791,21 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
}
|
||||
}
|
||||
|
||||
// §3: Apply transforms
|
||||
// §3: Apply transforms. When MapsTo is set, the transformed value is
|
||||
// routed to params[MapsTo] and the original params[paramName] is
|
||||
// dropped, so the MCP body carries a single (post-transform) entry
|
||||
// at the target slot.
|
||||
for _, t := range transforms {
|
||||
val, exists := params[t.paramName]
|
||||
if !exists {
|
||||
// For enum_map with _default, apply default even when flag is omitted
|
||||
if t.transform == "enum_map" && t.transformArgs != nil {
|
||||
if defaultVal, hasDefault := t.transformArgs["_default"]; hasDefault {
|
||||
params[t.paramName] = defaultVal
|
||||
target := t.paramName
|
||||
if t.mapsTo != "" {
|
||||
target = t.mapsTo
|
||||
}
|
||||
params[target] = defaultVal
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -772,7 +814,25 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params[t.paramName] = transformed
|
||||
if t.mapsTo != "" {
|
||||
params[t.mapsTo] = transformed
|
||||
delete(params, t.paramName)
|
||||
} else {
|
||||
params[t.paramName] = transformed
|
||||
}
|
||||
}
|
||||
|
||||
// §3b: mapsTo-only routes (no transform). Move params[paramName] →
|
||||
// params[mapsTo] verbatim. Common pattern: a literal --content flag
|
||||
// that routes to MCP parameter `markdown`, alongside a sibling
|
||||
// --content-file flag that transforms + routes to the same slot.
|
||||
for _, r := range mapsToRoutes {
|
||||
val, exists := params[r.paramName]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
params[r.mapsTo] = val
|
||||
delete(params, r.paramName)
|
||||
}
|
||||
|
||||
// §v3.2.2: Apply omitWhen — drop keys whose value meets the omit
|
||||
|
||||
@@ -15,6 +15,8 @@ package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -1945,3 +1947,283 @@ func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
|
||||
t.Fatalf("expected exactly one 'send' leaf, got %d", sendCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFlagsFromDetailSchema_FormatEnumAnnotations verifies that the
|
||||
// JSON Schema "format" and "enum" hints are copied onto the cobra flag's
|
||||
// pflag annotations under x-cli-format / x-cli-enum, so PreParse
|
||||
// handlers can use them when deciding whether to split glued tokens.
|
||||
func TestBuildFlagsFromDetailSchema_FormatEnumAnnotations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-calendar",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "calendar",
|
||||
Command: "calendar",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"event_list": {CLIName: "list"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
details := map[string][]market.DetailTool{
|
||||
"calendar": {
|
||||
{
|
||||
ToolName: "event_list",
|
||||
ToolRequest: `{"properties":{` +
|
||||
`"start":{"type":"string","format":"date-time","description":"开始时间"},` +
|
||||
`"end":{"type":"string","format":"date-time","description":"结束时间"},` +
|
||||
`"status":{"type":"string","enum":["confirmed","tentative","cancelled"]}` +
|
||||
`}}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
|
||||
list := findChild(cmds[0], "list")
|
||||
if list == nil {
|
||||
t.Fatal("list leaf not found")
|
||||
}
|
||||
|
||||
startFlag := list.Flags().Lookup("start")
|
||||
if startFlag == nil {
|
||||
t.Fatal("--start flag missing")
|
||||
}
|
||||
if got := startFlag.Annotations["x-cli-format"]; len(got) != 1 || got[0] != "date-time" {
|
||||
t.Errorf("--start x-cli-format = %v, want [date-time]", got)
|
||||
}
|
||||
|
||||
endFlag := list.Flags().Lookup("end")
|
||||
if endFlag == nil {
|
||||
t.Fatal("--end flag missing")
|
||||
}
|
||||
if got := endFlag.Annotations["x-cli-format"]; len(got) != 1 || got[0] != "date-time" {
|
||||
t.Errorf("--end x-cli-format = %v, want [date-time]", got)
|
||||
}
|
||||
|
||||
statusFlag := list.Flags().Lookup("status")
|
||||
if statusFlag == nil {
|
||||
t.Fatal("--status flag missing")
|
||||
}
|
||||
gotEnum := statusFlag.Annotations["x-cli-enum"]
|
||||
wantEnum := []string{"confirmed", "tentative", "cancelled"}
|
||||
if !equalStringSlice(gotEnum, wantEnum) {
|
||||
t.Errorf("--status x-cli-enum = %v, want %v", gotEnum, wantEnum)
|
||||
}
|
||||
// Status has no format and should not carry x-cli-format.
|
||||
if got := statusFlag.Annotations["x-cli-format"]; len(got) != 0 {
|
||||
t.Errorf("--status should not have x-cli-format, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommands_MapsTo_WithoutTransform verifies that a flag
|
||||
// carrying only MapsTo (no transform) moves its literal value to the
|
||||
// target MCP parameter slot and drops the source key. The canonical use
|
||||
// case is exposing --content as a sibling of --markdown that both feed
|
||||
// the same upstream `markdown` parameter.
|
||||
func TestBuildDynamicCommands_MapsTo_WithoutTransform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &captureRunner{}
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-doc",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"update_document": {
|
||||
CLIName: "update",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"nodeId": {Alias: "node"},
|
||||
"content": {Alias: "content", MapsTo: "markdown"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "# 标题"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
if err := cmds[0].Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
if runner.lastParams["markdown"] != "# 标题" {
|
||||
t.Errorf("params[markdown] = %v, want '# 标题'", runner.lastParams["markdown"])
|
||||
}
|
||||
if _, leftover := runner.lastParams["content"]; leftover {
|
||||
t.Errorf("source key 'content' must be deleted after mapsTo, got params=%+v", runner.lastParams)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommands_MapsTo_WithFileReadTransform verifies the full
|
||||
// envelope shape that #277 needs: a path-typed flag (--content-file) that
|
||||
// reads the file via the file_read transform AND routes the resulting
|
||||
// string into a sibling MCP parameter (markdown). End-to-end: user types
|
||||
// a path, the upstream tool receives file contents under the right key.
|
||||
func TestBuildDynamicCommands_MapsTo_WithFileReadTransform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.md")
|
||||
contents := "# 项目周报\n\n- 完成 A\n- 完成 B\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
runner := &captureRunner{}
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-doc",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"update_document": {
|
||||
CLIName: "update",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"nodeId": {Alias: "node"},
|
||||
"contentFile": {
|
||||
Alias: "content-file",
|
||||
MapsTo: "markdown",
|
||||
Transform: "file_read",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content-file", path})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
if err := cmds[0].Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
if runner.lastParams["markdown"] != contents {
|
||||
t.Errorf("params[markdown] = %v, want file contents", runner.lastParams["markdown"])
|
||||
}
|
||||
if _, leftover := runner.lastParams["contentFile"]; leftover {
|
||||
t.Errorf("source key 'contentFile' must be deleted after mapsTo, got params=%+v", runner.lastParams)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne verifies the
|
||||
// realistic pre-prod shape: two sibling flags (--content literal and
|
||||
// --content-file path) both mapsTo "markdown", guarded by the existing
|
||||
// tool-level cobra MutuallyExclusive constraint. When the user sets only
|
||||
// one, it routes through cleanly; the other source key is absent.
|
||||
func TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &captureRunner{}
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-doc",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"update_document": {
|
||||
CLIName: "update",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"nodeId": {Alias: "node"},
|
||||
"content": {Alias: "content", MapsTo: "markdown"},
|
||||
"contentFile": {
|
||||
Alias: "content-file",
|
||||
MapsTo: "markdown",
|
||||
Transform: "file_read",
|
||||
},
|
||||
},
|
||||
MutuallyExclusive: [][]string{{"content", "content-file"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "literal body"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
if err := cmds[0].Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
if runner.lastParams["markdown"] != "literal body" {
|
||||
t.Errorf("params[markdown] = %v, want 'literal body'", runner.lastParams["markdown"])
|
||||
}
|
||||
if _, leftover := runner.lastParams["content"]; leftover {
|
||||
t.Errorf("source key 'content' must be deleted, got params=%+v", runner.lastParams)
|
||||
}
|
||||
if _, leftover := runner.lastParams["contentFile"]; leftover {
|
||||
t.Errorf("untouched sibling key 'contentFile' must not appear, got params=%+v", runner.lastParams)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra verifies that
|
||||
// when both mapsTo siblings are set, the existing tool-level
|
||||
// MutuallyExclusive constraint produces a cobra error before dispatch
|
||||
// runs. This is a sanity regression check — the cobra mechanism is
|
||||
// pre-existing, but combining it with mapsTo is the realistic envelope
|
||||
// shape #277 needs.
|
||||
func TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &captureRunner{}
|
||||
servers := []market.ServerDescriptor{
|
||||
{
|
||||
Endpoint: "https://endpoint-doc",
|
||||
CLI: market.CLIOverlay{
|
||||
ID: "doc",
|
||||
Command: "doc",
|
||||
ToolOverrides: map[string]market.CLIToolOverride{
|
||||
"update_document": {
|
||||
CLIName: "update",
|
||||
Flags: map[string]market.CLIFlagOverride{
|
||||
"nodeId": {Alias: "node"},
|
||||
"content": {Alias: "content", MapsTo: "markdown"},
|
||||
"contentFile": {Alias: "content-file", MapsTo: "markdown", Transform: "file_read"},
|
||||
},
|
||||
MutuallyExclusive: [][]string{{"content", "content-file"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cmds := BuildDynamicCommands(servers, runner, nil)
|
||||
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "x", "--content-file", "/tmp/y"})
|
||||
cmds[0].SilenceErrors = true
|
||||
cmds[0].SilenceUsage = true
|
||||
err := cmds[0].Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected mutually-exclusive error, got nil")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "none of the others") && !strings.Contains(msg, "mutually") && !strings.Contains(msg, "exclusive") {
|
||||
t.Fatalf("expected mutually-exclusive error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// equalStringSlice is a small helper for slice comparison in tests.
|
||||
func equalStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -169,10 +169,10 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
|
||||
strictMin = b.PositionalIndex + 1
|
||||
}
|
||||
}
|
||||
var argsValidator cobra.PositionalArgs = cobra.NoArgs
|
||||
var argsValidator cobra.PositionalArgs = cobra.ArbitraryArgs
|
||||
switch {
|
||||
case totalMax == 0:
|
||||
argsValidator = cobra.NoArgs
|
||||
argsValidator = cobra.ArbitraryArgs
|
||||
case strictMin > 0 && strictMin == totalMax:
|
||||
argsValidator = cobra.MinimumNArgs(strictMin)
|
||||
case strictMin > 0:
|
||||
|
||||
@@ -16,9 +16,12 @@ package compat
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
@@ -27,7 +30,7 @@ import (
|
||||
|
||||
// ApplyTransform applies a named transform rule to a value.
|
||||
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
|
||||
// json_parse_strict, enum_map.
|
||||
// json_parse_strict, enum_map, file_read.
|
||||
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
|
||||
switch strings.TrimSpace(transform) {
|
||||
case "":
|
||||
@@ -42,6 +45,8 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
|
||||
return transformJSONParseStrict(value)
|
||||
case "enum_map":
|
||||
return transformEnumMap(value, args)
|
||||
case "file_read":
|
||||
return transformFileRead(value)
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
@@ -194,6 +199,44 @@ func transformEnumMap(value any, args map[string]any) (any, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// transformFileRead reads the file at the given path and returns its contents
|
||||
// as a UTF-8 string. The special path "-" reads from stdin.
|
||||
//
|
||||
// Typical envelope use is paired with CLIFlagOverride.MapsTo so a path-typed
|
||||
// CLI flag (e.g. --content-file ./a.md) routes the file contents into a
|
||||
// content-typed MCP parameter (e.g. markdown), letting a sibling literal
|
||||
// flag (--content "# 标题") feed the same parameter without conflict.
|
||||
//
|
||||
// Errors are surfaced as validation errors so the dispatcher returns exit code 2
|
||||
// (user input) rather than the generic exit code 1 (transient failure).
|
||||
func transformFileRead(value any) (any, error) {
|
||||
s, ok := toString(value)
|
||||
if !ok {
|
||||
return nil, apperrors.NewValidation("file_read: expected string path, got non-string value")
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, apperrors.NewValidation("file_read: empty path")
|
||||
}
|
||||
var buf []byte
|
||||
var err error
|
||||
if s == "-" {
|
||||
buf, err = io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read stdin: %v", err))
|
||||
}
|
||||
} else {
|
||||
buf, err = os.ReadFile(s)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read %q: %v", s, err))
|
||||
}
|
||||
}
|
||||
if !utf8.Valid(buf) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: %q is not valid UTF-8", s))
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
func toString(v any) (string, bool) {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
package compat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -123,3 +126,136 @@ func TestJSONParse_InvalidInput(t *testing.T) {
|
||||
t.Fatal("error message should be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
|
||||
// read in full and surfaced as a string value. This is the contract the
|
||||
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
|
||||
// file contents in place of the path.
|
||||
func TestFileRead_BasicFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.md")
|
||||
contents := "# Heading\n\n- bullet one\n- bullet two\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
got, err := ApplyTransform(path, "file_read", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("file_read should succeed, got err: %v", err)
|
||||
}
|
||||
if got != contents {
|
||||
t.Errorf("file_read should return file contents verbatim; got %q want %q", got, contents)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_EmptyPath rejects empty input with a validation error rather
|
||||
// than silently reading "" / cwd. The dispatcher maps validation errors to
|
||||
// exit code 2 so the user sees a usage problem.
|
||||
func TestFileRead_EmptyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ApplyTransform("", "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file_read") {
|
||||
t.Errorf("error should mention the transform name, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_MissingFile surfaces a clear validation error when the path
|
||||
// doesn't exist. The previous `os.ReadFile` error is wrapped so the user
|
||||
// sees what they passed.
|
||||
func TestFileRead_MissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "definitely-not-here.md")
|
||||
_, err := ApplyTransform(missing, "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "definitely-not-here.md") {
|
||||
t.Errorf("error should mention the missing path, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_InvalidUTF8 rejects binary input. Upstream tools expect text
|
||||
// content and silently shipping a corrupted byte string would mask a real
|
||||
// user error.
|
||||
func TestFileRead_InvalidUTF8(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "binary.dat")
|
||||
if err := os.WriteFile(path, []byte{0xff, 0xfe, 0x00, 0x01}, 0o600); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
_, err := ApplyTransform(path, "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected UTF-8 validation error for binary input")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UTF-8") {
|
||||
t.Errorf("error should mention UTF-8, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_NonString rejects non-string flag values. CLI flags resolve to
|
||||
// string by default but a misconfigured envelope (e.g. Type: int) shouldn't
|
||||
// silently no-op.
|
||||
func TestFileRead_NonString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ApplyTransform(123, "file_read", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for non-string value")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_StdinDashIsAccepted documents the contract: the special value
|
||||
// "-" is reserved for stdin. We don't test stdin redirection here (that
|
||||
// requires plumbing os.Stdin replacement which complicates the test) — this
|
||||
// is a compile-time signal that "-" doesn't path-resolve to a file named "-"
|
||||
// in the current directory. The end-to-end stdin path is covered in
|
||||
// test/cli_compat once the envelope ships.
|
||||
func TestFileRead_StdinDashIsAccepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Run with stdin redirected from an empty pipe so we don't hang.
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
if _, err := w.Write([]byte("piped content")); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
origStdin := os.Stdin
|
||||
os.Stdin = r
|
||||
defer func() { os.Stdin = origStdin }()
|
||||
|
||||
got, err := ApplyTransform("-", "file_read", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("file_read with '-' should read stdin, got err: %v", err)
|
||||
}
|
||||
if got != "piped content" {
|
||||
t.Errorf("expected stdin contents, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRead_UnknownTransformPassThrough double-checks that the new case
|
||||
// is gated by name and doesn't regress when the transform name is missing.
|
||||
func TestFileRead_UnknownTransformPassThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ApplyTransform("./some-path", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("empty transform should pass through, got err: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, "./some-path") {
|
||||
t.Errorf("expected pass-through, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
+58
-13
@@ -37,19 +37,20 @@ const (
|
||||
|
||||
// Error is the structured repository-local error model for the Go rewrite.
|
||||
type Error struct {
|
||||
Category Category
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
Category Category
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
AvailableFlags []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
@@ -135,6 +136,16 @@ func WithActions(actions ...string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithAvailableFlags records visible local flag names for agent recovery.
|
||||
func WithAvailableFlags(names ...string) Option {
|
||||
return func(err *Error) {
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
err.AvailableFlags = append([]string{}, names...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithSnapshot records the recovery snapshot path associated with the failure.
|
||||
func WithSnapshot(path string) Option {
|
||||
return func(err *Error) {
|
||||
@@ -260,6 +271,9 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
if len(typed.Actions) > 0 {
|
||||
errorPayload["actions"] = typed.Actions
|
||||
}
|
||||
if len(typed.AvailableFlags) > 0 {
|
||||
errorPayload["available_flags"] = typed.AvailableFlags
|
||||
}
|
||||
if typed.Snapshot != "" {
|
||||
errorPayload["snapshot_path"] = typed.Snapshot
|
||||
}
|
||||
@@ -359,6 +373,9 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
lines = append(lines, fmt.Sprintf("Action: %s", action))
|
||||
}
|
||||
}
|
||||
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if typed.Retryable {
|
||||
lines = append(lines, "Retryable: true")
|
||||
}
|
||||
@@ -414,3 +431,31 @@ func category(err error) string {
|
||||
}
|
||||
return string(CategoryInternal)
|
||||
}
|
||||
|
||||
const availableFlagsHumanMaxRunes = 200
|
||||
|
||||
func formatAvailableFlagsHumanLine(flags []string) string {
|
||||
if len(flags) == 0 {
|
||||
return ""
|
||||
}
|
||||
b := strings.Builder{}
|
||||
b.WriteString("Flags: ")
|
||||
written := 0
|
||||
for i, name := range flags {
|
||||
if i > 0 {
|
||||
if written+2 > availableFlagsHumanMaxRunes {
|
||||
b.WriteString("...")
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString(", ")
|
||||
written += 2
|
||||
}
|
||||
if written+len(name) > availableFlagsHumanMaxRunes {
|
||||
b.WriteString("...")
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString(name)
|
||||
written += len(name)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -77,6 +77,27 @@ func TestPrintJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation(
|
||||
"unknown flag: --foo",
|
||||
WithReason("unknown_flag"),
|
||||
WithHint("Did you mean --bar?"),
|
||||
WithAvailableFlags("bar", "baz"),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
if !strings.Contains(got, `"available_flags"`) {
|
||||
t.Fatalf("expected available_flags in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"bar"`) || !strings.Contains(got, `"baz"`) {
|
||||
t.Fatalf("expected flag names in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHuman(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -93,11 +93,11 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
三者只能选其一,不能同时指定。
|
||||
|
||||
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。
|
||||
单聊消息(--user / --open-dingtalk-id)必须提供 --title 作为消息标题;群聊可选。
|
||||
--title 是消息标题,群聊与单聊都必填(API 强制要求;缺失时返回误导性的 "发群服务窗会话消息失败")。
|
||||
|
||||
群聊场景下可用 --at-all / --at-users / --at-mobiles 进行 @ 提醒(仅 --group 时生效)。
|
||||
注意 --text 中需包含对应的 <@userId> / <@all> 占位符才能在客户端渲染出 @ 效果。`,
|
||||
Example: ` dws chat message send --group <openconversation_id> --text "hello"
|
||||
Example: ` dws chat message send --group <openconversation_id> --title "周报" --text "请提交本周日报"
|
||||
dws chat message send --user <userId> --title "提醒" --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请确认"
|
||||
dws chat message send --group <openconversation_id> --title "拉群通知" --text "<@uid> 你被 @ 了" --at-users uid`,
|
||||
@@ -128,7 +128,7 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("user", "", "接收人 userId (单聊三选一)")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "接收人 openDingTalkId (单聊三选一)")
|
||||
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
|
||||
cmd.Flags().String("title", "", "消息标题 (单聊必填,群聊可选)")
|
||||
cmd.Flags().String("title", "", "消息标题 (必填,群聊与单聊都必填)")
|
||||
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
|
||||
cmd.Flags().String("at-users", "", "按 userId @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
|
||||
cmd.Flags().String("at-mobiles", "", "按手机号 @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
|
||||
@@ -196,10 +196,14 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
if !hasGroup && (atAll || hasAtUsers || hasAtMobiles) {
|
||||
return nil, "", apperrors.NewValidation("--at-all / --at-users / --at-mobiles only apply when --group is set")
|
||||
}
|
||||
// Direct-message tools (send_direct_message_as_user) reject an empty title at
|
||||
// the API level with a misleading "发群服务窗会话消息失败" error, so fail loudly
|
||||
// here instead. Group messages do not require a title.
|
||||
if (hasUser || hasOpenID) && strings.TrimSpace(title) == "" {
|
||||
// Both send_message_as_user (group) and send_direct_message_as_user (direct)
|
||||
// reject an empty title at the API level with a misleading
|
||||
// "发群服务窗会话消息失败" error, so fail loudly here instead. The schema
|
||||
// declares title as a required parameter on both tools.
|
||||
if strings.TrimSpace(title) == "" {
|
||||
if hasGroup {
|
||||
return nil, "", apperrors.NewValidation("--title is required for group messages (--group)")
|
||||
}
|
||||
return nil, "", apperrors.NewValidation("--title is required for direct messages (--user / --open-dingtalk-id)")
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestChatMessageSendRoutesByDestination(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "group",
|
||||
args: []string{"--group", "cid-xyz", "--text", "hello"},
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
|
||||
wantTool: "send_message_as_user",
|
||||
wantKey: "openConversation_id",
|
||||
wantValue: "cid-xyz",
|
||||
@@ -81,7 +81,7 @@ func TestChatMessageSendRoutesByDestination(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "positional-text",
|
||||
args: []string{"--group", "cid-xyz", "hello from positional"},
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "hello from positional"},
|
||||
wantTool: "send_message_as_user",
|
||||
wantKey: "text",
|
||||
wantValue: "hello from positional",
|
||||
@@ -132,6 +132,11 @@ func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
|
||||
args: []string{"--group", "cid-x"},
|
||||
wantErr: "--text (or positional argument) is required",
|
||||
},
|
||||
{
|
||||
name: "group-without-title",
|
||||
args: []string{"--group", "cid-x", "--text", "hi"},
|
||||
wantErr: "--title is required for group messages",
|
||||
},
|
||||
{
|
||||
name: "direct-user-without-title",
|
||||
args: []string{"--user", "034766", "--text", "hi"},
|
||||
|
||||
@@ -283,7 +283,19 @@ type CLIFlagOverride struct {
|
||||
// name and Alias, and reserved names ("json", "params") are skipped.
|
||||
// When any alias is set by the user, the binding's Required check is
|
||||
// satisfied and the value is written to params[Property].
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
// MapsTo redirects this flag's final value into a different MCP parameter
|
||||
// slot. When empty (default), the value is written to params[propertyName]
|
||||
// as today. When set, params[MapsTo] receives the (possibly transformed)
|
||||
// value and params[propertyName] is NOT written. This is what lets a
|
||||
// sibling CLI flag — e.g. --content-file with transform: file_read — feed
|
||||
// the same MCP parameter (markdown) as the existing literal --content
|
||||
// flag, without forcing one flag to do double-duty.
|
||||
//
|
||||
// Pair with the existing CLIToolOverride.MutuallyExclusive (tool-level
|
||||
// cobra MarkFlagsMutuallyExclusive) when two sibling flags map to the
|
||||
// same MCP slot but should not be set together.
|
||||
MapsTo string `json:"mapsTo,omitempty"`
|
||||
Transform string `json:"transform,omitempty"`
|
||||
TransformArgs map[string]any `json:"transformArgs,omitempty"`
|
||||
EnvDefault string `json:"envDefault,omitempty"`
|
||||
|
||||
+25
-10
@@ -79,6 +79,12 @@ func RunPreParse(root *cobra.Command, engine *Engine) {
|
||||
|
||||
// FlagInfoFromCommand extracts FlagInfo entries from a Cobra
|
||||
// command's registered flags (both local and inherited).
|
||||
//
|
||||
// JSON Schema "format" and "enum" hints injected via pflag
|
||||
// annotations (x-cli-format / x-cli-enum, see
|
||||
// internal/compat/dynamic_commands.go) are surfaced on FlagInfo
|
||||
// so PreParse handlers can validate sticky-split candidates against
|
||||
// the actual schema, not just the pflag type.
|
||||
func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
@@ -92,11 +98,7 @@ func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
|
||||
return
|
||||
}
|
||||
seen[f.Name] = true
|
||||
infos = append(infos, FlagInfo{
|
||||
Name: f.Name,
|
||||
PropertyName: f.Name,
|
||||
Type: f.Value.Type(),
|
||||
})
|
||||
infos = append(infos, flagInfoFromPflag(f))
|
||||
})
|
||||
|
||||
cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
|
||||
@@ -104,12 +106,25 @@ func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
|
||||
return
|
||||
}
|
||||
seen[f.Name] = true
|
||||
infos = append(infos, FlagInfo{
|
||||
Name: f.Name,
|
||||
PropertyName: f.Name,
|
||||
Type: f.Value.Type(),
|
||||
})
|
||||
infos = append(infos, flagInfoFromPflag(f))
|
||||
})
|
||||
|
||||
return infos
|
||||
}
|
||||
|
||||
// flagInfoFromPflag builds a FlagInfo from a pflag.Flag, copying
|
||||
// schema metadata stashed in the flag's annotations map.
|
||||
func flagInfoFromPflag(f *pflag.Flag) FlagInfo {
|
||||
fi := FlagInfo{
|
||||
Name: f.Name,
|
||||
PropertyName: f.Name,
|
||||
Type: f.Value.Type(),
|
||||
}
|
||||
if v := f.Annotations["x-cli-format"]; len(v) > 0 {
|
||||
fi.Format = v[0]
|
||||
}
|
||||
if v := f.Annotations["x-cli-enum"]; len(v) > 0 {
|
||||
fi.Enum = append([]string{}, v...)
|
||||
}
|
||||
return fi
|
||||
}
|
||||
|
||||
@@ -32,76 +32,101 @@ func TestFullPreParsePipeline(t *testing.T) {
|
||||
ParamNameHandler{},
|
||||
)
|
||||
|
||||
// Numeric / boolean flag typing matters for the sticky guard. The
|
||||
// helper below declares known flags as int so digit-led suffixes
|
||||
// pass the suffixLooksLikeValue check.
|
||||
intSpecs := func(names ...string) []pipeline.FlagInfo {
|
||||
out := make([]pipeline.FlagInfo, len(names))
|
||||
for i, n := range names {
|
||||
out[i] = pipeline.FlagInfo{Name: n, Type: "int"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
flags []string
|
||||
flags []pipeline.FlagInfo
|
||||
want string
|
||||
corrections int
|
||||
}{
|
||||
{
|
||||
name: "camelCase + sticky combined",
|
||||
args: []string{"--userId", "123", "--pageSize50"},
|
||||
flags: []string{"user-id", "page-size"},
|
||||
flags: intSpecs("user-id", "page-size"),
|
||||
want: "--user-id 123 --page-size 50",
|
||||
corrections: 2, // alias(userId) + sticky(pageSize50)
|
||||
},
|
||||
{
|
||||
name: "camelCase + typo combined",
|
||||
args: []string{"--userId", "123", "--limt", "10"},
|
||||
flags: []string{"user-id", "limit"},
|
||||
flags: intSpecs("user-id", "limit"),
|
||||
want: "--user-id 123 --limit 10",
|
||||
corrections: 2, // alias(userId) + fuzzy(limt)
|
||||
},
|
||||
{
|
||||
name: "triple error: case + sticky + typo",
|
||||
args: []string{"--UserName", "alice", "--limit100", "--offse", "0"},
|
||||
flags: []string{"user-name", "limit", "offset"},
|
||||
flags: append(flagSpecs("user-name"), intSpecs("limit", "offset")...),
|
||||
want: "--user-name alice --limit 100 --offset 0",
|
||||
corrections: 3,
|
||||
},
|
||||
{
|
||||
name: "snake_case + sticky",
|
||||
args: []string{"--user_id", "42", "--pageSize20"},
|
||||
flags: []string{"user-id", "page-size"},
|
||||
flags: intSpecs("user-id", "page-size"),
|
||||
want: "--user-id 42 --page-size 20",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "all correct — zero corrections",
|
||||
args: []string{"--user-id", "123", "--limit", "10"},
|
||||
flags: []string{"user-id", "limit"},
|
||||
flags: intSpecs("user-id", "limit"),
|
||||
want: "--user-id 123 --limit 10",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "UPPER case flags",
|
||||
args: []string{"--USER-ID", "999"},
|
||||
flags: []string{"user-id"},
|
||||
flags: intSpecs("user-id"),
|
||||
want: "--user-id 999",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "= syntax with camelCase",
|
||||
args: []string{"--userId=123", "--pageSize=50"},
|
||||
flags: []string{"user-id", "page-size"},
|
||||
flags: intSpecs("user-id", "page-size"),
|
||||
want: "--user-id=123 --page-size=50",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "camelCase sticky split with normalisation",
|
||||
args: []string{"--limitValue100"},
|
||||
flags: []string{"limit-value"},
|
||||
flags: intSpecs("limit-value"),
|
||||
want: "--limit-value 100",
|
||||
corrections: 1, // sticky handles both kebab-normalisation and split
|
||||
},
|
||||
|
||||
// Hardening: a mistyped flag whose name happens to start with
|
||||
// a real flag must NOT be split. The pipeline should leave the
|
||||
// token untouched so Cobra can raise "unknown flag".
|
||||
{
|
||||
name: "typo --starttime1 not split (date-time format)",
|
||||
args: []string{"--starttime1", "2026-02-07"},
|
||||
flags: []pipeline.FlagInfo{
|
||||
{Name: "start", Type: "string", Format: "date-time"},
|
||||
{Name: "end", Type: "string", Format: "date-time"},
|
||||
},
|
||||
want: "--starttime1 2026-02-07",
|
||||
corrections: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Args: append([]string{}, tt.args...),
|
||||
FlagSpecs: flagSpecs(tt.flags...),
|
||||
FlagSpecs: tt.flags,
|
||||
}
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("RunPhase error: %v", err)
|
||||
@@ -191,7 +216,11 @@ func TestFullPipelineEndToEnd(t *testing.T) {
|
||||
"--pageSize50",
|
||||
"--verbosetrue",
|
||||
},
|
||||
FlagSpecs: flagSpecs("user-id", "page-size", "verbose"),
|
||||
FlagSpecs: []pipeline.FlagInfo{
|
||||
{Name: "user-id", Type: "string"},
|
||||
{Name: "page-size", Type: "int"},
|
||||
{Name: "verbose", Type: "bool"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
@@ -287,7 +316,11 @@ func TestFullFivePhasePipeline(t *testing.T) {
|
||||
"--pageSize50",
|
||||
"--verbosetrue",
|
||||
}
|
||||
ctx.FlagSpecs = flagSpecs("user-id", "page-size", "verbose")
|
||||
ctx.FlagSpecs = []pipeline.FlagInfo{
|
||||
{Name: "user-id", Type: "string"},
|
||||
{Name: "page-size", Type: "int"},
|
||||
{Name: "verbose", Type: "bool"},
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("PreParse error: %v", err)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// StickyHandler detects glued flag-value pairs in raw argv and splits
|
||||
@@ -25,7 +26,12 @@ import (
|
||||
//
|
||||
// The handler only operates on tokens that start with "--" and do not
|
||||
// contain "=". It tries to match the longest known flag name prefix
|
||||
// and, if the remaining suffix is non-empty, splits the token.
|
||||
// and, if the remaining suffix is non-empty AND looks like a plausible
|
||||
// value for the matched flag's type/format, splits the token. The
|
||||
// type/format guard prevents misinterpreting mistyped flag names like
|
||||
// "--starttime1" as "--start time1" — when the suffix does not look
|
||||
// like a value, the original token is left untouched so Cobra can
|
||||
// report "unknown flag".
|
||||
type StickyHandler struct{}
|
||||
|
||||
func (StickyHandler) Name() string { return "sticky" }
|
||||
@@ -36,11 +42,11 @@ func (StickyHandler) Handle(ctx *pipeline.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
known := buildFlagSet(ctx.FlagSpecs)
|
||||
specByName := buildFlagSpecIndex(ctx.FlagSpecs)
|
||||
result := make([]string, 0, len(ctx.Args))
|
||||
|
||||
for _, arg := range ctx.Args {
|
||||
split, ok := trySplitSticky(arg, known)
|
||||
split, ok := trySplitSticky(arg, specByName)
|
||||
if ok {
|
||||
ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky")
|
||||
result = append(result, split.flag, split.value)
|
||||
@@ -65,12 +71,14 @@ type stickyPair struct {
|
||||
// - the prefix matches a known flag name (directly or after
|
||||
// kebab-case normalisation)
|
||||
// - the remaining suffix is non-empty
|
||||
// - the suffix looks like a plausible value for the matched flag's
|
||||
// declared type/format/enum (suffixLooksLikeValue)
|
||||
//
|
||||
// When multiple flag names match as prefixes, the longest one wins.
|
||||
// The handler also tries kebab-case normalisation of each prefix so
|
||||
// that camelCase+glued values like "--pageSize50" are correctly
|
||||
// split to "--page-size", "50".
|
||||
func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
|
||||
func trySplitSticky(arg string, specByName map[string]pipeline.FlagInfo) (stickyPair, bool) {
|
||||
if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
@@ -83,7 +91,10 @@ func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
|
||||
|
||||
// If the whole token is a known flag, it is not sticky — it is
|
||||
// a normal flag expecting a separate value token.
|
||||
if known[bare] || known[toKebabCase(bare)] {
|
||||
if _, ok := specByName[bare]; ok {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
if _, ok := specByName[toKebabCase(bare)]; ok {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
@@ -96,12 +107,14 @@ func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
|
||||
prefix := bare[:i]
|
||||
|
||||
matchedFlag := ""
|
||||
if known[prefix] {
|
||||
if _, ok := specByName[prefix]; ok {
|
||||
matchedFlag = prefix
|
||||
} else {
|
||||
kebab := toKebabCase(prefix)
|
||||
if kebab != "" && known[kebab] {
|
||||
matchedFlag = kebab
|
||||
if kebab != "" {
|
||||
if _, ok := specByName[kebab]; ok {
|
||||
matchedFlag = kebab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,14 +133,38 @@ func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
// Guard: only split if the suffix plausibly looks like a value
|
||||
// for this flag's declared type/format/enum. Otherwise leave the
|
||||
// token untouched so Cobra reports "unknown flag" instead of
|
||||
// silently corrupting the value.
|
||||
fi := specByName[bestFlag]
|
||||
if !cmdutil.SuffixLooksLikeValue(suffix, fi.Type, fi.Format, fi.Enum) {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
return stickyPair{
|
||||
flag: "--" + bestFlag,
|
||||
value: suffix,
|
||||
}, true
|
||||
}
|
||||
|
||||
// buildFlagSpecIndex creates an index of known flag names (without "--"
|
||||
// prefix) to their FlagInfo entries from the context's FlagSpecs.
|
||||
func buildFlagSpecIndex(specs []pipeline.FlagInfo) map[string]pipeline.FlagInfo {
|
||||
m := make(map[string]pipeline.FlagInfo, len(specs))
|
||||
for _, spec := range specs {
|
||||
if spec.Name != "" {
|
||||
m[spec.Name] = spec
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// buildFlagSet creates a set of known flag names (without "--" prefix)
|
||||
// from the context's FlagSpecs.
|
||||
//
|
||||
// Retained for other PreParse handlers (alias, paramname) that only
|
||||
// need name presence and do not consume the richer FlagInfo metadata.
|
||||
func buildFlagSet(specs []pipeline.FlagInfo) map[string]bool {
|
||||
m := make(map[string]bool, len(specs))
|
||||
for _, spec := range specs {
|
||||
|
||||
@@ -20,6 +20,12 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// flagSpecs is the legacy helper used by alias/paramname tests. It
|
||||
// produces string-typed FlagInfo entries which is fine for those
|
||||
// handlers because they don't consult the type.
|
||||
//
|
||||
// Sticky tests should use flagSpecsTyped (below) — the post-hardening
|
||||
// sticky decision depends on Type/Format/Enum.
|
||||
func flagSpecs(names ...string) []pipeline.FlagInfo {
|
||||
specs := make([]pipeline.FlagInfo, len(names))
|
||||
for i, name := range names {
|
||||
@@ -28,126 +34,254 @@ func flagSpecs(names ...string) []pipeline.FlagInfo {
|
||||
return specs
|
||||
}
|
||||
|
||||
// flagSpec is a compact builder used by sticky tests to declare typed
|
||||
// flags inline. Type defaults to "string" when unset.
|
||||
type flagSpec struct {
|
||||
name string
|
||||
typ string
|
||||
format string
|
||||
enum []string
|
||||
}
|
||||
|
||||
func specs(in ...flagSpec) []pipeline.FlagInfo {
|
||||
out := make([]pipeline.FlagInfo, len(in))
|
||||
for i, s := range in {
|
||||
t := s.typ
|
||||
if t == "" {
|
||||
t = "string"
|
||||
}
|
||||
out[i] = pipeline.FlagInfo{
|
||||
Name: s.name,
|
||||
Type: t,
|
||||
Format: s.format,
|
||||
Enum: s.enum,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestStickyHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
flags []string
|
||||
flags []pipeline.FlagInfo
|
||||
want string
|
||||
corrections int
|
||||
}{
|
||||
{
|
||||
name: "basic split --limit100",
|
||||
name: "basic split --limit100 (int)",
|
||||
args: []string{"--limit100"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--limit 100",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "no split when flag takes value separately",
|
||||
args: []string{"--limit", "100"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--limit 100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "no split when = syntax used",
|
||||
args: []string{"--limit=100"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--limit=100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "no split when flag name is not known",
|
||||
args: []string{"--unknown100"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--unknown100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "split with string value",
|
||||
args: []string{"--nameJohn"},
|
||||
flags: []string{"name"},
|
||||
want: "--name John",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "longest prefix wins",
|
||||
args: []string{"--user-id123"},
|
||||
flags: []string{"user", "user-id"},
|
||||
flags: specs(flagSpec{name: "user"}, flagSpec{name: "user-id", typ: "int"}),
|
||||
want: "--user-id 123",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple sticky args in one invocation",
|
||||
args: []string{"--limit100", "--offset50"},
|
||||
flags: []string{"limit", "offset"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}, flagSpec{name: "offset", typ: "int"}),
|
||||
want: "--limit 100 --offset 50",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "mixed sticky and normal args",
|
||||
args: []string{"--limit100", "--name", "test", "--offset50"},
|
||||
flags: []string{"limit", "name", "offset"},
|
||||
name: "mixed sticky and normal args",
|
||||
args: []string{"--limit100", "--name", "test", "--offset50"},
|
||||
flags: specs(
|
||||
flagSpec{name: "limit", typ: "int"},
|
||||
flagSpec{name: "name"},
|
||||
flagSpec{name: "offset", typ: "int"},
|
||||
),
|
||||
want: "--limit 100 --name test --offset 50",
|
||||
corrections: 2,
|
||||
},
|
||||
{
|
||||
name: "single dash prefix is ignored",
|
||||
args: []string{"-l100"},
|
||||
flags: []string{"l"},
|
||||
flags: specs(flagSpec{name: "l", typ: "int"}),
|
||||
want: "-l100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "empty args",
|
||||
args: []string{},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "bare double dash",
|
||||
args: []string{"--"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "no flag specs available",
|
||||
args: []string{"--limit100"},
|
||||
flags: []string{},
|
||||
flags: nil,
|
||||
want: "--limit100",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "exact flag name is not split",
|
||||
args: []string{"--limit"},
|
||||
flags: []string{"limit"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--limit",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "boolean-like value still splits",
|
||||
name: "boolean-like value splits when type is bool",
|
||||
args: []string{"--verbosetrue"},
|
||||
flags: []string{"verbose"},
|
||||
flags: specs(flagSpec{name: "verbose", typ: "bool"}),
|
||||
want: "--verbose true",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "hyphenated flag name with numeric suffix",
|
||||
args: []string{"--page-size50"},
|
||||
flags: []string{"page-size", "page"},
|
||||
flags: specs(flagSpec{name: "page-size", typ: "int"}, flagSpec{name: "page", typ: "int"}),
|
||||
want: "--page-size 50",
|
||||
corrections: 1,
|
||||
},
|
||||
|
||||
// --- Hardening cases: typo flags MUST NOT be misinterpreted ---
|
||||
{
|
||||
name: "typo --starttime1 not split (date-time format)",
|
||||
args: []string{"--starttime1", "2026-02-07"},
|
||||
flags: specs(flagSpec{
|
||||
name: "start", typ: "string", format: "date-time",
|
||||
}),
|
||||
want: "--starttime1 2026-02-07",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "glued ISO date splits cleanly (date-time format)",
|
||||
args: []string{"--start2026-02-07"},
|
||||
flags: specs(flagSpec{
|
||||
name: "start", typ: "string", format: "date-time",
|
||||
}),
|
||||
want: "--start 2026-02-07",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "int flag rejects alpha suffix",
|
||||
args: []string{"--limitabc"},
|
||||
flags: specs(flagSpec{name: "limit", typ: "int"}),
|
||||
want: "--limitabc",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "bool flag rejects non-literal suffix",
|
||||
args: []string{"--verbosehello"},
|
||||
flags: specs(flagSpec{name: "verbose", typ: "bool"}),
|
||||
want: "--verbosehello",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "string + enum: suffix hits enum splits",
|
||||
args: []string{"--statusapproved"},
|
||||
flags: specs(flagSpec{
|
||||
name: "status", typ: "string", enum: []string{"approved", "pending"},
|
||||
}),
|
||||
want: "--status approved",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "string + enum: suffix not in enum refuses split",
|
||||
args: []string{"--statusunknown"},
|
||||
flags: specs(flagSpec{
|
||||
name: "status", typ: "string", enum: []string{"approved", "pending"},
|
||||
}),
|
||||
want: "--statusunknown",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "email format: suffix containing @ splits",
|
||||
args: []string{"--emailfoo@bar.com"},
|
||||
flags: specs(flagSpec{
|
||||
name: "email", typ: "string", format: "email",
|
||||
}),
|
||||
want: "--email foo@bar.com",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "email format: suffix without @ refuses split",
|
||||
args: []string{"--emailalice"},
|
||||
flags: specs(flagSpec{
|
||||
name: "email", typ: "string", format: "email",
|
||||
}),
|
||||
want: "--emailalice",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "stringSlice: refuses to split (ambiguous)",
|
||||
args: []string{"--tagsfoo,bar"},
|
||||
flags: specs(flagSpec{name: "tags", typ: "stringSlice"}),
|
||||
want: "--tagsfoo,bar",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "plain string + no metadata: alpha suffix refuses split",
|
||||
args: []string{"--nameJohn"},
|
||||
flags: specs(flagSpec{name: "name"}),
|
||||
want: "--nameJohn",
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "plain string + no metadata: digit-led suffix splits",
|
||||
args: []string{"--name123"},
|
||||
flags: specs(flagSpec{name: "name"}),
|
||||
want: "--name 123",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "duration type: digit-led suffix splits",
|
||||
args: []string{"--timeout30s"},
|
||||
flags: specs(flagSpec{name: "timeout", typ: "duration"}),
|
||||
want: "--timeout 30s",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "duration type: alpha suffix refuses split",
|
||||
args: []string{"--timeoutever"},
|
||||
flags: specs(flagSpec{name: "timeout", typ: "duration"}),
|
||||
want: "--timeoutever",
|
||||
corrections: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Args: append([]string{}, tt.args...),
|
||||
FlagSpecs: flagSpecs(tt.flags...),
|
||||
FlagSpecs: tt.flags,
|
||||
}
|
||||
h := StickyHandler{}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
|
||||
@@ -120,8 +120,20 @@ type FlagInfo struct {
|
||||
// PropertyName is the original schema property key (e.g. "userId").
|
||||
PropertyName string
|
||||
|
||||
// Type is the JSON Schema type ("string", "integer", etc.).
|
||||
// Type is the JSON Schema / pflag type ("string", "integer",
|
||||
// "bool", "stringSlice", "duration", etc.).
|
||||
Type string
|
||||
|
||||
// Format carries the JSON Schema "format" hint when present —
|
||||
// e.g. "date", "date-time", "duration", "email", "uri", "ipv4".
|
||||
// PreParse handlers use this to decide whether a suffix in a
|
||||
// glued token (e.g. "--starttime1") looks like a plausible value.
|
||||
Format string
|
||||
|
||||
// Enum carries the JSON Schema "enum" string values when present.
|
||||
// PreParse handlers use this for sticky-split validation: a glued
|
||||
// suffix is accepted only if it matches one of the enum entries.
|
||||
Enum []string
|
||||
}
|
||||
|
||||
// Correction records a single input correction applied by a handler.
|
||||
|
||||
@@ -106,6 +106,7 @@ func (s *StdioClient) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Stop kills the subprocess and waits for it to exit.
|
||||
// A non-zero exit code after Kill is expected and not treated as an error.
|
||||
func (s *StdioClient) Stop() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -118,6 +119,9 @@ func (s *StdioClient) Stop() error {
|
||||
|
||||
if s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
_ = s.cmd.Wait()
|
||||
s.started = false
|
||||
return nil
|
||||
}
|
||||
err := s.cmd.Wait()
|
||||
s.started = false
|
||||
|
||||
@@ -97,10 +97,9 @@ func TestStdioClientEndToEnd(t *testing.T) {
|
||||
t.Error("CallTool with unknown tool should return error")
|
||||
}
|
||||
|
||||
// Stop
|
||||
// Stop — after kill, Stop should return nil (non-zero exit is suppressed)
|
||||
if err := client.Stop(); err != nil {
|
||||
// Process killed, expected to return an error
|
||||
_ = err
|
||||
t.Errorf("Stop after kill should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+78
-6
@@ -15,6 +15,7 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -75,6 +76,41 @@ func DetectNumericTypeError(err error) (flagName, badValue string, ok bool) {
|
||||
return rest[:endIdx], badVal, true
|
||||
}
|
||||
|
||||
// flagFixCandidate reports whether f should participate in unknown-flag
|
||||
// suggestion candidates and Flags: listings. Hidden flags (e.g. wukong's
|
||||
// MarkHidden compatibility aliases) and internal json/params merge flags
|
||||
// are skipped so the hint candidate set stays a subset of what --help shows.
|
||||
func flagFixCandidate(f *pflag.Flag) bool {
|
||||
if f == nil || f.Hidden {
|
||||
return false
|
||||
}
|
||||
switch f.Name {
|
||||
case "json", "params":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// VisibleFlagNames returns sorted candidate flag names for cmd.Flags()
|
||||
// using flagFixCandidate. Intended for agent-facing error recovery
|
||||
// (available_flags).
|
||||
func VisibleFlagNames(cmd *cobra.Command) []string {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var names []string
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !flagFixCandidate(f) || seen[f.Name] {
|
||||
return
|
||||
}
|
||||
seen[f.Name] = true
|
||||
names = append(names, f.Name)
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// SuggestFlagFix detects flag-value concatenation errors, common flag aliases,
|
||||
// and Levenshtein-close typos.
|
||||
func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
|
||||
@@ -92,6 +128,9 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
|
||||
|
||||
var bestFlag, bestValue string
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !flagFixCandidate(f) {
|
||||
return
|
||||
}
|
||||
name := f.Name
|
||||
if strings.HasPrefix(body, name) && len(body) > len(name) {
|
||||
if len(name) > len(bestFlag) {
|
||||
@@ -101,16 +140,28 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
|
||||
}
|
||||
})
|
||||
if bestFlag != "" {
|
||||
canAutoFix := len(bestValue) > 0
|
||||
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
|
||||
if canAutoFix {
|
||||
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
|
||||
lf := cmd.Flags().Lookup(bestFlag)
|
||||
if lf != nil {
|
||||
fmtStr := ""
|
||||
if v := lf.Annotations["x-cli-format"]; len(v) > 0 {
|
||||
fmtStr = v[0]
|
||||
}
|
||||
var enumCopy []string
|
||||
if v := lf.Annotations["x-cli-enum"]; len(v) > 0 {
|
||||
enumCopy = append([]string{}, v...)
|
||||
}
|
||||
if SuffixLooksLikeValue(bestValue, lf.Value.Type(), fmtStr, enumCopy) {
|
||||
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
|
||||
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
|
||||
}
|
||||
}
|
||||
return FlagFixResult{Suggestion: suggestion}
|
||||
}
|
||||
|
||||
bestName, bestDist := "", 999
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !flagFixCandidate(f) {
|
||||
return
|
||||
}
|
||||
d := LevenshteinDist(body, f.Name)
|
||||
if d < bestDist {
|
||||
bestDist = d
|
||||
@@ -119,12 +170,33 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
|
||||
})
|
||||
threshold := LevenshteinThreshold(len(body))
|
||||
if bestDist > 0 && bestDist <= threshold && bestName != "" {
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?", bestName)}
|
||||
suf := formatFlagHintSuffix(cmd.Flags().Lookup(bestName))
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?%s", bestName, suf)}
|
||||
}
|
||||
|
||||
return FlagFixResult{Suggestion: fmt.Sprintf("Run '%s --help' to see available options", cmd.CommandPath())}
|
||||
}
|
||||
|
||||
func formatFlagHintSuffix(f *pflag.Flag) string {
|
||||
if f == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if u := strings.TrimSpace(f.Usage); u != "" {
|
||||
if len(u) > 100 {
|
||||
u = u[:97] + "..."
|
||||
}
|
||||
parts = append(parts, u)
|
||||
}
|
||||
if v := f.Annotations["x-cli-format"]; len(v) > 0 && v[0] != "" {
|
||||
parts = append(parts, "format="+v[0])
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " (" + strings.Join(parts, ", ") + ")"
|
||||
}
|
||||
|
||||
// LevenshteinThreshold returns the max edit distance allowed based on string length.
|
||||
func LevenshteinThreshold(nameLen int) int {
|
||||
if nameLen <= 3 {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSuggestFlagFix_falseGlue_starttime1(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
|
||||
err := errUnknownFlag("starttime1")
|
||||
fix := SuggestFlagFix(cmd, err)
|
||||
if strings.Contains(fix.Suggestion, "Space required") {
|
||||
t.Fatalf("should not treat as glued value, got %q", fix.Suggestion)
|
||||
}
|
||||
if fix.AutoFixFlag != "" {
|
||||
t.Fatalf("AutoFixFlag = %q, want empty", fix.AutoFixFlag)
|
||||
}
|
||||
if !strings.Contains(fix.Suggestion, "--help") {
|
||||
t.Fatalf("expected help fallback, got %q", fix.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestFlagFix_trueGlue_isoDateSuffix(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
|
||||
err := errUnknownFlag("start2026-02-07")
|
||||
fix := SuggestFlagFix(cmd, err)
|
||||
wantSub := "Space required between flag and value: --start 2026-02-07"
|
||||
if fix.Suggestion != wantSub {
|
||||
t.Fatalf("Suggestion = %q, want %q", fix.Suggestion, wantSub)
|
||||
}
|
||||
if fix.AutoFixFlag != "start" || fix.AutoFixValue != "2026-02-07" {
|
||||
t.Fatalf("AutoFix = %q/%q, want start/2026-02-07", fix.AutoFixFlag, fix.AutoFixValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestFlagFix_levenshteinAddsUsage(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "send"}
|
||||
cmd.Flags().String("conversation-id", "", "Conversation id")
|
||||
|
||||
err := errUnknownFlag("conversaton-id")
|
||||
fix := SuggestFlagFix(cmd, err)
|
||||
if !strings.HasPrefix(fix.Suggestion, "Did you mean --conversation-id?") {
|
||||
t.Fatalf("unexpected: %q", fix.Suggestion)
|
||||
}
|
||||
if !strings.Contains(fix.Suggestion, "Conversation id") {
|
||||
t.Fatalf("expected usage in hint, got %q", fix.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestFlagFix_skipsHiddenAlias(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().String("start", "", "begin time")
|
||||
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
|
||||
cmd.Flags().String("start-time", "", "")
|
||||
_ = cmd.Flags().MarkHidden("start-time")
|
||||
|
||||
fix := SuggestFlagFix(cmd, errUnknownFlag("starttime1"))
|
||||
if strings.Contains(fix.Suggestion, "start-time") {
|
||||
t.Fatalf("must not recommend hidden alias, got %q", fix.Suggestion)
|
||||
}
|
||||
if !strings.Contains(fix.Suggestion, "--help") {
|
||||
t.Fatalf("expected help fallback, got %q", fix.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleFlagNames_skipsHiddenAndInternal(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("alpha", "", "")
|
||||
cmd.Flags().String("json", "", "")
|
||||
cmd.Flags().String("beta", "", "")
|
||||
_ = cmd.Flags().MarkHidden("beta")
|
||||
|
||||
names := VisibleFlagNames(cmd)
|
||||
if len(names) != 1 || names[0] != "alpha" {
|
||||
t.Fatalf("got %v, want [alpha]", names)
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownFlag(body string) error {
|
||||
return &stubFlagErr{msg: "unknown flag: --" + body}
|
||||
}
|
||||
|
||||
type stubFlagErr struct{ msg string }
|
||||
|
||||
func (e *stubFlagErr) Error() string { return e.msg }
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// SuffixLooksLikeValue decides whether a candidate suffix from a glued
|
||||
// "--flagsuffix" token plausibly represents a value for a flag's
|
||||
// declared type/format/enum. Shared by StickyHandler (PreParse) and
|
||||
// SuggestFlagFix (unknown-flag recovery).
|
||||
//
|
||||
// typ is a pflag value type string (e.g. "int", "bool", "string");
|
||||
// format is JSON Schema "format" when present (e.g. "date-time");
|
||||
// enum is the schema enum list when present.
|
||||
func SuffixLooksLikeValue(suffix, typ, format string, enum []string) bool {
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
switch typ {
|
||||
case "int", "int8", "int16", "int32", "int64",
|
||||
"uint", "uint8", "uint16", "uint32", "uint64",
|
||||
"integer", "number", "float", "float32", "float64",
|
||||
"count":
|
||||
return startsWithNumericSuffix(suffix)
|
||||
|
||||
case "bool", "boolean":
|
||||
return isBoolLiteralSuffix(suffix)
|
||||
|
||||
case "duration":
|
||||
return startsWithDigitSuffix(suffix) || startsWithSignSuffix(suffix)
|
||||
|
||||
case "stringSlice", "stringArray", "intSlice", "boolSlice", "float32Slice",
|
||||
"float64Slice", "uintSlice", "durationSlice", "ipSlice", "array":
|
||||
return false
|
||||
|
||||
case "object":
|
||||
return false
|
||||
}
|
||||
|
||||
if len(enum) > 0 {
|
||||
return matchesEnumSuffix(suffix, enum)
|
||||
}
|
||||
|
||||
switch strings.ToLower(format) {
|
||||
case "date", "date-time", "datetime", "time":
|
||||
return startsWithDigitSuffix(suffix)
|
||||
case "duration":
|
||||
return startsWithDigitSuffix(suffix) || startsWithSignSuffix(suffix)
|
||||
case "email":
|
||||
return strings.Contains(suffix, "@")
|
||||
case "uri", "url":
|
||||
lower := strings.ToLower(suffix)
|
||||
return strings.HasPrefix(lower, "http") ||
|
||||
strings.HasPrefix(lower, "ftp") ||
|
||||
strings.HasPrefix(lower, "mailto:")
|
||||
case "ipv4", "ipv6", "hostname":
|
||||
return startsWithDigitSuffix(suffix)
|
||||
case "uuid":
|
||||
first, _ := utf8.DecodeRuneInString(suffix)
|
||||
return isHexRuneSuffix(first)
|
||||
}
|
||||
|
||||
first, _ := utf8.DecodeRuneInString(suffix)
|
||||
if first == utf8.RuneError || unicode.IsLetter(first) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func startsWithDigitSuffix(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
c := s[0]
|
||||
return c >= '0' && c <= '9'
|
||||
}
|
||||
|
||||
func startsWithSignSuffix(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
c := s[0]
|
||||
return c == '+' || c == '-'
|
||||
}
|
||||
|
||||
func startsWithNumericSuffix(s string) bool {
|
||||
if startsWithDigitSuffix(s) {
|
||||
return true
|
||||
}
|
||||
if startsWithSignSuffix(s) && len(s) > 1 {
|
||||
c := s[1]
|
||||
return c >= '0' && c <= '9'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBoolLiteralSuffix(s string) bool {
|
||||
switch strings.ToLower(s) {
|
||||
case "true", "false", "1", "0", "t", "f", "yes", "no", "on", "off", "y", "n":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesEnumSuffix(s string, enum []string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, e := range enum {
|
||||
if strings.ToLower(e) == lower {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHexRuneSuffix(r rune) bool {
|
||||
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSuffixLooksLikeValue_UTF8FirstRune locks in the UTF-8 first-rune
|
||||
// reading contract on SuffixLooksLikeValue. The function previously read
|
||||
// suffix[0] (a single byte) which produced incorrect splits / matches for
|
||||
// any multi-byte first rune — relevant because dws is a Chinese-language
|
||||
// CLI and value text often starts with CJK characters.
|
||||
func TestSuffixLooksLikeValue_UTF8FirstRune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
suffix string
|
||||
typ string
|
||||
format string
|
||||
enum []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
// --name张三 must NOT be split: with no metadata the fallback
|
||||
// branch should treat a CJK letter as "not a value-looking
|
||||
// suffix" so cobra reports unknown flag instead of cutting
|
||||
// the user's typo into --name + 张三.
|
||||
name: "plain string + CJK letter suffix refuses split",
|
||||
suffix: "张三",
|
||||
typ: "string",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// CJK leading rune is fine as long as the email-format guard
|
||||
// ('@' anywhere in suffix) still passes.
|
||||
name: "email format + CJK leading + @ allows split",
|
||||
suffix: "张三@example.com",
|
||||
typ: "string",
|
||||
format: "email",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// uuid format: first rune must be hex. CJK starting char is
|
||||
// not hex so the suffix must be rejected.
|
||||
name: "uuid format + CJK leading rejects split",
|
||||
suffix: "张abcd",
|
||||
typ: "string",
|
||||
format: "uuid",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Baseline: digit-led suffix on int still splits — guards
|
||||
// against accidental over-tightening of the fallback path.
|
||||
name: "int + digit-led baseline still splits",
|
||||
suffix: "100",
|
||||
typ: "int",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// Invalid UTF-8 (lone continuation byte 0x80) decodes as
|
||||
// utf8.RuneError; the RuneError guard makes the fallback
|
||||
// reject it instead of treating it as "non-letter, splittable".
|
||||
name: "plain string + invalid UTF-8 leading byte refuses split",
|
||||
suffix: "\x80abc",
|
||||
typ: "string",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := SuffixLooksLikeValue(tc.suffix, tc.typ, tc.format, tc.enum)
|
||||
if got != tc.want {
|
||||
t.Errorf("SuffixLooksLikeValue(%q, %q, %q, %v) = %v, want %v",
|
||||
tc.suffix, tc.typ, tc.format, tc.enum, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -74,23 +74,38 @@ const (
|
||||
DefaultPartition = "default/default"
|
||||
)
|
||||
|
||||
// EditionPartition returns the cache partition for a given edition name.
|
||||
// The open-source core (name == "" or "open") uses DefaultPartition; every
|
||||
// other edition gets its own namespace to prevent cross-edition data
|
||||
// leakage in the disk cache.
|
||||
// IsOpenEdition reports whether an edition name maps to the open-source core.
|
||||
//
|
||||
// This helper takes the edition name as a parameter instead of calling
|
||||
// edition.Get() so that pkg/config remains a leaf dependency — importable
|
||||
// from internal/cli, internal/app, internal/cache, etc. without risking
|
||||
// import cycles.
|
||||
func IsOpenEdition(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
return name == "" || name == "open"
|
||||
}
|
||||
|
||||
// EditionPartition returns the cache partition for a given edition name.
|
||||
// The open-source core (name == "" or "open") uses DefaultPartition; every
|
||||
// other edition gets its own namespace to prevent cross-edition data
|
||||
// leakage in the disk cache.
|
||||
func EditionPartition(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "open" {
|
||||
if IsOpenEdition(name) {
|
||||
return DefaultPartition
|
||||
}
|
||||
return name + "/default"
|
||||
}
|
||||
|
||||
// EditionFileName returns the edition-partitioned file name for base+ext.
|
||||
func EditionFileName(name, base, ext string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if IsOpenEdition(name) {
|
||||
return base + ext
|
||||
}
|
||||
return base + "-" + name + ext
|
||||
}
|
||||
|
||||
// ── Auth flow timeouts ──────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
|
||||
@@ -96,6 +96,48 @@ func TestEditionPartition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOpenEdition(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"empty is open", "", true},
|
||||
{"open is open", "open", true},
|
||||
{"whitespace open is open", " open ", true},
|
||||
{"wukong is sibling", "wukong", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsOpenEdition(tc.input); got != tc.want {
|
||||
t.Fatalf("IsOpenEdition(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditionFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
edition string
|
||||
want string
|
||||
}{
|
||||
{"empty uses legacy filename", "", "app.json"},
|
||||
{"open uses legacy filename", "open", "app.json"},
|
||||
{"whitespace is trimmed", " wukong ", "app-wukong.json"},
|
||||
{"sibling is suffixed", "wukong", "app-wukong.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := EditionFileName(tc.edition, "app", ".json"); got != tc.want {
|
||||
t.Fatalf("EditionFileName(%q, \"app\", \".json\") = %q, want %q", tc.edition, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualTokenExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
if ManualTokenExpiry <= 0 {
|
||||
|
||||
@@ -183,7 +183,7 @@ Flags:
|
||||
|
||||
## message send — 以当前用户身份发消息
|
||||
|
||||
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。单聊消息(--user / --open-dingtalk-id)必须提供 --title 作为消息标题;群聊可选。
|
||||
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。`--title` 是消息标题,**群聊与单聊都必填**(API 强制要求;缺失时服务端返回误导性的 "发群服务窗会话消息失败",CLI 现在前置校验直接报错)。
|
||||
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
|
||||
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
|
||||
|
||||
@@ -191,15 +191,15 @@ Flags:
|
||||
Usage:
|
||||
dws chat message send [flags] [<text>]
|
||||
Example:
|
||||
dws chat message send --group <openconversation_id> --text "hello"
|
||||
dws chat message send --group <openconversation_id> --title "周报" --text "请提交本周日报"
|
||||
dws chat message send --user <userId> --title "提醒" --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请查收"
|
||||
dws chat message send --group <openconversation_id> "hello"
|
||||
dws chat message send --group <openconversation_id> --title "通知" "hello"
|
||||
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
|
||||
dws chat message send --group <openconversation_id> --at-all "<@all> 请大家注意"
|
||||
dws chat message send --group <openconversation_id> --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
|
||||
dws chat message send --group <openconversation_id> --media-id <mediaId>
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --media-id <mediaId>
|
||||
dws chat message send --group <openconversation_id> --title "通知" --at-all "<@all> 请大家注意"
|
||||
dws chat message send --group <openconversation_id> --title "通知" --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
|
||||
dws chat message send --group <openconversation_id> --title "图片" --media-id <mediaId>
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --title "图片" --media-id <mediaId>
|
||||
Flags:
|
||||
--text string 消息内容(推荐使用,也可用位置参数)
|
||||
--group string 群聊 openconversation_id(群聊时必填)
|
||||
@@ -219,6 +219,7 @@ Flags:
|
||||
|
||||
注意:
|
||||
- --text 和位置参数二选一,--text 优先
|
||||
- --title 必填(群聊与单聊都必填,API 强制要求)
|
||||
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
|
||||
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
|
||||
- --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
|
||||
@@ -722,7 +723,7 @@ dws drive download --file-id <dentryUuid> --format json
|
||||
|
||||
# Step 5: 用 Markdown 图片语法发送
|
||||
dws chat message send --group <openconversation_id> \
|
||||
--text "" --format json
|
||||
--title "截图" --text "" --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
Reference in New Issue
Block a user