Compare commits

..
6 Commits
Author SHA1 Message Date
修雨 d268524084 docs(changelog): add 1.0.28 release notes (#295) 2026-05-14 21:34:28 +08:00
修雨 ed4673e7d2 fix(chat): require --title for group messages, completing #250's symmetric fix (#294)
`send_message_as_user`'s schema also marks `title` as required, but
`buildChatMessageSendInvocation` only pre-validated it for direct
messages. Sending `dws chat message send --group <cid> --text "1"`
without `--title` therefore reached the API and returned the same
misleading `发群服务窗会话消息失败` business error that #250 fixed for
direct messages, just on the other branch.

Pre-validation now covers both branches: group sends without title
return `--title is required for group messages (--group)`, direct sends
keep the existing `--title is required for direct messages (...)`
message. `Long` help, the `--title` flag description, the first
`Example` line, and `skills/references/products/chat.md` (including the
deeper "drive → chat" workflow example) are realigned to
"群聊与单聊都必填". `internal/helpers/chat_test.go` gains a
`group-without-title` case, and the existing `group` / `positional-text`
success cases are updated to pass `--title`. No API request shape change.
2026-05-14 21:13:28 +08:00
修雨 de723914a5 docs(changelog): add 1.0.27 release notes (#293)
* docs(changelog): add 1.0.27 release notes

Covers what landed on main since 1.0.26:

- #291: file_read transform + CLIFlagOverride.MapsTo field, and the
  envelope-side rollout that turns it into `dws doc update --content`
  / `--content-file` (literal vs file/stdin → markdown).
- envelope: `dws sheet find --query` hidden alias via the existing
  CLIFlagOverride.Aliases — keeps wukong-doc copy-paste working on
  open-source. Needs `dws cache refresh` once.
- #285: suppress noisy WARN on stdio client shutdown.

* docs(changelog): translate 1.0.27 entry to English
2026-05-14 15:50:45 +08:00
FuShu-Yang 649801e479 fix(transport): suppress noisy WARN on stdio client shutdown (#285)
* fix(transport): suppress noisy WARN on stdio client shutdown

When Stop() kills the subprocess, cmd.Wait() always returns a non-zero
exit code which is expected behavior. Previously this propagated as an
error, causing "failed to stop stdio client ... exit status 1" warnings
on every normal CLI exit for stdio-based plugins.

Now Stop() returns nil when the process was explicitly killed, keeping
the error path only for cases where the process exits on its own with
a non-zero code (e.g. stdin close without kill).

🤖 Generated with [Qoder][https://qoder.com]

* fix(transport): address review feedback — simplify Stop(), fix test assertion

- Remove redundant `killed` flag; inline Kill+Wait+return nil
- Update integration test to positively assert Stop() returns nil after kill

🤖 Generated with [Qoder][https://qoder.com]
2026-05-14 14:45:23 +08:00
修雨 49c5bea4f3 feat(schema): file_read transform + CLIFlagOverride.MapsTo for --content/--content-file (#277 #278 #282 #288) (#291)
* feat(transform): add file_read transform for UTF-8 file / stdin content (#277)

Introduces a new ApplyTransform case "file_read":
- Input: a string flag value treated as a UTF-8 file path
- Special case: the path "-" reads from stdin
- Output: the file contents as a string
- Errors surface as validation errors (exit 2) — non-UTF-8, missing
  file, empty path, and non-string input all reject cleanly

This is a foundational primitive intended to compose with envelope
schema features so a CLI flag like --content-file can carry a path
that ultimately feeds a string-typed MCP parameter with the file's
contents.

## Scope note — MapsTo intentionally NOT included

The original proposal in #277 paired this transform with a new
CLIFlagOverride.MapsTo field that retargets a flag's value to a
different MCP parameter (e.g. --content / --content-file both feed
the upstream `markdown` param). Pre-production end-to-end validation
(see #282) showed that the MCP registry server-side schema does not
currently recognise `mapsTo` and strips it on serialisation. Every
other new envelope field (hidden / required / transform /
mutuallyExclusive / requireOneOf) survives — only mapsTo is dropped.

Shipping MapsTo without server-side support would land dead client
code. The MapsTo field + sourceFlag transform guard were therefore
removed from this PR; they will return in a follow-up PR once #282
(server-side schema acknowledgement of mapsTo) is resolved. The
file_read transform stays here because it composes with multiple
mechanisms beyond MapsTo and is independently testable.

## Tests

Six new cases in internal/compat/transform_test.go cover the
contract: literal file, stdin via "-", missing file, non-UTF-8
input, empty path, non-string input. All pass under both `go test`
and `go test -coverprofile`.

Refs #277, blocked-by #282

* feat(schema): add CLIFlagOverride.MapsTo for sibling-flag routing (#277 #282)

Adds the `MapsTo` field on `CLIFlagOverride` and wires the dispatch loop
in `compat.buildOverrideBindings` so a flag's final value (post-transform
or literal) is routed into a different MCP parameter slot than its own
property name. This lets two sibling CLI flags feed a single upstream
parameter — the canonical case being `--content` (literal) + `--content-file`
(transform: file_read) both mapping to `markdown`.

Tool-level `CLIToolOverride.MutuallyExclusive` (cobra MarkFlagsMutuallyExclusive)
is the right partner for guarding "set one, not both" at parse time; no
new exclusion machinery is added.

Closes the client-side gap previously misattributed to a server-side
mapsTo strip in #282. Once a doc envelope with mapsTo lands in pre-prod,
end-to-end `--content-file` becomes shippable, finishing #277 Step 1b.

Test coverage (internal/compat/dynamic_commands_test.go):
  - MapsTo without transform: literal --content → params[markdown]
  - MapsTo with file_read transform: --content-file path → params[markdown]
  - Sibling flags both mapsTo same target, only one set: clean routing
  - Sibling flags both set: rejected by tool-level MutuallyExclusive (regression)

Backward-compat: empty MapsTo preserves existing params[propertyName] write
semantics for every existing envelope.
2026-05-14 14:44:00 +08:00
6707e56f9c feat(cli): schema-aware sticky flag splitting and structured unknown-flag recovery (#272)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* 1.0.19 changelog

* stick opt

* fix(cli): utf-8 safe sticky suffix guard + changelog (#272)

SuffixLooksLikeValue used to read suffix[0] (a single byte) for both the
uuid format branch and the fallback "is the first rune a letter?" check.
For multi-byte UTF-8 leading runes — common in dws because value text is
often Chinese — this picked up only the first byte (0xE0..0xF4 lead),
which is not a letter and not a hex digit, so the function silently
returned true and let glued tokens like --name<CJK> get split into
--name <CJK>... This switches both branches to utf8.DecodeRuneInString
and adds a utf8.RuneError guard so invalid UTF-8 input is rejected too.

Also locks down the new behaviour in CHANGELOG ## [Unreleased]:
- Changed: schema-aware sticky flag splitting
- Added: available_flags field on unknown-flag errors

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 10:56:02 +08:00
24 changed files with 1485 additions and 110 deletions
+33
View File
@@ -4,6 +4,31 @@ 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.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 +37,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` 全部对齐为「单聊必填,群聊可选」。群聊行为不变。
+75
View File
@@ -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)
}
}
+23 -1
View File
@@ -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)...),
)
}
}
+70 -10
View File
@@ -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
+282
View File
@@ -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
}
+44 -1
View File
@@ -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:
+136
View File
@@ -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
View File
@@ -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()
}
+21
View File
@@ -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()
+11 -7
View File
@@ -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)")
}
+7 -2
View File
@@ -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"},
+13 -1
View File
@@ -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
View File
@@ -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
}
+45 -12
View File
@@ -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)
+45 -8
View File
@@ -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 {
+161 -27
View File
@@ -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 {
+13 -1
View File
@@ -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.
+4
View File
@@ -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
+2 -3
View File
@@ -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
View File
@@ -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 {
+106
View File
@@ -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 }
+133
View File
@@ -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')
}
+91
View File
@@ -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)
}
})
}
}
+9 -8
View File
@@ -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
```
## 上下文传递表