From f75227aad15d04c1a0dc515426321dfa673eb3a8 Mon Sep 17 00:00:00 2001 From: audanye-sudo Date: Sun, 29 Mar 2026 16:28:19 +0800 Subject: [PATCH 1/6] feat: add pipeline engine for pre-parse and post-parse input correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AliasHandler: normalise model-generated flag casing (--userId → --user-id) - StickyHandler: split glued flag values (--limit100 → --limit 100) - ParamNameHandler: fix near-miss flag typos (--limt → --limit) - ParamValueHandler: normalise structured parameter values after parsing --- internal/pipeline/cobra.go | 115 ++++++ internal/pipeline/engine.go | 98 +++++ internal/pipeline/engine_test.go | 300 +++++++++++++++ internal/pipeline/handler.go | 37 ++ internal/pipeline/handlers/alias.go | 146 ++++++++ internal/pipeline/handlers/alias_test.go | 186 +++++++++ .../pipeline/handlers/integration_test.go | 284 ++++++++++++++ internal/pipeline/handlers/paramname.go | 164 ++++++++ internal/pipeline/handlers/paramname_test.go | 191 ++++++++++ internal/pipeline/handlers/paramvalue.go | 312 ++++++++++++++++ internal/pipeline/handlers/paramvalue_test.go | 352 ++++++++++++++++++ internal/pipeline/handlers/sticky.go | 139 +++++++ internal/pipeline/handlers/sticky_test.go | 183 +++++++++ internal/pipeline/phase.go | 158 ++++++++ 14 files changed, 2665 insertions(+) create mode 100644 internal/pipeline/cobra.go create mode 100644 internal/pipeline/engine.go create mode 100644 internal/pipeline/engine_test.go create mode 100644 internal/pipeline/handler.go create mode 100644 internal/pipeline/handlers/alias.go create mode 100644 internal/pipeline/handlers/alias_test.go create mode 100644 internal/pipeline/handlers/integration_test.go create mode 100644 internal/pipeline/handlers/paramname.go create mode 100644 internal/pipeline/handlers/paramname_test.go create mode 100644 internal/pipeline/handlers/paramvalue.go create mode 100644 internal/pipeline/handlers/paramvalue_test.go create mode 100644 internal/pipeline/handlers/sticky.go create mode 100644 internal/pipeline/handlers/sticky_test.go create mode 100644 internal/pipeline/phase.go diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go new file mode 100644 index 00000000..7c401735 --- /dev/null +++ b/internal/pipeline/cobra.go @@ -0,0 +1,115 @@ +// 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 pipeline + +import ( + "log/slog" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// RunPreParse resolves the target command from the raw args, extracts +// flag names from the Cobra command tree, and runs all PreParse +// handlers. The corrected args are set back on the root command via +// SetArgs so that Cobra's subsequent ExecuteC uses the corrected +// values. +// +// If the target command cannot be resolved (e.g. the user typed a +// non-existent command), PreParse is skipped silently and Cobra will +// handle the error. +func RunPreParse(root *cobra.Command, engine *Engine) { + if engine == nil || !engine.HasHandlers(PreParse) { + return + } + + rawArgs := os.Args[1:] + if len(rawArgs) == 0 { + return + } + + // Traverse the command tree to find the target command. + target, _, err := root.Traverse(rawArgs) + if err != nil || target == nil { + return + } + + // Build FlagInfo from the target command's registered flags. + flagInfos := FlagInfoFromCommand(target) + if len(flagInfos) == 0 { + return + } + + ctx := &Context{ + Args: append([]string{}, rawArgs...), + FlagSpecs: flagInfos, + } + + if err := engine.RunPhase(PreParse, ctx); err != nil { + slog.Debug("pipeline pre-parse", "error", err) + return + } + + // Only set corrected args if PreParse actually changed something. + if len(ctx.Corrections) > 0 { + root.SetArgs(ctx.Args) + for _, c := range ctx.Corrections { + slog.Debug("pipeline correction", + "handler", c.Handler, + "kind", c.Kind, + "field", c.Field, + "original", c.Original, + "corrected", c.Corrected, + ) + } + } +} + +// FlagInfoFromCommand extracts FlagInfo entries from a Cobra +// command's registered flags (both local and inherited). +func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo { + if cmd == nil { + return nil + } + + seen := make(map[string]bool) + var infos []FlagInfo + + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if seen[f.Name] { + return + } + seen[f.Name] = true + infos = append(infos, FlagInfo{ + Name: f.Name, + PropertyName: f.Name, + Type: f.Value.Type(), + }) + }) + + cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) { + if seen[f.Name] { + return + } + seen[f.Name] = true + infos = append(infos, FlagInfo{ + Name: f.Name, + PropertyName: f.Name, + Type: f.Value.Type(), + }) + }) + + return infos +} diff --git a/internal/pipeline/engine.go b/internal/pipeline/engine.go new file mode 100644 index 00000000..87496191 --- /dev/null +++ b/internal/pipeline/engine.go @@ -0,0 +1,98 @@ +// 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 pipeline + +import "fmt" + +// Engine manages handler registration and executes the pipeline +// chain. Handlers are grouped by phase and executed in registration +// order within each phase. The engine is safe to use concurrently +// for reads after all handlers have been registered; registration +// itself is not concurrent-safe and should be done at startup. +type Engine struct { + handlers map[Phase][]Handler +} + +// NewEngine creates a pipeline engine with no registered handlers. +func NewEngine() *Engine { + return &Engine{ + handlers: make(map[Phase][]Handler), + } +} + +// Register adds a handler to its declared phase. Handlers within +// the same phase execute in registration order. +func (e *Engine) Register(h Handler) { + phase := h.Phase() + e.handlers[phase] = append(e.handlers[phase], h) +} + +// RegisterAll registers multiple handlers at once. +func (e *Engine) RegisterAll(handlers ...Handler) { + for _, h := range handlers { + e.Register(h) + } +} + +// Handlers returns the registered handlers for a given phase, in +// registration order. The returned slice must not be modified. +func (e *Engine) Handlers(phase Phase) []Handler { + return e.handlers[phase] +} + +// HasHandlers reports whether at least one handler is registered +// for the given phase. +func (e *Engine) HasHandlers(phase Phase) bool { + return len(e.handlers[phase]) > 0 +} + +// RunPhase executes all handlers registered for the given phase in +// chain order. If any handler returns an error, execution stops +// immediately and the error is returned with the handler name as +// context. +func (e *Engine) RunPhase(phase Phase, ctx *Context) error { + for _, h := range e.handlers[phase] { + if err := h.Handle(ctx); err != nil { + return fmt.Errorf("pipeline %s handler %q: %w", phase, h.Name(), err) + } + } + return nil +} + +// Run executes all phases in order: Register → PreParse → PostParse +// → PreRequest → PostResponse. Each phase runs its handler chain +// completely before the next phase begins. +// +// Callers typically do not use Run directly — the CLI integration +// calls RunPhase at each stage of the execution flow. Run is +// provided for testing and for cases where the full pipeline must +// be exercised in one shot. +func (e *Engine) Run(ctx *Context) error { + for _, phase := range phases() { + if err := e.RunPhase(phase, ctx); err != nil { + return err + } + } + return nil +} + +// HandlerCount returns the total number of registered handlers +// across all phases. +func (e *Engine) HandlerCount() int { + total := 0 + for _, handlers := range e.handlers { + total += len(handlers) + } + return total +} diff --git a/internal/pipeline/engine_test.go b/internal/pipeline/engine_test.go new file mode 100644 index 00000000..ce798769 --- /dev/null +++ b/internal/pipeline/engine_test.go @@ -0,0 +1,300 @@ +// 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 pipeline + +import ( + "errors" + "strings" + "testing" +) + +// --- test helpers --- + +type stubHandler struct { + name string + phase Phase + fn func(*Context) error + called bool + callSeq *[]string +} + +func (h *stubHandler) Name() string { return h.name } +func (h *stubHandler) Phase() Phase { return h.phase } +func (h *stubHandler) Handle(ctx *Context) error { + h.called = true + if h.callSeq != nil { + *h.callSeq = append(*h.callSeq, h.name) + } + if h.fn != nil { + return h.fn(ctx) + } + return nil +} + +func newStub(name string, phase Phase, fn func(*Context) error) *stubHandler { + return &stubHandler{name: name, phase: phase, fn: fn} +} + +func newStubWithSeq(name string, phase Phase, seq *[]string) *stubHandler { + return &stubHandler{name: name, phase: phase, callSeq: seq} +} + +// --- tests --- + +func TestNewEngine(t *testing.T) { + e := NewEngine() + if e == nil { + t.Fatal("NewEngine returned nil") + } + if got := e.HandlerCount(); got != 0 { + t.Errorf("HandlerCount = %d, want 0", got) + } +} + +func TestRegisterAndHandlerCount(t *testing.T) { + e := NewEngine() + e.Register(newStub("a", PreParse, nil)) + e.Register(newStub("b", PreParse, nil)) + e.Register(newStub("c", PostParse, nil)) + + if got := e.HandlerCount(); got != 3 { + t.Errorf("HandlerCount = %d, want 3", got) + } + if got := len(e.Handlers(PreParse)); got != 2 { + t.Errorf("PreParse handlers = %d, want 2", got) + } + if got := len(e.Handlers(PostParse)); got != 1 { + t.Errorf("PostParse handlers = %d, want 1", got) + } + if got := len(e.Handlers(PreRequest)); got != 0 { + t.Errorf("PreRequest handlers = %d, want 0", got) + } +} + +func TestRegisterAll(t *testing.T) { + e := NewEngine() + e.RegisterAll( + newStub("a", PreParse, nil), + newStub("b", PostParse, nil), + newStub("c", PreRequest, nil), + ) + if got := e.HandlerCount(); got != 3 { + t.Errorf("HandlerCount = %d, want 3", got) + } +} + +func TestHasHandlers(t *testing.T) { + e := NewEngine() + if e.HasHandlers(PreParse) { + t.Error("HasHandlers(PreParse) = true on empty engine") + } + e.Register(newStub("a", PreParse, nil)) + if !e.HasHandlers(PreParse) { + t.Error("HasHandlers(PreParse) = false after registration") + } + if e.HasHandlers(PostResponse) { + t.Error("HasHandlers(PostResponse) = true, want false") + } +} + +func TestRunPhaseChainOrder(t *testing.T) { + var seq []string + e := NewEngine() + e.Register(newStubWithSeq("first", PreParse, &seq)) + e.Register(newStubWithSeq("second", PreParse, &seq)) + e.Register(newStubWithSeq("third", PreParse, &seq)) + + ctx := &Context{Args: []string{"test"}} + if err := e.RunPhase(PreParse, ctx); err != nil { + t.Fatalf("RunPhase returned error: %v", err) + } + want := "first,second,third" + got := strings.Join(seq, ",") + if got != want { + t.Errorf("execution order = %q, want %q", got, want) + } +} + +func TestRunPhaseContextMutationPropagates(t *testing.T) { + e := NewEngine() + e.Register(newStub("append-foo", PreParse, func(ctx *Context) error { + ctx.Args = append(ctx.Args, "--foo") + return nil + })) + e.Register(newStub("append-bar", PreParse, func(ctx *Context) error { + ctx.Args = append(ctx.Args, "--bar") + return nil + })) + + ctx := &Context{Args: []string{"cmd"}} + if err := e.RunPhase(PreParse, ctx); err != nil { + t.Fatalf("RunPhase error: %v", err) + } + want := "cmd,--foo,--bar" + got := strings.Join(ctx.Args, ",") + if got != want { + t.Errorf("Args = %q, want %q", got, want) + } +} + +func TestRunPhaseErrorAbortsChain(t *testing.T) { + boom := errors.New("boom") + h1 := newStub("ok", PreParse, nil) + h2 := newStub("fail", PreParse, func(*Context) error { return boom }) + h3 := newStub("never", PreParse, nil) + + e := NewEngine() + e.RegisterAll(h1, h2, h3) + + ctx := &Context{} + err := e.RunPhase(PreParse, ctx) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, boom) { + t.Errorf("error = %v, want wrapped boom", err) + } + if !strings.Contains(err.Error(), "fail") { + t.Errorf("error should contain handler name, got %q", err.Error()) + } + if !h1.called { + t.Error("h1 should have been called") + } + if !h2.called { + t.Error("h2 should have been called") + } + if h3.called { + t.Error("h3 should NOT have been called after error") + } +} + +func TestRunPhaseEmptyPhaseNoError(t *testing.T) { + e := NewEngine() + ctx := &Context{} + if err := e.RunPhase(PreParse, ctx); err != nil { + t.Errorf("RunPhase on empty phase returned error: %v", err) + } +} + +func TestRunAllPhasesOrder(t *testing.T) { + var seq []string + e := NewEngine() + e.Register(newStubWithSeq("post-resp", PostResponse, &seq)) + e.Register(newStubWithSeq("pre-parse", PreParse, &seq)) + e.Register(newStubWithSeq("register", Register, &seq)) + e.Register(newStubWithSeq("post-parse", PostParse, &seq)) + e.Register(newStubWithSeq("pre-req", PreRequest, &seq)) + + ctx := &Context{} + if err := e.Run(ctx); err != nil { + t.Fatalf("Run returned error: %v", err) + } + want := "register,pre-parse,post-parse,pre-req,post-resp" + got := strings.Join(seq, ",") + if got != want { + t.Errorf("phase execution order = %q, want %q", got, want) + } +} + +func TestRunAbortsOnPhaseError(t *testing.T) { + var seq []string + e := NewEngine() + e.Register(newStubWithSeq("pre-parse", PreParse, &seq)) + e.Register(newStub("post-parse-fail", PostParse, func(*Context) error { + return errors.New("fail") + })) + e.Register(newStubWithSeq("pre-req", PreRequest, &seq)) + + ctx := &Context{} + err := e.Run(ctx) + if err == nil { + t.Fatal("expected error") + } + if len(seq) != 1 || seq[0] != "pre-parse" { + t.Errorf("seq = %v, want [pre-parse]", seq) + } +} + +func TestContextAddCorrection(t *testing.T) { + ctx := &Context{} + ctx.AddCorrection("alias", PreParse, "--userId", "--userId", "--user-id", "alias") + ctx.AddCorrection("sticky", PreParse, "--limit", "--limit100", "--limit 100", "sticky") + + if got := len(ctx.Corrections); got != 2 { + t.Fatalf("Corrections count = %d, want 2", got) + } + c := ctx.Corrections[0] + if c.Handler != "alias" || c.Original != "--userId" || c.Corrected != "--user-id" { + t.Errorf("first correction = %+v", c) + } +} + +func TestPhaseString(t *testing.T) { + tests := []struct { + phase Phase + want string + }{ + {Register, "register"}, + {PreParse, "pre-parse"}, + {PostParse, "post-parse"}, + {PreRequest, "pre-request"}, + {PostResponse, "post-response"}, + {Phase(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.phase.String(); got != tt.want { + t.Errorf("Phase(%d).String() = %q, want %q", tt.phase, got, tt.want) + } + } +} + +func TestRunPhaseErrorContainsPhaseAndHandler(t *testing.T) { + e := NewEngine() + e.Register(newStub("my-handler", PostParse, func(*Context) error { + return errors.New("bad value") + })) + + err := e.RunPhase(PostParse, &Context{}) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "post-parse") { + t.Errorf("error should contain phase name, got %q", msg) + } + if !strings.Contains(msg, "my-handler") { + t.Errorf("error should contain handler name, got %q", msg) + } +} + +func TestMultipleHandlersSameAndDifferentPhases(t *testing.T) { + var seq []string + e := NewEngine() + e.Register(newStubWithSeq("pp1", PreParse, &seq)) + e.Register(newStubWithSeq("pp2", PreParse, &seq)) + e.Register(newStubWithSeq("op1", PostParse, &seq)) + e.Register(newStubWithSeq("rq1", PreRequest, &seq)) + e.Register(newStubWithSeq("rq2", PreRequest, &seq)) + e.Register(newStubWithSeq("rs1", PostResponse, &seq)) + + ctx := &Context{} + if err := e.Run(ctx); err != nil { + t.Fatalf("Run error: %v", err) + } + want := "pp1,pp2,op1,rq1,rq2,rs1" + got := strings.Join(seq, ",") + if got != want { + t.Errorf("order = %q, want %q", got, want) + } +} diff --git a/internal/pipeline/handler.go b/internal/pipeline/handler.go new file mode 100644 index 00000000..7bd2a963 --- /dev/null +++ b/internal/pipeline/handler.go @@ -0,0 +1,37 @@ +// 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 pipeline + +// Handler is the core abstraction for pipeline extensions. Each +// handler declares the phase it belongs to and provides a Handle +// method that receives a mutable context. Handlers are executed in +// registration order within a phase; each handler's output becomes +// the next handler's input. +// +// A handler that returns a non-nil error aborts the chain — no +// further handlers in the same phase (or subsequent phases) will +// run. +type Handler interface { + // Name returns a short, unique identifier for the handler + // (e.g. "sticky", "alias", "date-normalise"). Used in + // correction records and log output. + Name() string + + // Phase returns the pipeline phase this handler belongs to. + Phase() Phase + + // Handle processes the context and returns an error to abort + // the chain, or nil to continue. + Handle(ctx *Context) error +} diff --git a/internal/pipeline/handlers/alias.go b/internal/pipeline/handlers/alias.go new file mode 100644 index 00000000..f88cf595 --- /dev/null +++ b/internal/pipeline/handlers/alias.go @@ -0,0 +1,146 @@ +// 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 handlers + +import ( + "strings" + "unicode" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// AliasHandler normalises flag names in raw argv so that common +// model-generated variants resolve to the canonical kebab-case name. +// +// Supported normalisations: +// - camelCase → kebab-case (--userId → --user-id) +// - snake_case → kebab-case (--user_name → --user-name) +// - UPPER-CASE → lower-case (--USER-ID → --user-id) +// - PascalCase → kebab-case (--UserName → --user-name) +// +// The handler only rewrites tokens that start with "--" and whose +// normalised form matches a known flag name. Unknown flags are left +// untouched so that Cobra can report them as errors. +type AliasHandler struct{} + +func (AliasHandler) Name() string { return "alias" } +func (AliasHandler) Phase() pipeline.Phase { return pipeline.PreParse } + +func (AliasHandler) Handle(ctx *pipeline.Context) error { + if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 { + return nil + } + + known := buildFlagSet(ctx.FlagSpecs) + result := make([]string, 0, len(ctx.Args)) + + for i, arg := range ctx.Args { + rewritten, ok := tryNormaliseFlag(arg, known) + if ok { + ctx.AddCorrection("alias", pipeline.PreParse, rewritten, arg, rewritten, "alias") + result = append(result, rewritten) + } else { + result = append(result, ctx.Args[i]) + } + } + + ctx.Args = result + return nil +} + +// tryNormaliseFlag checks whether arg is a "--flag" token that can be +// normalised to a known flag name. It handles both bare flags and +// "--flag=value" syntax. +func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) { + if !strings.HasPrefix(arg, "--") { + return "", false + } + + bare := arg[2:] + if bare == "" { + return "", false + } + + // Handle --flag=value syntax: split, normalise the key, reassemble. + var suffix string + if idx := strings.IndexByte(bare, '='); idx >= 0 { + suffix = bare[idx:] // includes "=" + bare = bare[:idx] + } + + // Already a known flag in its current form — no change needed. + if known[bare] { + return "", false + } + + normalised := toKebabCase(bare) + if normalised == bare { + return "", false + } + if !known[normalised] { + return "", false + } + + return "--" + normalised + suffix, true +} + +// toKebabCase converts a string from camelCase, PascalCase, or +// snake_case to kebab-case. Examples: +// +// "userId" → "user-id" +// "UserName" → "user-name" +// "user_name" → "user-name" +// "USER_ID" → "user-id" +// "pageSize" → "page-size" +func toKebabCase(s string) string { + if s == "" { + return "" + } + + var b strings.Builder + b.Grow(len(s) + 4) // small extra for hyphens + + runes := []rune(s) + for i, r := range runes { + if r == '_' || r == ' ' { + if b.Len() > 0 { + b.WriteByte('-') + } + continue + } + + if unicode.IsUpper(r) { + // Insert hyphen before an uppercase letter when: + // 1. Not at start, AND + // 2. Previous char was lowercase, OR + // 3. Next char is lowercase (handles "userID" → "user-id" + // at the boundary between "I" and "D" in "ID" we don't + // split, but "IDs" → we split before "s" which is + // handled by the lowercase check at the next iteration). + if i > 0 { + prev := runes[i-1] + if unicode.IsLower(prev) { + b.WriteByte('-') + } else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) { + b.WriteByte('-') + } + } + b.WriteRune(unicode.ToLower(r)) + } else { + b.WriteRune(unicode.ToLower(r)) + } + } + + return strings.Trim(b.String(), "-") +} diff --git a/internal/pipeline/handlers/alias_test.go b/internal/pipeline/handlers/alias_test.go new file mode 100644 index 00000000..dd76a2fd --- /dev/null +++ b/internal/pipeline/handlers/alias_test.go @@ -0,0 +1,186 @@ +// 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 handlers + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func TestToKebabCase(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"userId", "user-id"}, + {"UserName", "user-name"}, + {"user_name", "user-name"}, + {"USER_ID", "user-id"}, + {"pageSize", "page-size"}, + {"user-id", "user-id"}, + {"limit", "limit"}, + {"ID", "id"}, + {"userID", "user-id"}, + {"HTMLParser", "html-parser"}, + {"getHTTPResponse", "get-http-response"}, + {"a", "a"}, + {"", ""}, + {"ABC", "abc"}, + {"already-kebab", "already-kebab"}, + {"with spaces", "with-spaces"}, + {"__leading_underscores", "leading-underscores"}, + {"mixedCamel_and_snake", "mixed-camel-and-snake"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := toKebabCase(tt.input) + if got != tt.want { + t.Errorf("toKebabCase(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAliasHandler(t *testing.T) { + tests := []struct { + name string + args []string + flags []string + want string + corrections int + }{ + { + name: "camelCase to kebab-case", + args: []string{"--userId", "123"}, + flags: []string{"user-id"}, + want: "--user-id 123", + corrections: 1, + }, + { + name: "snake_case to kebab-case", + args: []string{"--user_name", "alice"}, + flags: []string{"user-name"}, + want: "--user-name alice", + corrections: 1, + }, + { + name: "UPPER to lower", + args: []string{"--USER-ID", "456"}, + flags: []string{"user-id"}, + want: "--user-id 456", + corrections: 1, + }, + { + name: "PascalCase to kebab-case", + args: []string{"--PageSize", "20"}, + flags: []string{"page-size"}, + want: "--page-size 20", + corrections: 1, + }, + { + name: "already correct — no change", + args: []string{"--user-id", "789"}, + flags: []string{"user-id"}, + want: "--user-id 789", + corrections: 0, + }, + { + name: "unknown flag — left untouched", + args: []string{"--fooBar", "val"}, + flags: []string{"user-id"}, + want: "--fooBar val", + corrections: 0, + }, + { + name: "with = syntax", + args: []string{"--userId=123"}, + flags: []string{"user-id"}, + want: "--user-id=123", + corrections: 1, + }, + { + name: "multiple flags mixed", + args: []string{"--userId", "1", "--pageSize", "10", "--name", "test"}, + flags: []string{"user-id", "page-size", "name"}, + want: "--user-id 1 --page-size 10 --name test", + corrections: 2, + }, + { + name: "single dash is ignored", + args: []string{"-v"}, + flags: []string{"verbose"}, + want: "-v", + corrections: 0, + }, + { + name: "empty args", + args: []string{}, + flags: []string{"limit"}, + want: "", + corrections: 0, + }, + { + name: "bare double dash", + args: []string{"--"}, + flags: []string{"limit"}, + want: "--", + corrections: 0, + }, + { + name: "normalised form not in known set", + args: []string{"--unknownFlag"}, + flags: []string{"limit"}, + want: "--unknownFlag", + 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...), + } + h := AliasHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got := strings.Join(ctx.Args, " ") + if got != tt.want { + t.Errorf("Args = %q, want %q", got, tt.want) + } + if len(ctx.Corrections) != tt.corrections { + t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections) + } + for _, c := range ctx.Corrections { + if c.Kind != "alias" { + t.Errorf("correction kind = %q, want %q", c.Kind, "alias") + } + } + }) + } +} + +func TestAliasHandlerNameAndPhase(t *testing.T) { + h := AliasHandler{} + if h.Name() != "alias" { + t.Errorf("Name() = %q, want %q", h.Name(), "alias") + } + if h.Phase() != pipeline.PreParse { + t.Errorf("Phase() = %v, want PreParse", h.Phase()) + } +} diff --git a/internal/pipeline/handlers/integration_test.go b/internal/pipeline/handlers/integration_test.go new file mode 100644 index 00000000..eee94cd3 --- /dev/null +++ b/internal/pipeline/handlers/integration_test.go @@ -0,0 +1,284 @@ +// 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 handlers + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// TestFullPreParsePipeline exercises the complete PreParse handler +// chain: AliasHandler → StickyHandler → ParamNameHandler. It +// simulates a model-generated CLI invocation with multiple errors +// and verifies the pipeline corrects all of them in one pass. +func TestFullPreParsePipeline(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll( + AliasHandler{}, + StickyHandler{}, + ParamNameHandler{}, + ) + + tests := []struct { + name string + args []string + flags []string + want string + corrections int + }{ + { + name: "camelCase + sticky combined", + args: []string{"--userId", "123", "--pageSize50"}, + flags: []string{"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"}, + 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"}, + 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"}, + 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"}, + want: "--user-id 123 --limit 10", + corrections: 0, + }, + { + name: "UPPER case flags", + args: []string{"--USER-ID", "999"}, + flags: []string{"user-id"}, + want: "--user-id 999", + corrections: 1, + }, + { + name: "= syntax with camelCase", + args: []string{"--userId=123", "--pageSize=50"}, + flags: []string{"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"}, + want: "--limit-value 100", + corrections: 1, // sticky handles both kebab-normalisation and split + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Args: append([]string{}, tt.args...), + FlagSpecs: flagSpecs(tt.flags...), + } + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("RunPhase error: %v", err) + } + got := strings.Join(ctx.Args, " ") + if got != tt.want { + t.Errorf("Args = %q, want %q", got, tt.want) + } + if len(ctx.Corrections) != tt.corrections { + t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections) + for _, c := range ctx.Corrections { + t.Logf(" %s/%s: %q → %q", c.Handler, c.Kind, c.Original, c.Corrected) + } + } + }) + } +} + +// TestFullPostParsePipeline exercises the PostParse handler chain +// with the ParamValueHandler normalising multiple value types in a +// single invocation. +func TestFullPostParsePipeline(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll(ParamValueHandler{}) + + ctx := &pipeline.Context{ + Command: "aitable.list_records", + Params: map[string]any{ + "verbose": "yes", + "limit": "1,000", + "status": "ACTIVE", + "created_at": "2024/03/29", + "name": "test", + }, + Schema: map[string]any{ + "properties": map[string]any{ + "verbose": map[string]any{"type": "boolean"}, + "limit": map[string]any{"type": "integer"}, + "status": map[string]any{"type": "string", "enum": []any{"active", "inactive"}}, + "created_at": map[string]any{"type": "string", "format": "date"}, + "name": map[string]any{"type": "string"}, + }, + }, + } + + if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil { + t.Fatalf("RunPhase error: %v", err) + } + + if got := ctx.Params["verbose"]; got != true { + t.Errorf("verbose = %v, want true", got) + } + if got := ctx.Params["limit"]; got != int64(1000) { + t.Errorf("limit = %v, want 1000", got) + } + if got := ctx.Params["status"]; got != "active" { + t.Errorf("status = %q, want %q", got, "active") + } + if got := ctx.Params["created_at"]; got != "2024-03-29" { + t.Errorf("created_at = %q, want %q", got, "2024-03-29") + } + // "name" is a plain string — should be untouched. + if got := ctx.Params["name"]; got != "test" { + t.Errorf("name = %q, want %q", got, "test") + } + if len(ctx.Corrections) != 4 { + t.Errorf("Corrections = %d, want 4", len(ctx.Corrections)) + } +} + +// TestFullPipelineEndToEnd exercises all phases in sequence: +// PreParse → PostParse, simulating a model invocation that has +// errors at both the argv level and the value level. +func TestFullPipelineEndToEnd(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll( + AliasHandler{}, + StickyHandler{}, + ParamNameHandler{}, + ParamValueHandler{}, + ) + + // Phase 1: PreParse — fix raw argv. + ctx := &pipeline.Context{ + Args: []string{ + "--userId", "u001", + "--pageSize50", + "--verbosetrue", + }, + FlagSpecs: flagSpecs("user-id", "page-size", "verbose"), + } + + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("PreParse error: %v", err) + } + + want := "--user-id u001 --page-size 50 --verbose true" + got := strings.Join(ctx.Args, " ") + if got != want { + t.Errorf("after PreParse: Args = %q, want %q", got, want) + } + + preParseCorrections := len(ctx.Corrections) + if preParseCorrections != 3 { + t.Errorf("PreParse corrections = %d, want 3", preParseCorrections) + } + + // Phase 2: PostParse — simulate Cobra having parsed the corrected + // args into structured params, then normalise values. + ctx.Command = "chat.send_message" + ctx.Params = map[string]any{ + "user_id": "u001", + "page_size": "50", + "verbose": "true", + } + ctx.Schema = map[string]any{ + "properties": map[string]any{ + "user_id": map[string]any{"type": "string"}, + "page_size": map[string]any{"type": "integer"}, + "verbose": map[string]any{"type": "boolean"}, + }, + } + + if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil { + t.Fatalf("PostParse error: %v", err) + } + + if got := ctx.Params["verbose"]; got != true { + t.Errorf("verbose = %v (%T), want true (bool)", got, got) + } + // page_size "50" has no grouping separators — unchanged. + if got := ctx.Params["page_size"]; got != "50" { + t.Errorf("page_size = %v, want %q", got, "50") + } + + totalCorrections := len(ctx.Corrections) + postParseCorrections := totalCorrections - preParseCorrections + if postParseCorrections != 1 { + t.Errorf("PostParse corrections = %d, want 1", postParseCorrections) + } +} + +// TestPreParseDoesNotBreakValidArgs verifies that valid, correctly +// formatted args pass through the pipeline without modification. +func TestPreParseDoesNotBreakValidArgs(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll( + AliasHandler{}, + StickyHandler{}, + ParamNameHandler{}, + ) + + original := []string{ + "--user-id", "123", + "--limit", "10", + "--offset", "0", + "--format", "json", + "--verbose", + } + ctx := &pipeline.Context{ + Args: append([]string{}, original...), + FlagSpecs: flagSpecs("user-id", "limit", "offset", "format", "verbose"), + } + + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("RunPhase error: %v", err) + } + + got := strings.Join(ctx.Args, " ") + want := strings.Join(original, " ") + if got != want { + t.Errorf("Args changed!\n got: %q\n want: %q", got, want) + } + if len(ctx.Corrections) != 0 { + t.Errorf("unexpected corrections: %d", len(ctx.Corrections)) + for _, c := range ctx.Corrections { + t.Logf(" %s: %q → %q", c.Handler, c.Original, c.Corrected) + } + } +} diff --git a/internal/pipeline/handlers/paramname.go b/internal/pipeline/handlers/paramname.go new file mode 100644 index 00000000..1cd4207a --- /dev/null +++ b/internal/pipeline/handlers/paramname.go @@ -0,0 +1,164 @@ +// 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 handlers + +import ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// ParamNameHandler performs fuzzy correction on flag names that are +// not recognised after alias normalisation. It uses Levenshtein edit +// distance to find the closest known flag name, with a conservative +// threshold to avoid false positives. +// +// Correction rules: +// - Edit distance must be ≤ maxEditDistance (default 2). +// - The match must be unambiguous (exactly one candidate within threshold). +// - Very short flag names (≤ 3 chars) use a tighter threshold of 1. +// +// This handler should run after AliasHandler in the PreParse phase +// so that obvious normalisation (camelCase → kebab-case) is already +// done and fuzzy matching only handles genuine near-misses. +type ParamNameHandler struct{} + +const maxEditDistance = 2 + +func (ParamNameHandler) Name() string { return "paramname" } +func (ParamNameHandler) Phase() pipeline.Phase { return pipeline.PreParse } + +func (ParamNameHandler) Handle(ctx *pipeline.Context) error { + if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 { + return nil + } + + known := buildFlagSet(ctx.FlagSpecs) + names := make([]string, 0, len(ctx.FlagSpecs)) + for _, spec := range ctx.FlagSpecs { + if spec.Name != "" { + names = append(names, spec.Name) + } + } + + result := make([]string, 0, len(ctx.Args)) + for _, arg := range ctx.Args { + rewritten, ok := tryFuzzyMatch(arg, known, names) + if ok { + ctx.AddCorrection("paramname", pipeline.PreParse, rewritten, arg, rewritten, "fuzzy") + result = append(result, rewritten) + } else { + result = append(result, arg) + } + } + + ctx.Args = result + return nil +} + +// tryFuzzyMatch attempts to correct an unrecognised "--flag" token by +// finding the closest known flag name within the edit distance threshold. +func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (string, bool) { + if !strings.HasPrefix(arg, "--") { + return "", false + } + + bare := arg[2:] + if bare == "" { + return "", false + } + + // Handle --flag=value syntax. + var suffix string + if idx := strings.IndexByte(bare, '='); idx >= 0 { + suffix = bare[idx:] + bare = bare[:idx] + } + + // Already known — nothing to fix. + if known[bare] { + return "", false + } + + threshold := maxEditDistance + if len(bare) <= 3 { + threshold = 1 + } + + bestDist := threshold + 1 + bestMatch := "" + ambiguous := false + + for _, candidate := range candidates { + dist := levenshtein(bare, candidate) + if dist < bestDist { + bestDist = dist + bestMatch = candidate + ambiguous = false + } else if dist == bestDist && candidate != bestMatch { + ambiguous = true + } + } + + if bestDist > threshold || ambiguous || bestMatch == "" { + return "", false + } + + return "--" + bestMatch + suffix, true +} + +// levenshtein computes the edit distance between two strings using +// the standard dynamic programming approach with O(min(m,n)) space. +func levenshtein(a, b string) int { + if a == b { + return 0 + } + + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + + // Ensure a is the shorter string for O(min) space. + if la > lb { + a, b = b, a + la, lb = lb, la + } + + prev := make([]int, la+1) + curr := make([]int, la+1) + for i := range prev { + prev[i] = i + } + + for j := 1; j <= lb; j++ { + curr[0] = j + for i := 1; i <= la; i++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[i] = min( + prev[i]+1, // deletion + curr[i-1]+1, // insertion + prev[i-1]+cost, // substitution + ) + } + prev, curr = curr, prev + } + return prev[la] +} diff --git a/internal/pipeline/handlers/paramname_test.go b/internal/pipeline/handlers/paramname_test.go new file mode 100644 index 00000000..ef3f6c07 --- /dev/null +++ b/internal/pipeline/handlers/paramname_test.go @@ -0,0 +1,191 @@ +// 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 handlers + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func TestLevenshtein(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"", "", 0}, + {"a", "", 1}, + {"", "a", 1}, + {"abc", "abc", 0}, + {"abc", "abd", 1}, + {"abc", "abcd", 1}, + {"kitten", "sitting", 3}, + {"limit", "limt", 1}, + {"limit", "limti", 2}, + {"user-id", "user-di", 2}, + {"page", "pge", 1}, + } + + for _, tt := range tests { + t.Run(tt.a+"→"+tt.b, func(t *testing.T) { + got := levenshtein(tt.a, tt.b) + if got != tt.want { + t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + // Verify symmetry. + gotRev := levenshtein(tt.b, tt.a) + if gotRev != got { + t.Errorf("asymmetric: (%q,%q)=%d but (%q,%q)=%d", tt.a, tt.b, got, tt.b, tt.a, gotRev) + } + }) + } +} + +func TestParamNameHandler(t *testing.T) { + tests := []struct { + name string + args []string + flags []string + want string + corrections int + }{ + { + name: "typo correction — limt → limit", + args: []string{"--limt", "10"}, + flags: []string{"limit", "offset", "name"}, + want: "--limit 10", + corrections: 1, + }, + { + name: "transposition — limti → limit", + args: []string{"--limti", "10"}, + flags: []string{"limit", "offset"}, + want: "--limit 10", + corrections: 1, + }, + { + name: "known flag unchanged", + args: []string{"--limit", "10"}, + flags: []string{"limit"}, + want: "--limit 10", + corrections: 0, + }, + { + name: "too far — no correction", + args: []string{"--xyz", "10"}, + flags: []string{"limit"}, + want: "--xyz 10", + corrections: 0, + }, + { + name: "ambiguous — two equally close", + args: []string{"--nae", "val"}, + flags: []string{"name", "nave"}, + want: "--nae val", + corrections: 0, + }, + { + name: "short flag tighter threshold", + args: []string{"--id", "1"}, + flags: []string{"id"}, + want: "--id 1", + corrections: 0, + }, + { + name: "short flag one edit away", + args: []string{"--ix", "1"}, + flags: []string{"id"}, + want: "--id 1", + corrections: 1, + }, + { + name: "short flag two edits away rejected", + args: []string{"--xy", "1"}, + flags: []string{"id"}, + want: "--xy 1", + corrections: 0, + }, + { + name: "with = syntax", + args: []string{"--limt=10"}, + flags: []string{"limit"}, + want: "--limit=10", + corrections: 1, + }, + { + name: "empty args", + args: []string{}, + flags: []string{"limit"}, + want: "", + corrections: 0, + }, + { + name: "single dash ignored", + args: []string{"-l"}, + flags: []string{"limit"}, + want: "-l", + corrections: 0, + }, + { + name: "missing char — offse → offset", + args: []string{"--offse", "0"}, + flags: []string{"offset", "limit"}, + want: "--offset 0", + corrections: 1, + }, + { + name: "extra char — offsett → offset", + args: []string{"--offsett", "0"}, + flags: []string{"offset", "limit"}, + want: "--offset 0", + corrections: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Args: append([]string{}, tt.args...), + FlagSpecs: flagSpecs(tt.flags...), + } + h := ParamNameHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got := strings.Join(ctx.Args, " ") + if got != tt.want { + t.Errorf("Args = %q, want %q", got, tt.want) + } + if len(ctx.Corrections) != tt.corrections { + t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections) + } + for _, c := range ctx.Corrections { + if c.Kind != "fuzzy" { + t.Errorf("correction kind = %q, want %q", c.Kind, "fuzzy") + } + } + }) + } +} + +func TestParamNameHandlerNameAndPhase(t *testing.T) { + h := ParamNameHandler{} + if h.Name() != "paramname" { + t.Errorf("Name() = %q, want %q", h.Name(), "paramname") + } + if h.Phase() != pipeline.PreParse { + t.Errorf("Phase() = %v, want PreParse", h.Phase()) + } +} diff --git a/internal/pipeline/handlers/paramvalue.go b/internal/pipeline/handlers/paramvalue.go new file mode 100644 index 00000000..90444f93 --- /dev/null +++ b/internal/pipeline/handlers/paramvalue.go @@ -0,0 +1,312 @@ +// 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 handlers + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// ParamValueHandler normalises parameter values after Cobra parsing. +// It operates on the structured Params map using the tool's input +// schema to determine value types and apply format-specific +// corrections. +// +// Supported normalisations: +// - Boolean: "yes"/"no"/"1"/"0"/"on"/"off"/"True"/"FALSE" → true/false +// - Number: "1,000" / "1_000" → 1000 (strip grouping separators) +// - Date: multiple input formats → RFC 3339 / ISO 8601 +// - Enum: case-insensitive matching against allowed values +type ParamValueHandler struct{} + +func (ParamValueHandler) Name() string { return "paramvalue" } +func (ParamValueHandler) Phase() pipeline.Phase { return pipeline.PostParse } + +func (ParamValueHandler) Handle(ctx *pipeline.Context) error { + if len(ctx.Params) == 0 || len(ctx.Schema) == 0 { + return nil + } + + properties, ok := schemaProperties(ctx.Schema) + if !ok { + return nil + } + + for key, value := range ctx.Params { + propSchema, ok := properties[key].(map[string]any) + if !ok { + continue + } + + corrected, changed := normaliseValue(value, propSchema) + if changed { + original := fmt.Sprintf("%v", value) + ctx.Params[key] = corrected + ctx.AddCorrection("paramvalue", pipeline.PostParse, key, original, fmt.Sprintf("%v", corrected), classifyCorrection(propSchema)) + } + } + return nil +} + +// normaliseValue dispatches to the appropriate normaliser based on +// the schema type and format. +func normaliseValue(value any, schema map[string]any) (any, bool) { + // Enum normalisation takes priority — it is type-independent. + if enumValues, ok := schemaEnum(schema); ok { + return normaliseEnum(value, enumValues) + } + + schemaType, _ := schema["type"].(string) + schemaFormat, _ := schema["format"].(string) + + switch schemaType { + case "boolean": + return normaliseBoolean(value) + case "integer": + return normaliseInteger(value) + case "number": + return normaliseNumber(value) + case "string": + if isDateFormat(schemaFormat) { + return normaliseDate(value, schemaFormat) + } + } + return value, false +} + +// --- boolean normalisation --- + +var booleanTrueValues = map[string]bool{ + "true": true, "yes": true, "on": true, "1": true, +} +var booleanFalseValues = map[string]bool{ + "false": true, "no": true, "off": true, "0": true, +} + +func normaliseBoolean(value any) (any, bool) { + // Already a bool — no change needed. + if _, ok := value.(bool); ok { + return value, false + } + + str, ok := value.(string) + if !ok { + return value, false + } + lower := strings.ToLower(strings.TrimSpace(str)) + if booleanTrueValues[lower] { + return true, true + } + if booleanFalseValues[lower] { + return false, true + } + return value, false +} + +// --- integer normalisation --- + +func normaliseInteger(value any) (any, bool) { + str, ok := value.(string) + if !ok { + return value, false + } + cleaned := stripNumberGrouping(str) + if cleaned == str { + return value, false + } + parsed, err := strconv.ParseInt(cleaned, 10, 64) + if err != nil { + return value, false + } + return parsed, true +} + +// --- number normalisation --- + +func normaliseNumber(value any) (any, bool) { + str, ok := value.(string) + if !ok { + return value, false + } + cleaned := stripNumberGrouping(str) + if cleaned == str { + return value, false + } + parsed, err := strconv.ParseFloat(cleaned, 64) + if err != nil { + return value, false + } + return parsed, true +} + +// stripNumberGrouping removes comma and underscore grouping +// separators from a numeric string. +func stripNumberGrouping(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, ",", "") + s = strings.ReplaceAll(s, "_", "") + return s +} + +// --- date normalisation --- + +// Supported input date formats, tried in order. +var dateFormats = []string{ + time.RFC3339, // 2006-01-02T15:04:05Z07:00 + "2006-01-02T15:04:05", // ISO without timezone + "2006-01-02 15:04:05", // space-separated datetime + "2006-01-02", // date only + "2006/01/02", // slash-separated + "02-01-2006", // DD-MM-YYYY + "01/02/2006", // US format MM/DD/YYYY + "Jan 2, 2006", // English short month + "January 2, 2006", // English full month + "2 Jan 2006", // Day-first English + "20060102", // Compact YYYYMMDD + "20060102T150405", // Compact with time + time.RFC1123, // Mon, 02 Jan 2006 15:04:05 MST + time.RFC1123Z, // Mon, 02 Jan 2006 15:04:05 -0700 +} + +func isDateFormat(format string) bool { + switch format { + case "date", "date-time", "datetime": + return true + } + return false +} + +func normaliseDate(value any, schemaFormat string) (any, bool) { + str, ok := value.(string) + if !ok { + return value, false + } + str = strings.TrimSpace(str) + if str == "" { + return value, false + } + + // Try parsing as millisecond timestamp. + if ms, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 10 { + t := time.UnixMilli(ms).UTC() + formatted := formatDate(t, schemaFormat) + if formatted != str { + return formatted, true + } + return value, false + } + + // Try parsing as second timestamp. + if sec, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 10 { + t := time.Unix(sec, 0).UTC() + formatted := formatDate(t, schemaFormat) + if formatted != str { + return formatted, true + } + return value, false + } + + // Try known date formats. + for _, layout := range dateFormats { + t, err := time.Parse(layout, str) + if err == nil { + formatted := formatDate(t, schemaFormat) + if formatted != str { + return formatted, true + } + return value, false + } + } + + return value, false +} + +// formatDate produces the output format based on the schema format +// field. "date" produces YYYY-MM-DD, everything else produces +// RFC 3339. +func formatDate(t time.Time, schemaFormat string) string { + if schemaFormat == "date" { + return t.Format("2006-01-02") + } + return t.Format(time.RFC3339) +} + +// --- enum normalisation --- + +func schemaEnum(schema map[string]any) ([]any, bool) { + raw, ok := schema["enum"].([]any) + if !ok || len(raw) == 0 { + return nil, false + } + return raw, true +} + +// normaliseEnum performs case-insensitive matching of a value against +// the allowed enum values. If exactly one enum entry matches +// (case-insensitive), the value is rewritten to match the canonical +// case from the schema. +func normaliseEnum(value any, allowed []any) (any, bool) { + str, ok := value.(string) + if !ok { + return value, false + } + lower := strings.ToLower(strings.TrimSpace(str)) + + var match any + matches := 0 + for _, candidate := range allowed { + candidateStr, ok := candidate.(string) + if !ok { + continue + } + if strings.ToLower(candidateStr) == lower { + match = candidate + matches++ + } + } + + if matches == 1 && match != value { + return match, true + } + return value, false +} + +// --- helpers --- + +func schemaProperties(schema map[string]any) (map[string]any, bool) { + properties, ok := schema["properties"].(map[string]any) + return properties, ok +} + +func classifyCorrection(schema map[string]any) string { + if _, ok := schemaEnum(schema); ok { + return "enum" + } + schemaType, _ := schema["type"].(string) + schemaFormat, _ := schema["format"].(string) + if schemaType == "boolean" { + return "boolean" + } + if schemaType == "integer" || schemaType == "number" { + return "number" + } + if isDateFormat(schemaFormat) { + return "date" + } + return "value" +} diff --git a/internal/pipeline/handlers/paramvalue_test.go b/internal/pipeline/handlers/paramvalue_test.go new file mode 100644 index 00000000..4894fbe4 --- /dev/null +++ b/internal/pipeline/handlers/paramvalue_test.go @@ -0,0 +1,352 @@ +// 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 handlers + +import ( + "fmt" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func makeSchema(properties map[string]any) map[string]any { + return map[string]any{"properties": properties} +} + +func TestParamValueHandlerBoolean(t *testing.T) { + tests := []struct { + input any + want any + }{ + {"yes", true}, + {"Yes", true}, + {"YES", true}, + {"no", false}, + {"No", false}, + {"on", true}, + {"off", false}, + {"1", true}, + {"0", false}, + {"True", true}, + {"FALSE", false}, + {"true", true}, // already correct string form + {"false", false}, // already correct string form + {true, true}, // already bool — no change + {false, false}, // already bool — no change + {"maybe", "maybe"}, // not a boolean — unchanged + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{"flag": tt.input}, + Schema: makeSchema(map[string]any{ + "flag": map[string]any{"type": "boolean"}, + }), + } + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got := ctx.Params["flag"] + if got != tt.want { + t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want) + } + }) + } +} + +func TestParamValueHandlerInteger(t *testing.T) { + tests := []struct { + input any + want any + }{ + {"1,000", int64(1000)}, + {"1_000", int64(1000)}, + {"1,000,000", int64(1000000)}, + {"42", "42"}, // no grouping — unchanged + {42, 42}, // already int — unchanged + {"abc", "abc"}, // not a number — unchanged + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{"count": tt.input}, + Schema: makeSchema(map[string]any{ + "count": map[string]any{"type": "integer"}, + }), + } + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got := ctx.Params["count"] + if got != tt.want { + t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want) + } + }) + } +} + +func TestParamValueHandlerNumber(t *testing.T) { + tests := []struct { + input any + want any + }{ + {"1,234.56", 1234.56}, + {"1_000.5", 1000.5}, + {"3.14", "3.14"}, // no grouping — unchanged + {3.14, 3.14}, // already float — unchanged + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{"amount": tt.input}, + Schema: makeSchema(map[string]any{ + "amount": map[string]any{"type": "number"}, + }), + } + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got := ctx.Params["amount"] + if got != tt.want { + t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want) + } + }) + } +} + +func TestParamValueHandlerDate(t *testing.T) { + tests := []struct { + name string + input string + format string + want string + }{ + { + name: "slash date → ISO", + input: "2024/03/29", + format: "date", + want: "2024-03-29", + }, + { + name: "already ISO date — no change", + input: "2024-03-29", + format: "date", + want: "2024-03-29", + }, + { + name: "space datetime → RFC3339", + input: "2024-03-29 14:30:00", + format: "date-time", + want: "2024-03-29T14:30:00Z", + }, + { + name: "compact YYYYMMDD → ISO date", + input: "20240329", + format: "date", + want: "2024-03-29", + }, + { + name: "English short month → RFC3339", + input: "Mar 29, 2024", + format: "date-time", + want: "2024-03-29T00:00:00Z", + }, + { + name: "English full month → date", + input: "January 15, 2024", + format: "date", + want: "2024-01-15", + }, + { + name: "millisecond timestamp → RFC3339", + input: "1711699200000", + format: "date-time", + want: "2024-03-29T08:00:00Z", + }, + { + name: "already RFC3339 — no change", + input: "2024-03-29T14:30:00Z", + format: "date-time", + want: "2024-03-29T14:30:00Z", + }, + { + name: "ISO without timezone → RFC3339", + input: "2024-03-29T14:30:00", + format: "date-time", + want: "2024-03-29T14:30:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{"date": tt.input}, + Schema: makeSchema(map[string]any{ + "date": map[string]any{"type": "string", "format": tt.format}, + }), + } + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got, ok := ctx.Params["date"].(string) + if !ok { + t.Fatalf("result not a string: %v (%T)", ctx.Params["date"], ctx.Params["date"]) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestParamValueHandlerEnum(t *testing.T) { + tests := []struct { + name string + input any + enum []any + want any + }{ + { + name: "uppercase → canonical", + input: "ACTIVE", + enum: []any{"active", "inactive"}, + want: "active", + }, + { + name: "mixed case → canonical", + input: "Active", + enum: []any{"active", "inactive"}, + want: "active", + }, + { + name: "already correct — no change", + input: "active", + enum: []any{"active", "inactive"}, + want: "active", + }, + { + name: "no match — unchanged", + input: "deleted", + enum: []any{"active", "inactive"}, + want: "deleted", + }, + { + name: "non-string value — unchanged", + input: 42, + enum: []any{"active"}, + want: 42, + }, + { + name: "canonical preserves original case", + input: "json", + enum: []any{"JSON", "XML", "CSV"}, + want: "JSON", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{"status": tt.input}, + Schema: makeSchema(map[string]any{ + "status": map[string]any{"type": "string", "enum": tt.enum}, + }), + } + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + if got := ctx.Params["status"]; got != tt.want { + t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want) + } + }) + } +} + +func TestParamValueHandlerMultipleParams(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{ + "verbose": "yes", + "count": "1,000", + "status": "ACTIVE", + "created_at": "2024/03/29", + }, + Schema: makeSchema(map[string]any{ + "verbose": map[string]any{"type": "boolean"}, + "count": map[string]any{"type": "integer"}, + "status": map[string]any{"type": "string", "enum": []any{"active", "inactive"}}, + "created_at": map[string]any{"type": "string", "format": "date"}, + }), + } + + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + + if got := ctx.Params["verbose"]; got != true { + t.Errorf("verbose = %v, want true", got) + } + if got := ctx.Params["count"]; got != int64(1000) { + t.Errorf("count = %v, want 1000", got) + } + if got := ctx.Params["status"]; got != "active" { + t.Errorf("status = %q, want %q", got, "active") + } + if got := ctx.Params["created_at"]; got != "2024-03-29" { + t.Errorf("created_at = %q, want %q", got, "2024-03-29") + } + if len(ctx.Corrections) != 4 { + t.Errorf("corrections = %d, want 4", len(ctx.Corrections)) + } +} + +func TestParamValueHandlerEmptyInputs(t *testing.T) { + h := ParamValueHandler{} + + // nil params + ctx := &pipeline.Context{Params: nil, Schema: makeSchema(map[string]any{})} + if err := h.Handle(ctx); err != nil { + t.Errorf("nil params: %v", err) + } + + // nil schema + ctx = &pipeline.Context{Params: map[string]any{"a": "b"}, Schema: nil} + if err := h.Handle(ctx); err != nil { + t.Errorf("nil schema: %v", err) + } + + // schema without properties + ctx = &pipeline.Context{Params: map[string]any{"a": "b"}, Schema: map[string]any{}} + if err := h.Handle(ctx); err != nil { + t.Errorf("no properties: %v", err) + } +} + +func TestParamValueHandlerNameAndPhase(t *testing.T) { + h := ParamValueHandler{} + if h.Name() != "paramvalue" { + t.Errorf("Name() = %q, want %q", h.Name(), "paramvalue") + } + if h.Phase() != pipeline.PostParse { + t.Errorf("Phase() = %v, want PostParse", h.Phase()) + } +} diff --git a/internal/pipeline/handlers/sticky.go b/internal/pipeline/handlers/sticky.go new file mode 100644 index 00000000..af99aed9 --- /dev/null +++ b/internal/pipeline/handlers/sticky.go @@ -0,0 +1,139 @@ +// 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 handlers + +import ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// StickyHandler detects glued flag-value pairs in raw argv and splits +// them into separate tokens. For example, "--limit100" becomes +// "--limit", "100" when "limit" is a known flag name. +// +// 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. +type StickyHandler struct{} + +func (StickyHandler) Name() string { return "sticky" } +func (StickyHandler) Phase() pipeline.Phase { return pipeline.PreParse } + +func (StickyHandler) Handle(ctx *pipeline.Context) error { + if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 { + return nil + } + + known := buildFlagSet(ctx.FlagSpecs) + result := make([]string, 0, len(ctx.Args)) + + for _, arg := range ctx.Args { + split, ok := trySplitSticky(arg, known) + if ok { + ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky") + result = append(result, split.flag, split.value) + } else { + result = append(result, arg) + } + } + + ctx.Args = result + return nil +} + +type stickyPair struct { + flag string + value string +} + +// trySplitSticky checks if arg looks like a glued flag-value (e.g. +// "--limit100") and returns the split result. It requires: +// - arg starts with "--" +// - arg does not contain "=" (that is a valid Cobra syntax) +// - the prefix matches a known flag name (directly or after +// kebab-case normalisation) +// - the remaining suffix is non-empty +// +// 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) { + if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") { + return stickyPair{}, false + } + + // Strip "--" prefix to work with the bare token. + bare := arg[2:] + if bare == "" { + return stickyPair{}, false + } + + // 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)] { + return stickyPair{}, false + } + + // Try longest-prefix match: walk from len-1 down to 1, looking + // for the longest known flag that is a prefix of bare. For each + // candidate prefix, try both the raw form and kebab-case form. + bestLen := 0 + bestFlag := "" + for i := len(bare) - 1; i >= 1; i-- { + prefix := bare[:i] + + matchedFlag := "" + if known[prefix] { + matchedFlag = prefix + } else { + kebab := toKebabCase(prefix) + if kebab != "" && known[kebab] { + matchedFlag = kebab + } + } + + if matchedFlag != "" && i > bestLen { + bestLen = i + bestFlag = matchedFlag + break // longest first since we walk from the end + } + } + if bestFlag == "" { + return stickyPair{}, false + } + + suffix := bare[bestLen:] + if suffix == "" { + return stickyPair{}, false + } + + return stickyPair{ + flag: "--" + bestFlag, + value: suffix, + }, true +} + +// buildFlagSet creates a set of known flag names (without "--" prefix) +// from the context's FlagSpecs. +func buildFlagSet(specs []pipeline.FlagInfo) map[string]bool { + m := make(map[string]bool, len(specs)) + for _, spec := range specs { + if spec.Name != "" { + m[spec.Name] = true + } + } + return m +} diff --git a/internal/pipeline/handlers/sticky_test.go b/internal/pipeline/handlers/sticky_test.go new file mode 100644 index 00000000..434c356f --- /dev/null +++ b/internal/pipeline/handlers/sticky_test.go @@ -0,0 +1,183 @@ +// 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 handlers + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func flagSpecs(names ...string) []pipeline.FlagInfo { + specs := make([]pipeline.FlagInfo, len(names)) + for i, name := range names { + specs[i] = pipeline.FlagInfo{Name: name, Type: "string"} + } + return specs +} + +func TestStickyHandler(t *testing.T) { + tests := []struct { + name string + args []string + flags []string + want string + corrections int + }{ + { + name: "basic split --limit100", + args: []string{"--limit100"}, + flags: []string{"limit"}, + want: "--limit 100", + corrections: 1, + }, + { + name: "no split when flag takes value separately", + args: []string{"--limit", "100"}, + flags: []string{"limit"}, + want: "--limit 100", + corrections: 0, + }, + { + name: "no split when = syntax used", + args: []string{"--limit=100"}, + flags: []string{"limit"}, + want: "--limit=100", + corrections: 0, + }, + { + name: "no split when flag name is not known", + args: []string{"--unknown100"}, + flags: []string{"limit"}, + 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"}, + want: "--user-id 123", + corrections: 1, + }, + { + name: "multiple sticky args in one invocation", + args: []string{"--limit100", "--offset50"}, + flags: []string{"limit", "offset"}, + want: "--limit 100 --offset 50", + corrections: 2, + }, + { + name: "mixed sticky and normal args", + args: []string{"--limit100", "--name", "test", "--offset50"}, + flags: []string{"limit", "name", "offset"}, + want: "--limit 100 --name test --offset 50", + corrections: 2, + }, + { + name: "single dash prefix is ignored", + args: []string{"-l100"}, + flags: []string{"l"}, + want: "-l100", + corrections: 0, + }, + { + name: "empty args", + args: []string{}, + flags: []string{"limit"}, + want: "", + corrections: 0, + }, + { + name: "bare double dash", + args: []string{"--"}, + flags: []string{"limit"}, + want: "--", + corrections: 0, + }, + { + name: "no flag specs available", + args: []string{"--limit100"}, + flags: []string{}, + want: "--limit100", + corrections: 0, + }, + { + name: "exact flag name is not split", + args: []string{"--limit"}, + flags: []string{"limit"}, + want: "--limit", + corrections: 0, + }, + { + name: "boolean-like value still splits", + args: []string{"--verbosetrue"}, + flags: []string{"verbose"}, + want: "--verbose true", + corrections: 1, + }, + { + name: "hyphenated flag name with numeric suffix", + args: []string{"--page-size50"}, + flags: []string{"page-size", "page"}, + want: "--page-size 50", + corrections: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Args: append([]string{}, tt.args...), + FlagSpecs: flagSpecs(tt.flags...), + } + h := StickyHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } + got := strings.Join(ctx.Args, " ") + if got != tt.want { + t.Errorf("Args = %q, want %q", got, tt.want) + } + if len(ctx.Corrections) != tt.corrections { + t.Errorf("Corrections count = %d, want %d", len(ctx.Corrections), tt.corrections) + } + for _, c := range ctx.Corrections { + if c.Kind != "sticky" { + t.Errorf("correction kind = %q, want %q", c.Kind, "sticky") + } + if c.Handler != "sticky" { + t.Errorf("correction handler = %q, want %q", c.Handler, "sticky") + } + } + }) + } +} + +func TestStickyHandlerNameAndPhase(t *testing.T) { + h := StickyHandler{} + if h.Name() != "sticky" { + t.Errorf("Name() = %q, want %q", h.Name(), "sticky") + } + if h.Phase() != pipeline.PreParse { + t.Errorf("Phase() = %v, want PreParse", h.Phase()) + } +} diff --git a/internal/pipeline/phase.go b/internal/pipeline/phase.go new file mode 100644 index 00000000..92acb766 --- /dev/null +++ b/internal/pipeline/phase.go @@ -0,0 +1,158 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +// Phase represents a named stage in the CLI execution pipeline. +// Handlers are grouped by phase and executed in chain order within +// each phase. Phases themselves execute in a fixed order defined +// by the engine. +type Phase int + +const ( + // Register runs at CLI startup when the Cobra command tree is + // being built. Handlers can add, remove, or modify commands. + Register Phase = iota + + // PreParse runs before Cobra parses the raw argv. Handlers + // receive the raw argument slice and can rewrite it — for + // example to fix flag-name typos or split glued values. + PreParse + + // PostParse runs after Cobra has successfully parsed flags and + // args. Handlers receive structured parameters plus the tool + // schema, enabling value-level corrections such as date format + // normalisation. + PostParse + + // PreRequest runs after validation, just before the JSON-RPC + // call is dispatched. Handlers can inspect or mutate the final + // payload. + PreRequest + + // PostResponse runs after the transport returns a result and + // before the output is written to stdout. + PostResponse +) + +// String returns the human-readable name of the phase. +func (p Phase) String() string { + switch p { + case Register: + return "register" + case PreParse: + return "pre-parse" + case PostParse: + return "post-parse" + case PreRequest: + return "pre-request" + case PostResponse: + return "post-response" + default: + return "unknown" + } +} + +// phases returns all phases in execution order. +func phases() []Phase { + return []Phase{Register, PreParse, PostParse, PreRequest, PostResponse} +} + +// Context carries mutable state through the handler chain. Each phase +// populates additional fields; earlier-phase fields remain available in +// later phases so that handlers can correlate raw input with structured +// parameters. +type Context struct { + // Args is the raw argv slice (available from PreParse onward). + // PreParse handlers may rewrite this in place. + Args []string + + // Command is the resolved product.tool canonical path + // (available from PostParse onward). + Command string + + // Params holds structured key→value parameters after Cobra + // parsing (available from PostParse onward). Handlers may + // mutate values or add/remove keys. + Params map[string]any + + // Schema is the JSON Schema for the resolved tool's input + // (available from PostParse onward). Handlers must treat this + // as read-only. + Schema map[string]any + + // Payload is the merged, validated payload ready to be sent + // over the wire (available from PreRequest onward). + Payload map[string]any + + // Response is the JSON-RPC result returned by the server + // (available from PostResponse onward). Handlers may mutate + // the response before it is written to stdout. + Response map[string]any + + // FlagSpecs provides the list of known flag names for the + // current tool, derived from the input schema. PreParse + // handlers use this to match against raw argv tokens. + FlagSpecs []FlagInfo + + // Corrections records every correction applied by handlers, + // enabling downstream logging and debugging. + Corrections []Correction +} + +// FlagInfo describes a single CLI flag derived from a tool's input +// schema. PreParse handlers use this to recognise valid flag names +// when performing fuzzy matching or alias resolution. +type FlagInfo struct { + // Name is the canonical kebab-case flag name (e.g. "user-id"). + Name string + + // PropertyName is the original schema property key (e.g. "userId"). + PropertyName string + + // Type is the JSON Schema type ("string", "integer", etc.). + Type string +} + +// Correction records a single input correction applied by a handler. +type Correction struct { + // Handler is the name of the handler that applied the correction. + Handler string + + // Phase is the pipeline phase in which the correction occurred. + Phase Phase + + // Field identifies the affected flag or parameter name. + Field string + + // Original is the value before correction. + Original string + + // Corrected is the value after correction. + Corrected string + + // Kind classifies the correction (e.g. "alias", "sticky", "case"). + Kind string +} + +// AddCorrection appends a correction record to the context. +func (c *Context) AddCorrection(handler string, phase Phase, field, original, corrected, kind string) { + c.Corrections = append(c.Corrections, Correction{ + Handler: handler, + Phase: phase, + Field: field, + Original: original, + Corrected: corrected, + Kind: kind, + }) +} From 9bf51322cc0d51378ee641306d36e03cfb97c377 Mon Sep 17 00:00:00 2001 From: audanye-sudo Date: Sun, 29 Mar 2026 16:28:49 +0800 Subject: [PATCH 2/6] feat: add --fields and --jq output filtering flags - Add --fields for comma-separated field selection on JSON output - Add --jq for jq expression filtering powered by gojq library - Integrate filters into output formatter pipeline --- go.mod | 2 + go.sum | 4 + internal/app/flags.go | 4 + internal/output/filter.go | 146 +++++++++++++++ internal/output/filter_test.go | 326 +++++++++++++++++++++++++++++++++ internal/output/formatter.go | 55 ++++++ 6 files changed, 537 insertions(+) create mode 100644 internal/output/filter.go create mode 100644 internal/output/filter_test.go diff --git a/go.mod b/go.mod index 891b6f89..2a734851 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,8 @@ require ( require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/itchyny/gojq v0.12.18 // indirect + github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect ) diff --git a/go.sum b/go.sum index cafaf057..4e538ff1 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= +github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= +github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= +github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= diff --git a/internal/app/flags.go b/internal/app/flags.go index 33ea9112..bcdc3f05 100644 --- a/internal/app/flags.go +++ b/internal/app/flags.go @@ -23,7 +23,9 @@ type GlobalFlags struct { ClientSecret string Debug bool DryRun bool + Fields string Format string + JQ string Mock bool Output string Timeout int @@ -37,7 +39,9 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) { cmd.PersistentFlags().StringVar(&flags.ClientSecret, "client-secret", "", "Override OAuth client secret (DingTalk AppSecret)") cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志") cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行") + cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)") cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw") + cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')") cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)") cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file") _ = cmd.PersistentFlags().MarkHidden("output") diff --git a/internal/output/filter.go b/internal/output/filter.go new file mode 100644 index 00000000..c2b6191c --- /dev/null +++ b/internal/output/filter.go @@ -0,0 +1,146 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package output + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/itchyny/gojq" +) + +// SelectFields filters a JSON-serialisable payload to include only +// the specified field names. It works on both objects and arrays: +// - Object: returns a new object with only the matching keys. +// - Array of objects: returns a new array where each element +// contains only the matching keys. +// - Other types: returned unchanged. +// +// Field names are matched case-insensitively against top-level keys. +// Nested field selection (e.g. "response.data") is not supported; +// use --jq for complex queries. +func SelectFields(payload any, fields []string) any { + if len(fields) == 0 { + return payload + } + + // Normalise to a generic JSON structure. + normalised := toGeneric(payload) + + wanted := make(map[string]bool, len(fields)) + for _, f := range fields { + wanted[strings.TrimSpace(strings.ToLower(f))] = true + } + + switch typed := normalised.(type) { + case map[string]any: + return filterMap(typed, wanted) + case []any: + result := make([]any, 0, len(typed)) + for _, item := range typed { + if m, ok := item.(map[string]any); ok { + result = append(result, filterMap(m, wanted)) + } else { + result = append(result, item) + } + } + return result + default: + return normalised + } +} + +// ApplyJQ applies a jq expression to a JSON-serialisable payload and +// writes the results to w. Each result value is written as a separate +// line of JSON. +// +// The expression is compiled once and evaluated against the +// normalised payload. Multiple result values (e.g. from `.[]`) are +// each written as indented JSON followed by a newline. +func ApplyJQ(w io.Writer, payload any, expr string) error { + query, err := gojq.Parse(expr) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("invalid --jq expression: %v", err)) + } + + normalised := toGeneric(payload) + iter := query.Run(normalised) + + first := true + for { + value, ok := iter.Next() + if !ok { + break + } + if err, isErr := value.(error); isErr { + return apperrors.NewValidation(fmt.Sprintf("--jq evaluation error: %v", err)) + } + if !first { + if _, err := fmt.Fprintln(w); err != nil { + return err + } + } + first = false + + data, marshalErr := json.MarshalIndent(value, "", " ") + if marshalErr != nil { + return apperrors.NewInternal("failed to encode --jq result") + } + if _, err := fmt.Fprint(w, string(data)); err != nil { + return err + } + } + if !first { + if _, err := fmt.Fprintln(w); err != nil { + return err + } + } + return nil +} + +// filterMap returns a new map containing only keys that match the +// wanted set (case-insensitive). +func filterMap(m map[string]any, wanted map[string]bool) map[string]any { + result := make(map[string]any, len(wanted)) + for key, value := range m { + if wanted[strings.ToLower(key)] { + result[key] = value + } + } + return result +} + +// toGeneric converts an arbitrary Go value into a generic JSON +// structure (map[string]any / []any / primitives) by round-tripping +// through JSON marshal/unmarshal. This ensures consistent types +// regardless of the original Go struct. The round-trip is always +// performed because even a map[string]any may contain typed values +// (e.g. []ir.CanonicalProduct) that gojq cannot handle. +func toGeneric(payload any) any { + if payload == nil { + return nil + } + data, err := json.Marshal(payload) + if err != nil { + return payload + } + var generic any + if err := json.Unmarshal(data, &generic); err != nil { + return payload + } + return generic +} diff --git a/internal/output/filter_test.go b/internal/output/filter_test.go new file mode 100644 index 00000000..f28477fb --- /dev/null +++ b/internal/output/filter_test.go @@ -0,0 +1,326 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package output + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// --- SelectFields tests --- + +func TestSelectFieldsObject(t *testing.T) { + payload := map[string]any{ + "id": "123", + "name": "Alice", + "email": "alice@example.com", + "status": "active", + } + + tests := []struct { + name string + fields []string + want map[string]any + }{ + { + name: "select single field", + fields: []string{"name"}, + want: map[string]any{"name": "Alice"}, + }, + { + name: "select multiple fields", + fields: []string{"id", "name"}, + want: map[string]any{"id": "123", "name": "Alice"}, + }, + { + name: "case insensitive", + fields: []string{"ID", "Name"}, + want: map[string]any{"id": "123", "name": "Alice"}, + }, + { + name: "non-existent field ignored", + fields: []string{"name", "nonexistent"}, + want: map[string]any{"name": "Alice"}, + }, + { + name: "empty fields returns all", + fields: []string{}, + want: payload, + }, + { + name: "whitespace in field names trimmed", + fields: []string{" name ", " id "}, + want: map[string]any{"id": "123", "name": "Alice"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SelectFields(payload, tt.fields) + gotMap, ok := got.(map[string]any) + if !ok { + t.Fatalf("expected map, got %T", got) + } + if len(gotMap) != len(tt.want) { + t.Errorf("got %d fields, want %d", len(gotMap), len(tt.want)) + } + for key, wantVal := range tt.want { + if gotVal, exists := gotMap[key]; !exists || gotVal != wantVal { + t.Errorf("field %q = %v, want %v", key, gotVal, wantVal) + } + } + }) + } +} + +func TestSelectFieldsArray(t *testing.T) { + payload := []any{ + map[string]any{"id": "1", "name": "Alice", "age": 30}, + map[string]any{"id": "2", "name": "Bob", "age": 25}, + } + + got := SelectFields(payload, []string{"id", "name"}) + arr, ok := got.([]any) + if !ok { + t.Fatalf("expected []any, got %T", got) + } + if len(arr) != 2 { + t.Fatalf("got %d items, want 2", len(arr)) + } + + for i, item := range arr { + m, ok := item.(map[string]any) + if !ok { + t.Fatalf("item %d: expected map, got %T", i, item) + } + if len(m) != 2 { + t.Errorf("item %d: got %d fields, want 2", i, len(m)) + } + if _, hasAge := m["age"]; hasAge { + t.Errorf("item %d: should not have 'age' field", i) + } + } +} + +func TestSelectFieldsStruct(t *testing.T) { + type testStruct struct { + ID string `json:"id"` + Name string `json:"name"` + Age int `json:"age"` + } + payload := testStruct{ID: "1", Name: "Alice", Age: 30} + + got := SelectFields(payload, []string{"id", "name"}) + gotMap, ok := got.(map[string]any) + if !ok { + t.Fatalf("expected map after struct normalisation, got %T", got) + } + if _, hasAge := gotMap["age"]; hasAge { + t.Error("should not have 'age' field") + } + if gotMap["id"] != "1" || gotMap["name"] != "Alice" { + t.Errorf("got %v", gotMap) + } +} + +func TestSelectFieldsPrimitive(t *testing.T) { + got := SelectFields("hello", []string{"name"}) + if got != "hello" { + t.Errorf("primitive should pass through, got %v", got) + } +} + +// --- ApplyJQ tests --- + +func TestApplyJQIdentity(t *testing.T) { + payload := map[string]any{"name": "Alice", "age": float64(30)} + var buf bytes.Buffer + if err := ApplyJQ(&buf, payload, "."); err != nil { + t.Fatalf("ApplyJQ error: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if got["name"] != "Alice" { + t.Errorf("name = %v, want Alice", got["name"]) + } +} + +func TestApplyJQFieldAccess(t *testing.T) { + payload := map[string]any{"name": "Alice", "age": float64(30)} + var buf bytes.Buffer + if err := ApplyJQ(&buf, payload, ".name"); err != nil { + t.Fatalf("ApplyJQ error: %v", err) + } + got := strings.TrimSpace(buf.String()) + if got != `"Alice"` { + t.Errorf("got %q, want %q", got, `"Alice"`) + } +} + +func TestApplyJQArrayIteration(t *testing.T) { + payload := map[string]any{ + "items": []any{ + map[string]any{"name": "Alice"}, + map[string]any{"name": "Bob"}, + }, + } + var buf bytes.Buffer + if err := ApplyJQ(&buf, payload, ".items[].name"); err != nil { + t.Fatalf("ApplyJQ error: %v", err) + } + got := strings.TrimSpace(buf.String()) + if !strings.Contains(got, `"Alice"`) || !strings.Contains(got, `"Bob"`) { + t.Errorf("got %q, want Alice and Bob", got) + } +} + +func TestApplyJQSelect(t *testing.T) { + payload := map[string]any{ + "items": []any{ + map[string]any{"name": "Alice", "active": true}, + map[string]any{"name": "Bob", "active": false}, + }, + } + var buf bytes.Buffer + if err := ApplyJQ(&buf, payload, `.items[] | select(.active) | .name`); err != nil { + t.Fatalf("ApplyJQ error: %v", err) + } + got := strings.TrimSpace(buf.String()) + if got != `"Alice"` { + t.Errorf("got %q, want %q", got, `"Alice"`) + } +} + +func TestApplyJQInvalidExpression(t *testing.T) { + var buf bytes.Buffer + err := ApplyJQ(&buf, map[string]any{}, ".[invalid") + if err == nil { + t.Fatal("expected error for invalid expression") + } + if !strings.Contains(err.Error(), "--jq") { + t.Errorf("error should mention --jq, got %q", err.Error()) + } +} + +func TestApplyJQLength(t *testing.T) { + payload := []any{1, 2, 3, 4, 5} + var buf bytes.Buffer + if err := ApplyJQ(&buf, payload, "length"); err != nil { + t.Fatalf("ApplyJQ error: %v", err) + } + got := strings.TrimSpace(buf.String()) + if got != "5" { + t.Errorf("got %q, want %q", got, "5") + } +} + +func TestApplyJQObjectConstruction(t *testing.T) { + payload := map[string]any{ + "first_name": "Alice", + "last_name": "Smith", + "age": float64(30), + } + var buf bytes.Buffer + if err := ApplyJQ(&buf, payload, `{name: (.first_name + " " + .last_name), age}`); err != nil { + t.Fatalf("ApplyJQ error: %v", err) + } + var got map[string]any + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if got["name"] != "Alice Smith" { + t.Errorf("name = %v, want 'Alice Smith'", got["name"]) + } +} + +func TestApplyJQEmptyResult(t *testing.T) { + payload := map[string]any{"name": "Alice"} + var buf bytes.Buffer + if err := ApplyJQ(&buf, payload, ".nonexistent"); err != nil { + t.Fatalf("ApplyJQ error: %v", err) + } + got := strings.TrimSpace(buf.String()) + if got != "null" { + t.Errorf("got %q, want %q", got, "null") + } +} + +// --- WriteFiltered integration tests --- + +func TestWriteFilteredWithFields(t *testing.T) { + payload := map[string]any{ + "id": "123", + "name": "test", + "data": "secret", + } + var buf bytes.Buffer + if err := WriteFiltered(&buf, FormatJSON, payload, "id,name", ""); err != nil { + t.Fatalf("WriteFiltered error: %v", err) + } + var got map[string]any + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if _, hasData := got["data"]; hasData { + t.Error("should not have 'data' field") + } + if got["id"] != "123" || got["name"] != "test" { + t.Errorf("got %v", got) + } +} + +func TestWriteFilteredWithJQ(t *testing.T) { + payload := map[string]any{"items": []any{"a", "b", "c"}} + var buf bytes.Buffer + if err := WriteFiltered(&buf, FormatJSON, payload, "", ".items | length"); err != nil { + t.Fatalf("WriteFiltered error: %v", err) + } + got := strings.TrimSpace(buf.String()) + if got != "3" { + t.Errorf("got %q, want %q", got, "3") + } +} + +func TestWriteFilteredJQTakesPrecedence(t *testing.T) { + payload := map[string]any{"id": "1", "name": "test"} + var buf bytes.Buffer + // Both --fields and --jq provided: jq wins. + if err := WriteFiltered(&buf, FormatJSON, payload, "id", ".name"); err != nil { + t.Fatalf("WriteFiltered error: %v", err) + } + got := strings.TrimSpace(buf.String()) + if got != `"test"` { + t.Errorf("got %q, want %q", got, `"test"`) + } +} + +func TestWriteFilteredNoFilters(t *testing.T) { + payload := map[string]any{"id": "1"} + var buf bytes.Buffer + if err := WriteFiltered(&buf, FormatJSON, payload, "", ""); err != nil { + t.Fatalf("WriteFiltered error: %v", err) + } + var got map[string]any + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if got["id"] != "1" { + t.Errorf("id = %v, want 1", got["id"]) + } +} diff --git a/internal/output/formatter.go b/internal/output/formatter.go index d35881f7..b919b3cc 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -122,6 +122,61 @@ func normalizeFormat(raw string, fallback Format) Format { } } +// WriteFiltered applies field selection and/or jq filtering before +// writing the payload. If jq is non-empty, the jq result is written +// directly (bypassing format). If fields is non-empty, the payload +// is filtered to those fields before normal output. +func WriteFiltered(w io.Writer, format Format, payload any, fields, jq string) error { + payload = unwrapCompatRuntimePayload(payload) + + if strings.TrimSpace(jq) != "" { + return ApplyJQ(w, payload, strings.TrimSpace(jq)) + } + + if strings.TrimSpace(fields) != "" { + fieldList := strings.Split(fields, ",") + payload = SelectFields(payload, fieldList) + } + + return Write(w, format, payload) +} + +// ResolveFields extracts the --fields flag value from the command. +func ResolveFields(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} { + if flags == nil { + continue + } + if f := flags.Lookup("fields"); f != nil && f.Changed { + if v, err := flags.GetString("fields"); err == nil { + return v + } + } + } + return "" +} + +// ResolveJQ extracts the --jq flag value from the command. +func ResolveJQ(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} { + if flags == nil { + continue + } + if f := flags.Lookup("jq"); f != nil && f.Changed { + if v, err := flags.GetString("jq"); err == nil { + return v + } + } + } + return "" +} + // WriteJSON marshals payload as indented JSON and writes it to w. func WriteJSON(w io.Writer, payload any) error { data, err := json.MarshalIndent(payload, "", " ") From 13f248b285567d75467940a35c0884d88b7cb86e Mon Sep 17 00:00:00 2001 From: audanye-sudo Date: Sun, 29 Mar 2026 16:29:13 +0800 Subject: [PATCH 3/6] feat: enhance stdin handling with StdinGuard and @file/@- support - Add StdinGuard for safe single-read stdin across multiple flags - Add ResolveInputSource unified resolver for @file, @-, and pipe - Support @file / @- syntax for all string-typed override flags - Remove unused writeSchemaText function --- internal/cli/canonical.go | 177 +++++++--- internal/cli/canonical_test.go | 541 +++++++++++++++++++++++++++++- internal/cli/stdin.go | 137 +++++++- internal/cli/stdin_test.go | 587 ++++++++++++++++++++++++++++++++- 4 files changed, 1362 insertions(+), 80 deletions(-) diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index cf3c341a..8b39b221 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -30,6 +30,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/spf13/cobra" ) @@ -56,7 +57,7 @@ type FlagSpec struct { Description string } -func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Runner) *cobra.Command { +func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command { catalog, loadErr := loader.Load(ctx) longDescription := "Reserved canonical runtime surface. Tools are generated from the shared Tool IR under dws mcp." @@ -94,17 +95,27 @@ func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Ru if product.CLI != nil && product.CLI.Skip { continue } - productCommand := newProductCommand(product, runner) + productCommand := newProductCommand(product, runner, engine) cmd.AddCommand(productCommand) - addGroupedProductAlias(cmd, product, runner) + addGroupedProductAlias(cmd, product, runner, engine) } return cmd } func NewSchemaCommand(loader CatalogLoader) *cobra.Command { - cmd := &cobra.Command{ - Use: "schema [canonical-product.tool]", - Short: "Inspect canonical schema metadata", + return &cobra.Command{ + Use: "schema [product.tool]", + Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)", + Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。 + +不带参数时列出所有产品及其工具数量;带 product.tool 路径时 +输出该工具的完整输入 Schema(JSON Schema 格式)。 + +示例: + dws schema # 列出所有产品 + dws schema aitable.query_records # 查看 aitable query_records 的参数 Schema + dws schema --fields id,tools # 只显示 id 和 tools 字段 + dws schema --jq '.products[].id' # 用 jq 提取所有产品 ID`, Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -113,24 +124,20 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command { return err } - jsonOut, err := cmd.Flags().GetBool("json") - if err != nil { - return apperrors.NewInternal("failed to read schema flags") - } - payload, err := schemaPayload(catalog, args) if err != nil { return err } - if jsonOut { - return output.WriteJSON(cmd.OutOrStdout(), payload) - } - return writeSchemaText(cmd.OutOrStdout(), payload) + return output.WriteFiltered( + cmd.OutOrStdout(), + output.ResolveFormat(cmd, output.FormatJSON), + payload, + output.ResolveFields(cmd), + output.ResolveJQ(cmd), + ) }, } - cmd.Flags().Bool("json", false, "Emit schema metadata as JSON") - return cmd } func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []FlagSpec { @@ -169,7 +176,7 @@ func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []Fl return specs } -func newProductCommand(product ir.CanonicalProduct, runner executor.Runner) *cobra.Command { +func newProductCommand(product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) *cobra.Command { shortDescription := product.DisplayName if strings.TrimSpace(product.Description) != "" { shortDescription = product.Description @@ -204,12 +211,12 @@ func newProductCommand(product ir.CanonicalProduct, runner executor.Runner) *cob } for _, tool := range product.Tools { - cmd.AddCommand(newToolCommand(product, tool, runner)) + cmd.AddCommand(newToolCommand(product, tool, runner, engine)) } return cmd } -func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, runner executor.Runner) { +func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) { if root == nil || product.CLI == nil { return } @@ -260,7 +267,7 @@ func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, ru cliCopy.Group = "" aliasProduct.CLI = &cliCopy } - productCommand := newProductCommand(aliasProduct, runner) + productCommand := newProductCommand(aliasProduct, runner, engine) productCommand.Use = leaf productCommand.Aliases = nil if leaf != aliasProduct.ID { @@ -269,7 +276,7 @@ func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, ru parent.AddCommand(productCommand) } -func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner executor.Runner) *cobra.Command { +func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner executor.Runner, engine *pipeline.Engine) *cobra.Command { shortDescription := tool.Title if strings.TrimSpace(tool.Description) != "" { shortDescription = tool.Description @@ -303,60 +310,125 @@ func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner } dryRun = value } + + // One guard per invocation ensures stdin is read at most once. + guard := NewStdinGuard() + jsonPayload, err := cmd.Flags().GetString("json") if err != nil { return apperrors.NewInternal("failed to read --json") } - // Support @file syntax for --json: read file contents as JSON payload. - if content, isFile, fileErr := ReadFileArg(jsonPayload); fileErr != nil { - return fileErr - } else if isFile { - jsonPayload = content - } - - // Support stdin pipe: if no --json given, read from pipe. - if jsonPayload == "" { - if stdinData, stdinErr := ReadStdinIfPiped(); stdinErr != nil { - return stdinErr - } else if stdinData != "" { - jsonPayload = stdinData - } + // Resolve @file / @- for --json flag. + jsonPayload, err = ResolveInputSource(jsonPayload, "json", guard) + if err != nil { + return err } paramsPayload, err := cmd.Flags().GetString("params") if err != nil { return apperrors.NewInternal("failed to read --params") } - overrides, err := collectOverrides(cmd, specs) + + // Resolve @file / @- for all string-typed override flags BEFORE + // the implicit stdin fallback, so explicit @- in any flag takes + // priority over the implicit pipe read. + overrides, err := collectOverrides(cmd, specs, guard) if err != nil { return err } + + // Implicit stdin fallback (lowest priority): if no --json was + // given and no flag claimed stdin via @-, read from pipe. + if jsonPayload == "" && !guard.Claimed() && StdinIsPipe() { + if claimErr := guard.Claim("implicit stdin (pipe)"); claimErr != nil { + return claimErr + } + stdinData, stdinErr := ReadStdin() + if stdinErr != nil { + return stdinErr + } + jsonPayload = stdinData + } + params, err := executor.MergePayloads(jsonPayload, paramsPayload, overrides) if err != nil { return err } + + // PostParse: normalise parameter values (date formats, + // booleans, enums) using the tool's input schema. + if engine != nil && engine.HasHandlers(pipeline.PostParse) { + pctx := &pipeline.Context{ + Command: tool.CanonicalPath, + Params: params, + Schema: tool.InputSchema, + } + if pipeErr := engine.RunPhase(pipeline.PostParse, pctx); pipeErr != nil { + return pipeErr + } + params = pctx.Params + } + if err := ValidateInputSchema(params, tool.InputSchema); err != nil { return err } if !dryRun { - if err := confirmSensitiveTool(cmd, tool); err != nil { + if err := confirmSensitiveTool(cmd, tool, guard); err != nil { return err } } + + // PreRequest: last chance to inspect/mutate payload before + // the JSON-RPC call is dispatched. + if engine != nil && engine.HasHandlers(pipeline.PreRequest) { + pctx := &pipeline.Context{ + Command: tool.CanonicalPath, + Params: params, + Schema: tool.InputSchema, + Payload: params, + } + if pipeErr := engine.RunPhase(pipeline.PreRequest, pctx); pipeErr != nil { + return pipeErr + } + params = pctx.Params + } + invocation := executor.NewInvocation(product, tool, params) invocation.DryRun = dryRun result, err := runner.Run(cmd.Context(), invocation) if err != nil { return err } + + // PostResponse: transform or enrich the response before + // writing it to stdout. + if engine != nil && engine.HasHandlers(pipeline.PostResponse) { + pctx := &pipeline.Context{ + Command: tool.CanonicalPath, + Params: params, + Schema: tool.InputSchema, + Response: result.Response, + } + if pipeErr := engine.RunPhase(pipeline.PostResponse, pctx); pipeErr != nil { + return pipeErr + } + result.Response = pctx.Response + } + if warning := lifecycleWarning(product); warning != "" { if result.Response == nil { result.Response = map[string]any{} } result.Response["warning"] = warning } - return output.WriteJSON(cmd.OutOrStdout(), result) + return output.WriteFiltered( + cmd.OutOrStdout(), + output.ResolveFormat(cmd, output.FormatJSON), + result, + output.ResolveFields(cmd), + output.ResolveJQ(cmd), + ) }, } @@ -416,7 +488,7 @@ func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) { } } -func collectOverrides(cmd *cobra.Command, specs []FlagSpec) (map[string]any, error) { +func collectOverrides(cmd *cobra.Command, specs []FlagSpec, guard *StdinGuard) (map[string]any, error) { overrides := make(map[string]any) for _, spec := range specs { flagName := strings.TrimSpace(spec.FlagName) @@ -434,7 +506,12 @@ func collectOverrides(cmd *cobra.Command, specs []FlagSpec) (map[string]any, err if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName)) } - overrides[spec.PropertyName] = value + // Resolve @file / @- for all string-typed flags. + resolved, resolveErr := ResolveInputSource(value, flagName, guard) + if resolveErr != nil { + return nil, resolveErr + } + overrides[spec.PropertyName] = resolved case flagJSON: value, err := cmd.Flags().GetString(flagName) if err != nil { @@ -528,16 +605,7 @@ func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) { }, nil } -func writeSchemaText(w io.Writer, payload map[string]any) error { - if path, ok := payload["path"].(string); ok && path != "" { - _, err := fmt.Fprintf(w, "schema for %s\n", path) - return err - } - _, err := fmt.Fprintln(w, "canonical schema catalog") - return err -} - -func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor) error { +func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor, guard *StdinGuard) error { if !tool.Sensitive { return nil } @@ -554,6 +622,13 @@ func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor) error { return nil } + // Stdin was consumed for data input — interactive confirmation is impossible. + if guard != nil && guard.Claimed() { + return apperrors.NewValidation( + "stdin used for data input; pass --yes to confirm sensitive operation", + ) + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "tool %s is sensitive, continue? [y/N]: ", tool.CanonicalPath) confirmed, err := readYesNo(cmd.InOrStdin()) if err != nil { diff --git a/internal/cli/canonical_test.go b/internal/cli/canonical_test.go index f14c5720..5e010f2e 100644 --- a/internal/cli/canonical_test.go +++ b/internal/cli/canonical_test.go @@ -25,6 +25,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir" + "github.com/spf13/cobra" ) func TestBuildFlagSpecsGeneratesOnlySupportedTopLevelFlags(t *testing.T) { @@ -124,7 +125,7 @@ func TestNewMCPCommandReturnsLoaderErrorForInvocations(t *testing.T) { t.Parallel() wantErr := errors.New("fixture missing") - cmd := NewMCPCommand(context.Background(), errorLoader{err: wantErr}, executor.EchoRunner{}) + cmd := NewMCPCommand(context.Background(), errorLoader{err: wantErr}, executor.EchoRunner{}, nil) var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) @@ -153,7 +154,7 @@ func TestNewMCPCommandSkipsProductsMarkedSkip(t *testing.T) { }, }, }, - }, executor.EchoRunner{}) + }, executor.EchoRunner{}, nil) if got := cmd.Commands(); len(got) != 1 || got[0].Name() != "drive" { t.Fatalf("mcp commands = %#v, want only drive", got) @@ -178,7 +179,7 @@ func TestProductCommandUsesCLICommandAlias(t *testing.T) { }, }, }, - }, runner) + }, runner, nil) var out bytes.Buffer cmd.SetOut(&out) @@ -212,7 +213,7 @@ func TestNewMCPCommandAddsGroupedRoutesFromCLIMetadata(t *testing.T) { }, }, }, - }, runner) + }, runner, nil) var out bytes.Buffer cmd.SetOut(&out) @@ -257,7 +258,7 @@ func TestToolCommandUsesCLINameAndFlagHints(t *testing.T) { }, }, }, - }, runner) + }, runner, nil) var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) @@ -300,7 +301,7 @@ func TestToolCommandValidatesInputSchemaBeforeRun(t *testing.T) { }, }, }, - }, runner) + }, runner, nil) var out bytes.Buffer cmd.SetOut(&out) @@ -340,7 +341,7 @@ func TestToolCommandSupportsDryRunWithoutSensitiveConfirmation(t *testing.T) { }, }, }, - }, runner) + }, runner, nil) var out bytes.Buffer cmd.SetOut(&out) @@ -380,7 +381,7 @@ func TestDeprecatedLifecycleAddsWarningToResult(t *testing.T) { }, }, }, - }, executor.EchoRunner{}) + }, executor.EchoRunner{}, nil) var out bytes.Buffer var errOut bytes.Buffer @@ -424,7 +425,7 @@ func TestDeprecatedLifecyclePrintsWarningToStderr(t *testing.T) { }, }, }, - }, executor.EchoRunner{}) + }, executor.EchoRunner{}, nil) var out bytes.Buffer var errOut bytes.Buffer @@ -462,7 +463,7 @@ func TestSensitiveToolConfirmationWorksWithoutYesFlag(t *testing.T) { }, }, }, - }, executor.EchoRunner{}) + }, executor.EchoRunner{}, nil) var out bytes.Buffer cmd.SetOut(&out) @@ -491,7 +492,7 @@ func TestLegacyCandidateLifecycleAddsWarningToResult(t *testing.T) { }, }, }, - }, executor.EchoRunner{}) + }, executor.EchoRunner{}, nil) var out bytes.Buffer var errOut bytes.Buffer @@ -514,6 +515,524 @@ func TestLegacyCandidateLifecycleAddsWarningToResult(t *testing.T) { } } +// --------------------------------------------------------------------------- +// Input source resolution: @file for string flags +// --------------------------------------------------------------------------- + +func TestToolCommandResolvesAtFileForStringFlag(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "msg.md") + if err := os.WriteFile(filePath, []byte("Hello from file"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "chat", + Tools: []ir.ToolDescriptor{ + { + RPCName: "send_message", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string"}, + "user_id": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.SetArgs([]string{"chat", "send_message", "--text", "@" + filePath, "--user-id", "u001"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if runner.last.Params["text"] != "Hello from file" { + t.Errorf("params[text] = %q, want %q", runner.last.Params["text"], "Hello from file") + } + if runner.last.Params["user_id"] != "u001" { + t.Errorf("params[user_id] = %q, want %q", runner.last.Params["user_id"], "u001") + } +} + +func TestToolCommandResolvesAtFileForJsonFlag(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "payload.json") + payload := `{"text":"from json file","user_id":"u002"}` + if err := os.WriteFile(filePath, []byte(payload), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "chat", + Tools: []ir.ToolDescriptor{ + { + RPCName: "send_message", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string"}, + "user_id": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.SetArgs([]string{"chat", "send_message", "--json", "@" + filePath}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if runner.last.Params["text"] != "from json file" { + t.Errorf("params[text] = %q, want %q", runner.last.Params["text"], "from json file") + } + if runner.last.Params["user_id"] != "u002" { + t.Errorf("params[user_id] = %q, want %q", runner.last.Params["user_id"], "u002") + } +} + +func TestToolCommandMultipleAtFileFlags(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + titlePath := filepath.Join(dir, "title.txt") + bodyPath := filepath.Join(dir, "body.md") + if err := os.WriteFile(titlePath, []byte("My Title"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := os.WriteFile(bodyPath, []byte("# Body\n\nContent here"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "doc", + Tools: []ir.ToolDescriptor{ + { + RPCName: "create_document", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "title": map[string]any{"type": "string"}, + "body": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.SetArgs([]string{"doc", "create_document", "--title", "@" + titlePath, "--body", "@" + bodyPath}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if runner.last.Params["title"] != "My Title" { + t.Errorf("params[title] = %q, want %q", runner.last.Params["title"], "My Title") + } + if runner.last.Params["body"] != "# Body\n\nContent here" { + t.Errorf("params[body] = %q, want %q", runner.last.Params["body"], "# Body\n\nContent here") + } +} + +func TestToolCommandAtFileMissingReturnsError(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "chat", + Tools: []ir.ToolDescriptor{ + { + RPCName: "send_message", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.SetArgs([]string{"chat", "send_message", "--text", "@/nonexistent/file.txt"}) + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() should fail for missing @file") + } + if !strings.Contains(err.Error(), "--text") { + t.Errorf("error should mention flag name, got: %v", err) + } + if runner.called != 0 { + t.Error("runner should not be called on @file error") + } +} + +func TestToolCommandAtFileForJsonMissingReturnsError(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "chat", + Tools: []ir.ToolDescriptor{ + { + RPCName: "send_message", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.SetArgs([]string{"chat", "send_message", "--json", "@/nonexistent/payload.json"}) + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() should fail for missing @file on --json") + } + if !strings.Contains(err.Error(), "--json") { + t.Errorf("error should mention --json, got: %v", err) + } +} + +func TestToolCommandAtFileUTF8ContentPreserved(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "chinese.txt") + content := "你好世界 🌍\n第二行" + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "chat", + Tools: []ir.ToolDescriptor{ + { + RPCName: "send_message", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.SetArgs([]string{"chat", "send_message", "--text", "@" + filePath}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if runner.last.Params["text"] != content { + t.Errorf("params[text] = %q, want %q", runner.last.Params["text"], content) + } +} + +func TestToolCommandPlainAtValueNotResolvedForNonStringFlags(t *testing.T) { + t.Parallel() + + // Integer and boolean flags should NOT resolve @file syntax. + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "todo", + Tools: []ir.ToolDescriptor{ + { + RPCName: "create_task", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "title": map[string]any{"type": "string"}, + "priority": map[string]any{"type": "integer"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.SetArgs([]string{"todo", "create_task", "--title", "test", "--priority", "3"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if runner.last.Params["priority"] != 3 { + t.Errorf("params[priority] = %v, want 3", runner.last.Params["priority"]) + } +} + +// --------------------------------------------------------------------------- +// Input source resolution: --json @file override priority +// --------------------------------------------------------------------------- + +func TestToolCommandJsonFlagOverridesOverrideFlags(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "base.json") + if err := os.WriteFile(filePath, []byte(`{"text":"from-json","user_id":"json-user"}`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "chat", + Tools: []ir.ToolDescriptor{ + { + RPCName: "send_message", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string"}, + "user_id": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, runner) + + // --text override should win over --json base payload. + cmd.SetArgs([]string{"chat", "send_message", "--json", "@" + filePath, "--text", "override"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if runner.last.Params["text"] != "override" { + t.Errorf("params[text] = %q, want %q (override should win)", runner.last.Params["text"], "override") + } + if runner.last.Params["user_id"] != "json-user" { + t.Errorf("params[user_id] = %q, want %q (from json base)", runner.last.Params["user_id"], "json-user") + } +} + +// --------------------------------------------------------------------------- +// Sensitive tool + stdin guard interaction +// --------------------------------------------------------------------------- + +func TestSensitiveToolWithStdinClaimedRequiresYes(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "msg.txt") + if err := os.WriteFile(filePath, []byte("content"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + // Sensitive tool + @file (does NOT claim stdin) → should still prompt. + // We provide "yes" on stdin to pass confirmation. + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "doc", + Tools: []ir.ToolDescriptor{ + { + RPCName: "delete_document", + Sensitive: true, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "doc_id": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.SetIn(strings.NewReader("yes\n")) + cmd.SetArgs([]string{"doc", "delete_document", "--doc-id", "DOC001"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if runner.called != 1 { + t.Errorf("runner called = %d, want 1", runner.called) + } +} + +func TestSensitiveToolDeniedOnStdinWithNoYesFlag(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "doc", + Tools: []ir.ToolDescriptor{ + { + RPCName: "delete_document", + Sensitive: true, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "doc_id": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.SetIn(strings.NewReader("no\n")) + cmd.SetArgs([]string{"doc", "delete_document", "--doc-id", "DOC001"}) + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() should fail when user denies confirmation") + } + if !strings.Contains(err.Error(), "cancelled") { + t.Errorf("error should mention cancellation, got: %v", err) + } + if runner.called != 0 { + t.Error("runner should not be called when confirmation denied") + } +} + +func TestSensitiveToolWithYesFlagSkipsConfirmation(t *testing.T) { + t.Parallel() + + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "doc", + Tools: []ir.ToolDescriptor{ + { + RPCName: "delete_document", + Sensitive: true, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "doc_id": map[string]any{"type": "string"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.PersistentFlags().Bool("yes", false, "Skip confirmation") + cmd.SetArgs([]string{"doc", "delete_document", "--doc-id", "DOC001", "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if runner.called != 1 { + t.Errorf("runner called = %d, want 1", runner.called) + } +} + +// --------------------------------------------------------------------------- +// collectOverrides: @file does not affect non-string flag types +// --------------------------------------------------------------------------- + +func TestCollectOverridesResolvesAtFileOnlyForStringKind(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "name.txt") + if err := os.WriteFile(filePath, []byte("resolved name"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + runner := &captureRunner{} + cmd := newTestMCPCommand(t, ir.Catalog{ + Products: []ir.CanonicalProduct{ + { + ID: "contact", + Tools: []ir.ToolDescriptor{ + { + RPCName: "search_user", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "keyword": map[string]any{"type": "string"}, + "active": map[string]any{"type": "boolean"}, + "limit": map[string]any{"type": "integer"}, + }, + }, + }, + }, + }, + }, + }, runner) + + cmd.SetArgs([]string{"contact", "search_user", + "--keyword", "@" + filePath, + "--active=true", + "--limit", "10", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + if runner.last.Params["keyword"] != "resolved name" { + t.Errorf("params[keyword] = %q, want %q", runner.last.Params["keyword"], "resolved name") + } + if runner.last.Params["active"] != true { + t.Errorf("params[active] = %v, want true", runner.last.Params["active"]) + } + if runner.last.Params["limit"] != 10 { + t.Errorf("params[limit] = %v, want 10", runner.last.Params["limit"]) + } +} + +// --------------------------------------------------------------------------- +// Test helper +// --------------------------------------------------------------------------- + +func newTestMCPCommand(t *testing.T, catalog ir.Catalog, runner executor.Runner) *cobra.Command { + t.Helper() + cmd := NewMCPCommand(context.Background(), StaticLoader{Catalog: catalog}, runner, nil) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + return cmd +} + type errorLoader struct { err error } diff --git a/internal/cli/stdin.go b/internal/cli/stdin.go index 35d9a922..c99c46b7 100644 --- a/internal/cli/stdin.go +++ b/internal/cli/stdin.go @@ -14,9 +14,11 @@ package cli import ( + "fmt" "io" "os" "strings" + "sync" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) @@ -27,17 +29,89 @@ const ( maxStdinSize = 10 * 1024 * 1024 // 10 MB ) +// StdinGuard ensures stdin is consumed at most once per command invocation. +// Multiple flags using @- or implicit stdin fallback would race on the same +// reader; StdinGuard detects and rejects the second claim with a clear error. +type StdinGuard struct { + mu sync.Mutex + claimed bool + claimBy string +} + +// NewStdinGuard creates a fresh guard for one command invocation. +func NewStdinGuard() *StdinGuard { + return &StdinGuard{} +} + +// Claim marks stdin as consumed by the named source (e.g. "--text @-"). +// Returns an error if stdin was already claimed. +func (g *StdinGuard) Claim(source string) error { + g.mu.Lock() + defer g.mu.Unlock() + if g.claimed { + return apperrors.NewValidation(fmt.Sprintf( + "stdin already consumed by %s; cannot also read stdin for %s", + g.claimBy, source, + )) + } + g.claimed = true + g.claimBy = source + return nil +} + +// Claimed reports whether stdin has been consumed. +func (g *StdinGuard) Claimed() bool { + g.mu.Lock() + defer g.mu.Unlock() + return g.claimed +} + +// StdinIsPipe reports whether stdin is a pipe (not a terminal). +// This is a non-consuming check — it only inspects file mode via stat. +func StdinIsPipe() bool { + info, err := os.Stdin.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice == 0 +} + // ReadStdinIfPiped reads all data from stdin if it is a pipe (not a terminal). // Returns empty string if stdin is a terminal or has no data. func ReadStdinIfPiped() (string, error) { - info, err := os.Stdin.Stat() + if !StdinIsPipe() { + return "", nil + } + return readStdinBounded() +} + +// ReadStdin reads all data from stdin unconditionally (up to maxStdinSize). +// Use this when the caller has explicitly requested stdin via @-. +func ReadStdin() (string, error) { + return readStdinBounded() +} + +// readFileBounded opens a file and reads up to maxStdinSize bytes. +// Uses io.LimitReader to avoid TOCTOU between stat and read. +func readFileBounded(path string) ([]byte, error) { + f, err := os.Open(path) if err != nil { - return "", nil + return nil, apperrors.NewValidation("@file: " + err.Error()) } - // Check if stdin is a pipe (not a character device / terminal). - if info.Mode()&os.ModeCharDevice != 0 { - return "", nil + defer f.Close() + + data, err := io.ReadAll(io.LimitReader(f, maxStdinSize+1)) + if err != nil { + return nil, apperrors.NewValidation("@file: " + err.Error()) } + if int64(len(data)) > maxStdinSize { + return nil, apperrors.NewValidation("@file: file exceeds 10 MB limit") + } + return data, nil +} + +// readStdinBounded reads from os.Stdin up to maxStdinSize bytes. +func readStdinBounded() (string, error) { data, err := io.ReadAll(io.LimitReader(os.Stdin, maxStdinSize+1)) if err != nil { return "", apperrors.NewValidation("failed to read stdin: " + err.Error()) @@ -51,6 +125,8 @@ func ReadStdinIfPiped() (string, error) { // ReadFileArg reads the contents of a file referenced by the @filename syntax. // Returns the original value unchanged if it does not start with "@". // Returns an error if the file cannot be read or exceeds the size limit. +// +// Note: @- (stdin) is NOT handled here; use ResolveInputSource instead. func ReadFileArg(value string) (string, bool, error) { if !strings.HasPrefix(value, "@") { return value, false, nil @@ -59,18 +135,51 @@ func ReadFileArg(value string) (string, bool, error) { if path == "" { return "", false, apperrors.NewValidation("@file: filename must not be empty") } - - info, err := os.Stat(path) - if err != nil { - return "", false, apperrors.NewValidation("@file: " + err.Error()) - } - if info.Size() > maxStdinSize { - return "", false, apperrors.NewValidation("@file: file exceeds 10 MB limit") + // @- is stdin, not a file — callers should use ResolveInputSource. + if path == "-" { + return value, false, nil } - data, err := os.ReadFile(path) + data, err := readFileBounded(path) if err != nil { - return "", false, apperrors.NewValidation("@file: " + err.Error()) + return "", false, err } return string(data), true, nil } + +// ResolveInputSource resolves a flag value that may reference an external +// input source. It supports three forms: +// +// - "@-" reads from stdin (requires StdinGuard claim) +// - "@" reads from the named file +// - anything else returned unchanged +// +// The flagName parameter is used only for error messages and StdinGuard tracking. +func ResolveInputSource(value string, flagName string, guard *StdinGuard) (string, error) { + if !strings.HasPrefix(value, "@") { + return value, nil + } + + path := value[1:] + if path == "" { + return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName)) + } + + // @- reads from stdin. + if path == "-" { + if guard == nil { + return "", apperrors.NewValidation(fmt.Sprintf("--%s: stdin (@-) not available in this context", flagName)) + } + if err := guard.Claim(fmt.Sprintf("--%s @-", flagName)); err != nil { + return "", err + } + return ReadStdin() + } + + // @ reads from file. + data, err := readFileBounded(path) + if err != nil { + return "", fmt.Errorf("--%s: %w", flagName, err) + } + return string(data), nil +} diff --git a/internal/cli/stdin_test.go b/internal/cli/stdin_test.go index 0d0cfb30..e5e3e823 100644 --- a/internal/cli/stdin_test.go +++ b/internal/cli/stdin_test.go @@ -20,6 +20,19 @@ import ( "testing" ) +// writeTestFile is a test helper that writes data to a file and fails the +// test immediately if the write fails, preventing confusing downstream errors. +func writeTestFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("failed to write test file %s: %v", path, err) + } +} + +// --------------------------------------------------------------------------- +// ReadFileArg +// --------------------------------------------------------------------------- + func TestReadFileArgPlainValue(t *testing.T) { t.Parallel() val, isFile, err := ReadFileArg("hello world") @@ -39,7 +52,7 @@ func TestReadFileArgReadsFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "input.txt") - os.WriteFile(path, []byte(`{"title":"test"}`), 0o644) + writeTestFile(t, path, []byte(`{"title":"test"}`)) val, isFile, err := ReadFileArg("@" + path) if err != nil { @@ -78,12 +91,18 @@ func TestReadFileArgSizeLimit(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "huge.txt") // Create a file slightly over maxStdinSize (write 10MB + 1 byte) - f, _ := os.Create(path) + f, err := os.Create(path) + if err != nil { + t.Fatalf("failed to create test file: %v", err) + } data := strings.Repeat("x", maxStdinSize+1) - f.WriteString(data) + if _, err := f.WriteString(data); err != nil { + f.Close() + t.Fatalf("failed to write test data: %v", err) + } f.Close() - _, _, err := ReadFileArg("@" + path) + _, _, err = ReadFileArg("@" + path) if err == nil { t.Fatal("expected error for oversized file") } @@ -92,6 +111,21 @@ func TestReadFileArgSizeLimit(t *testing.T) { } } +func TestReadFileArgAtDashPassThrough(t *testing.T) { + t.Parallel() + // @- means stdin — ReadFileArg should NOT handle it, just pass through. + val, isFile, err := ReadFileArg("@-") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if isFile { + t.Error("@- should not be treated as a file by ReadFileArg") + } + if val != "@-" { + t.Errorf("got %q, want %q", val, "@-") + } +} + func TestReadStdinIfPipedReturnsEmptyForTerminal(t *testing.T) { // This test runs in a terminal context (go test), so stdin is a terminal. // ReadStdinIfPiped should return empty string. @@ -103,3 +137,548 @@ func TestReadStdinIfPipedReturnsEmptyForTerminal(t *testing.T) { t.Errorf("expected empty string for terminal stdin, got %q", val) } } + +func TestStdinIsPipeReturnsFalseForTerminal(t *testing.T) { + // go test runs with stdin as a terminal, so StdinIsPipe should return false. + if StdinIsPipe() { + t.Error("expected StdinIsPipe() == false in terminal context") + } +} + +// --------------------------------------------------------------------------- +// readFileBounded (via ReadFileArg) +// --------------------------------------------------------------------------- + +func TestReadFileBoundedEmptyFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "empty.txt") + writeTestFile(t, path, []byte("")) + + val, isFile, err := ReadFileArg("@" + path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !isFile { + t.Error("should be detected as file") + } + if val != "" { + t.Errorf("expected empty content, got %q", val) + } +} + +func TestReadFileBoundedPermissionDenied(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "noperm.txt") + writeTestFile(t, path, []byte("secret")) + os.Chmod(path, 0o000) + t.Cleanup(func() { os.Chmod(path, 0o644) }) + + _, _, err := ReadFileArg("@" + path) + if err == nil { + t.Fatal("expected error for permission denied") + } +} + +// --------------------------------------------------------------------------- +// StdinGuard +// --------------------------------------------------------------------------- + +func TestStdinGuardFirstClaimSucceeds(t *testing.T) { + t.Parallel() + guard := NewStdinGuard() + if err := guard.Claim("--text @-"); err != nil { + t.Fatalf("first claim should succeed: %v", err) + } + if !guard.Claimed() { + t.Error("guard should report claimed after successful claim") + } +} + +func TestStdinGuardSecondClaimFails(t *testing.T) { + t.Parallel() + guard := NewStdinGuard() + _ = guard.Claim("--text @-") + + err := guard.Claim("--body @-") + if err == nil { + t.Fatal("second claim should fail") + } + if !strings.Contains(err.Error(), "--text @-") { + t.Errorf("error should mention first claimer, got: %v", err) + } + if !strings.Contains(err.Error(), "--body @-") { + t.Errorf("error should mention second claimer, got: %v", err) + } +} + +func TestStdinGuardNotClaimedInitially(t *testing.T) { + t.Parallel() + guard := NewStdinGuard() + if guard.Claimed() { + t.Error("fresh guard should not be claimed") + } +} + +// --------------------------------------------------------------------------- +// ResolveInputSource +// --------------------------------------------------------------------------- + +func TestResolveInputSourcePlainValue(t *testing.T) { + t.Parallel() + guard := NewStdinGuard() + val, err := ResolveInputSource("hello", "text", guard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "hello" { + t.Errorf("got %q, want %q", val, "hello") + } + if guard.Claimed() { + t.Error("plain value should not claim stdin") + } +} + +func TestResolveInputSourceEmptyValue(t *testing.T) { + t.Parallel() + guard := NewStdinGuard() + val, err := ResolveInputSource("", "text", guard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "" { + t.Errorf("got %q, want empty", val) + } +} + +func TestResolveInputSourceAtFileReadsFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "msg.txt") + writeTestFile(t, path, []byte("file content here")) + + guard := NewStdinGuard() + val, err := ResolveInputSource("@"+path, "text", guard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "file content here" { + t.Errorf("got %q, want %q", val, "file content here") + } + // @file should NOT claim stdin. + if guard.Claimed() { + t.Error("@file should not claim stdin") + } +} + +func TestResolveInputSourceAtFileEmptyName(t *testing.T) { + t.Parallel() + guard := NewStdinGuard() + _, err := ResolveInputSource("@", "json", guard) + if err == nil { + t.Fatal("expected error for empty @file name") + } + if !strings.Contains(err.Error(), "--json") { + t.Errorf("error should mention flag name, got: %v", err) + } +} + +func TestResolveInputSourceAtFileMissing(t *testing.T) { + t.Parallel() + guard := NewStdinGuard() + _, err := ResolveInputSource("@/no/such/file.txt", "data", guard) + if err == nil { + t.Fatal("expected error for missing file") + } + if !strings.Contains(err.Error(), "--data") { + t.Errorf("error should mention flag name, got: %v", err) + } +} + +func TestResolveInputSourceAtFileSizeLimit(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "huge.bin") + f, err := os.Create(path) + if err != nil { + t.Fatalf("failed to create test file: %v", err) + } + if _, err := f.WriteString(strings.Repeat("x", maxStdinSize+1)); err != nil { + f.Close() + t.Fatalf("failed to write test data: %v", err) + } + f.Close() + + guard := NewStdinGuard() + _, resolveErr := ResolveInputSource("@"+path, "body", guard) + if resolveErr == nil { + t.Fatal("expected error for oversized file") + } + if !strings.Contains(resolveErr.Error(), "10 MB") { + t.Errorf("error should mention size limit, got: %v", resolveErr) + } +} + +func TestResolveInputSourceAtDashNilGuardFails(t *testing.T) { + t.Parallel() + _, err := ResolveInputSource("@-", "text", nil) + if err == nil { + t.Fatal("expected error when guard is nil") + } + if !strings.Contains(err.Error(), "not available") { + t.Errorf("error should mention stdin unavailability, got: %v", err) + } +} + +func TestResolveInputSourceAtDashDoubleClaimFails(t *testing.T) { + t.Parallel() + guard := NewStdinGuard() + // Simulate first claim from another source. + _ = guard.Claim("--json @-") + + _, err := ResolveInputSource("@-", "text", guard) + if err == nil { + t.Fatal("expected error for double stdin claim") + } + if !strings.Contains(err.Error(), "already consumed") { + t.Errorf("error should mention stdin conflict, got: %v", err) + } +} + +func TestResolveInputSourceAtFileMultiline(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "multiline.md") + content := "# Title\n\nLine 1\nLine 2\nLine 3\n" + writeTestFile(t, path, []byte(content)) + + guard := NewStdinGuard() + val, err := ResolveInputSource("@"+path, "text", guard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != content { + t.Errorf("multiline content mismatch:\ngot: %q\nwant: %q", val, content) + } +} + +func TestResolveInputSourceAtFileUTF8(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "chinese.txt") + content := "你好世界 🌍" + writeTestFile(t, path, []byte(content)) + + guard := NewStdinGuard() + val, err := ResolveInputSource("@"+path, "text", guard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != content { + t.Errorf("UTF-8 content mismatch:\ngot: %q\nwant: %q", val, content) + } +} + +func TestResolveInputSourceAtFileJSON(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "payload.json") + content := `{"title":"meeting","startTime":"2026-03-29T10:00:00Z"}` + writeTestFile(t, path, []byte(content)) + + guard := NewStdinGuard() + val, err := ResolveInputSource("@"+path, "json", guard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != content { + t.Errorf("JSON content mismatch:\ngot: %q\nwant: %q", val, content) + } +} + +func TestResolveInputSourceValueStartingWithAtSign(t *testing.T) { + t.Parallel() + // A value like "@mention" that looks like @file but the file doesn't exist + // should return an error (user likely intended file input). + guard := NewStdinGuard() + _, err := ResolveInputSource("@mention_someone", "text", guard) + if err == nil { + t.Fatal("expected error for non-existent @file path") + } +} + +// --------------------------------------------------------------------------- +// ResolveInputSource: table-driven edge cases +// --------------------------------------------------------------------------- + +func TestResolveInputSourceTable(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + existingFile := filepath.Join(dir, "exists.txt") + writeTestFile(t, existingFile, []byte("file-data")) + + tests := []struct { + name string + value string + flagName string + guard *StdinGuard + wantVal string + wantErr string // substring to match in error, "" means no error + wantClaim bool // expect guard to be claimed after call + }{ + { + name: "plain string unchanged", + value: "hello", + flagName: "text", + guard: NewStdinGuard(), + wantVal: "hello", + }, + { + name: "empty string unchanged", + value: "", + flagName: "text", + guard: NewStdinGuard(), + wantVal: "", + }, + { + name: "plain string with special chars", + value: "hello@world.com", + flagName: "email", + guard: NewStdinGuard(), + wantVal: "hello@world.com", + }, + { + name: "@file reads content", + value: "@" + existingFile, + flagName: "data", + guard: NewStdinGuard(), + wantVal: "file-data", + }, + { + name: "@file does not claim stdin", + value: "@" + existingFile, + flagName: "data", + guard: NewStdinGuard(), + wantVal: "file-data", + wantClaim: false, + }, + { + name: "bare @ is error", + value: "@", + flagName: "body", + guard: NewStdinGuard(), + wantErr: "must not be empty", + }, + { + name: "missing file is error", + value: "@/tmp/does-not-exist-" + t.Name(), + flagName: "file", + guard: NewStdinGuard(), + wantErr: "--file", + }, + { + name: "@- with nil guard is error", + value: "@-", + flagName: "text", + guard: nil, + wantErr: "not available", + }, + { + name: "@- with pre-claimed guard is error", + value: "@-", + flagName: "body", + guard: func() *StdinGuard { + g := NewStdinGuard() + _ = g.Claim("--json @-") + return g + }(), + wantErr: "already consumed", + }, + { + name: "error message includes flag name", + value: "@/nonexistent", + flagName: "my-flag", + guard: NewStdinGuard(), + wantErr: "--my-flag", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + val, err := ResolveInputSource(tt.value, tt.flagName, tt.guard) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != tt.wantVal { + t.Errorf("value = %q, want %q", val, tt.wantVal) + } + if tt.guard != nil && tt.wantClaim != tt.guard.Claimed() { + t.Errorf("guard.Claimed() = %v, want %v", tt.guard.Claimed(), tt.wantClaim) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ReadFileArg: table-driven edge cases +// --------------------------------------------------------------------------- + +func TestReadFileArgTable(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + existingFile := filepath.Join(dir, "data.txt") + writeTestFile(t, existingFile, []byte("content")) + emptyFile := filepath.Join(dir, "empty.txt") + writeTestFile(t, emptyFile, []byte("")) + + tests := []struct { + name string + value string + wantVal string + wantIsFile bool + wantErr string + }{ + {"plain string", "hello", "hello", false, ""}, + {"empty string", "", "", false, ""}, + {"email-like value", "user@domain.com", "user@domain.com", false, ""}, + {"@file reads content", "@" + existingFile, "content", true, ""}, + {"@file empty content", "@" + emptyFile, "", true, ""}, + {"@- passes through", "@-", "@-", false, ""}, + {"bare @ is error", "@", "", false, "must not be empty"}, + {"missing file is error", "@/nonexistent", "", false, "@file"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + val, isFile, err := ReadFileArg(tt.value) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != tt.wantVal { + t.Errorf("value = %q, want %q", val, tt.wantVal) + } + if isFile != tt.wantIsFile { + t.Errorf("isFile = %v, want %v", isFile, tt.wantIsFile) + } + }) + } +} + +// --------------------------------------------------------------------------- +// StdinGuard: table-driven claim sequences +// --------------------------------------------------------------------------- + +func TestStdinGuardClaimSequences(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + claims []string // sources to claim in order + wantFails int // how many claims should fail + wantErrSub string // substring expected in first failure + }{ + { + name: "single claim succeeds", + claims: []string{"--json @-"}, + wantFails: 0, + }, + { + name: "second claim fails", + claims: []string{"--json @-", "--text @-"}, + wantFails: 1, + wantErrSub: "already consumed", + }, + { + name: "third claim also fails", + claims: []string{"--json @-", "--text @-", "--body @-"}, + wantFails: 2, + wantErrSub: "already consumed", + }, + { + name: "error names both sources", + claims: []string{"implicit stdin (pipe)", "--text @-"}, + wantFails: 1, + wantErrSub: "implicit stdin (pipe)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + guard := NewStdinGuard() + fails := 0 + var firstErr error + for _, source := range tt.claims { + if err := guard.Claim(source); err != nil { + fails++ + if firstErr == nil { + firstErr = err + } + } + } + if fails != tt.wantFails { + t.Errorf("failures = %d, want %d", fails, tt.wantFails) + } + if tt.wantErrSub != "" && firstErr != nil { + if !strings.Contains(firstErr.Error(), tt.wantErrSub) { + t.Errorf("first error = %q, want substring %q", firstErr.Error(), tt.wantErrSub) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// StdinGuard concurrency safety +// --------------------------------------------------------------------------- + +func TestStdinGuardConcurrentClaims(t *testing.T) { + t.Parallel() + guard := NewStdinGuard() + + const goroutines = 50 + results := make(chan error, goroutines) + for i := 0; i < goroutines; i++ { + go func(id int) { + results <- guard.Claim("goroutine") + }(i) + } + + successCount := 0 + for i := 0; i < goroutines; i++ { + if err := <-results; err == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("exactly one goroutine should succeed, got %d", successCount) + } +} From c1893f40fc3a9e596b17a91c2244fc3f778ec7f6 Mon Sep 17 00:00:00 2001 From: audanye-sudo Date: Sun, 29 Mar 2026 16:29:35 +0800 Subject: [PATCH 4/6] feat: add tool-level endpoint routing for multi-endpoint products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build tool → endpoint mapping from CLI tools and overrides - directRuntimeEndpoint resolves by tool name before product fallback - Support dynamic command routing with compat layer updates --- internal/app/direct_runtime.go | 51 ++++++-- internal/app/recovery_command.go | 6 +- internal/app/runner.go | 2 +- internal/cli/loader.go | 16 +++ internal/compat/dynamic_commands.go | 43 ++++++- internal/compat/dynamic_commands_test.go | 145 +++++++++++++++++++++++ internal/market/registry.go | 1 + 7 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 internal/compat/dynamic_commands_test.go diff --git a/internal/app/direct_runtime.go b/internal/app/direct_runtime.go index c49d52f4..4c34c3e4 100644 --- a/internal/app/direct_runtime.go +++ b/internal/app/direct_runtime.go @@ -24,10 +24,11 @@ import ( ) var ( - dynamicMu sync.RWMutex - dynamicEndpoints map[string]string - dynamicProducts map[string]bool - dynamicAliases map[string]string + dynamicMu sync.RWMutex + dynamicEndpoints map[string]string + dynamicProducts map[string]bool + dynamicAliases map[string]string + dynamicToolEndpoints map[string]string // tool name → endpoint ) var legacyDirectRuntimeAliases = map[string]string{ @@ -46,6 +47,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) { endpoints := make(map[string]string) products := make(map[string]bool) aliases := make(map[string]string) + toolEndpoints := make(map[string]string) for _, server := range servers { if server.CLI.Skip { continue @@ -70,10 +72,26 @@ func SetDynamicServers(servers []market.ServerDescriptor) { aliases[alias] = id } } + // Build tool → endpoint mapping from CLI tools and overrides. + if endpoint != "" { + for _, tool := range server.CLI.Tools { + toolName := strings.TrimSpace(tool.Name) + if toolName != "" { + toolEndpoints[toolName] = endpoint + } + } + for toolName := range server.CLI.ToolOverrides { + toolName = strings.TrimSpace(toolName) + if toolName != "" { + toolEndpoints[toolName] = endpoint + } + } + } } dynamicEndpoints = endpoints dynamicProducts = products dynamicAliases = aliases + dynamicToolEndpoints = toolEndpoints } func shouldUseDirectRuntime(invocation executor.Invocation) bool { @@ -88,11 +106,9 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool { } } -func directRuntimeEndpoint(productID string) (string, bool) { +func directRuntimeEndpoint(productID, toolName string) (string, bool) { + // Priority 0: env-var override always wins (DINGTALK__MCP_URL). normalized := normalizeDirectRuntimeProductID(productID) - dynamicMu.RLock() - de := dynamicEndpoints - dynamicMu.RUnlock() for _, candidate := range []string{strings.TrimSpace(productID), normalized} { if candidate == "" { continue @@ -100,6 +116,25 @@ func directRuntimeEndpoint(productID string) (string, bool) { if override, ok := productEndpointOverride(candidate); ok { return override, true } + } + + dynamicMu.RLock() + de := dynamicEndpoints + te := dynamicToolEndpoints + dynamicMu.RUnlock() + + // Priority 1: tool-level endpoint (resolves multi-endpoint products). + if tool := strings.TrimSpace(toolName); tool != "" && te != nil { + if endpoint, ok := te[tool]; ok { + return endpoint, true + } + } + + // Priority 2: product-level endpoint. + for _, candidate := range []string{strings.TrimSpace(productID), normalized} { + if candidate == "" { + continue + } if de != nil { if endpoint, ok := de[candidate]; ok { return endpoint, true diff --git a/internal/app/recovery_command.go b/internal/app/recovery_command.go index 30ed2e42..514c6c47 100644 --- a/internal/app/recovery_command.go +++ b/internal/app/recovery_command.go @@ -324,7 +324,7 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName if r == nil || r.transport == nil { return nil, fmt.Errorf("recovery runtime not initialized") } - endpoint, err := r.resolveEndpoint(ctx, serverID) + endpoint, err := r.resolveEndpoint(ctx, serverID, toolName) if err != nil { return nil, err } @@ -344,8 +344,8 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName return &result, nil } -func (r *recoveryRuntime) resolveEndpoint(ctx context.Context, productID string) (string, error) { - if endpoint, ok := directRuntimeEndpoint(productID); ok { +func (r *recoveryRuntime) resolveEndpoint(ctx context.Context, productID, toolName string) (string, error) { + if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok { return endpoint, nil } if r == nil || r.loader == nil { diff --git a/internal/app/runner.go b/internal/app/runner.go index 4f32ba95..6c294ecc 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -88,7 +88,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) } if shouldUseDirectRuntime(invocation) { - if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct); ok { + if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok { return r.executeInvocation(ctx, endpoint, invocation) } } diff --git a/internal/cli/loader.go b/internal/cli/loader.go index ffb89406..8f6f094e 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -50,6 +50,22 @@ func (l StaticLoader) Load(_ context.Context) (ir.Catalog, error) { return l.Catalog, nil } +// CatalogLoaderFrom creates a CatalogLoader that returns a +// pre-loaded catalog and error. This allows multiple consumers +// (schema command, MCP command tree) to share one discovery result. +func CatalogLoaderFrom(catalog ir.Catalog, err error) CatalogLoader { + return &preloadedLoader{catalog: catalog, err: err} +} + +type preloadedLoader struct { + catalog ir.Catalog + err error +} + +func (l *preloadedLoader) Load(_ context.Context) (ir.Catalog, error) { + return l.catalog, l.err +} + type FixtureLoader struct { Path string } diff --git a/internal/compat/dynamic_commands.go b/internal/compat/dynamic_commands.go index ae9c8b82..af48d0e5 100644 --- a/internal/compat/dynamic_commands.go +++ b/internal/compat/dynamic_commands.go @@ -35,7 +35,12 @@ import ( // // Conversion rules reference: docs/mcp-to-cli-conversion.md func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool) []*cobra.Command { - var commands []*cobra.Command + type builtCmd struct { + cmd *cobra.Command + parent string // cli.Parent: attach as sub-command of this top-level command + } + + var built []builtCmd for _, server := range servers { cli := server.CLI // §1.5: cli.skip → skip entire service @@ -136,7 +141,41 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run } } - commands = append(commands, rootCmd) + built = append(built, builtCmd{cmd: rootCmd, parent: strings.TrimSpace(cli.Parent)}) + } + + // Collect top-level commands first, then attach child commands via cli.Parent. + topLevel := make(map[string]*cobra.Command) + var topOrder []string + var children []builtCmd + + for _, b := range built { + if b.parent == "" { + name := b.cmd.Name() + if _, exists := topLevel[name]; !exists { + topOrder = append(topOrder, name) + } + topLevel[name] = b.cmd + } else { + children = append(children, b) + } + } + for _, child := range children { + if parent, ok := topLevel[child.parent]; ok { + parent.AddCommand(child.cmd) + } else { + // Parent not found among dynamic commands; emit as top-level. + name := child.cmd.Name() + if _, exists := topLevel[name]; !exists { + topOrder = append(topOrder, name) + } + topLevel[name] = child.cmd + } + } + + commands := make([]*cobra.Command, 0, len(topLevel)) + for _, name := range topOrder { + commands = append(commands, topLevel[name]) } return commands } diff --git a/internal/compat/dynamic_commands_test.go b/internal/compat/dynamic_commands_test.go new file mode 100644 index 00000000..68f06cc2 --- /dev/null +++ b/internal/compat/dynamic_commands_test.go @@ -0,0 +1,145 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package compat + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" +) + +func TestBuildDynamicCommands_ParentNesting(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "group-chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_conversations": {CLIName: "list"}, + }, + }, + }, + { + Endpoint: "https://endpoint-bot", + CLI: market.CLIOverlay{ + ID: "bot", + Command: "bot", + Parent: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_robot_message": {CLIName: "send"}, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + + // Should produce only one top-level command: "chat" + if len(cmds) != 1 { + names := make([]string, len(cmds)) + for i, c := range cmds { + names[i] = c.Name() + } + t.Fatalf("expected 1 top-level command, got %d: %v", len(cmds), names) + } + if cmds[0].Name() != "chat" { + t.Fatalf("expected top-level command 'chat', got %q", cmds[0].Name()) + } + + // "bot" should be a sub-command of "chat" + found := false + for _, sub := range cmds[0].Commands() { + if sub.Name() == "bot" { + found = true + // "bot" should have its own sub-command "send" + hasSend := false + for _, leaf := range sub.Commands() { + if leaf.Name() == "send" { + hasSend = true + } + } + if !hasSend { + t.Fatal("expected 'bot' to have sub-command 'send'") + } + } + } + if !found { + t.Fatal("expected 'bot' as sub-command of 'chat'") + } +} + +func TestBuildDynamicCommands_ParentNotFound(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-orphan", + CLI: market.CLIOverlay{ + ID: "orphan", + Command: "orphan", + Parent: "nonexistent", + ToolOverrides: map[string]market.CLIToolOverride{ + "do_something": {CLIName: "do"}, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + + // Parent not found, should fall back to top-level + if len(cmds) != 1 { + t.Fatalf("expected 1 top-level command, got %d", len(cmds)) + } + if cmds[0].Name() != "orphan" { + t.Fatalf("expected top-level command 'orphan', got %q", cmds[0].Name()) + } +} + +func TestBuildDynamicCommands_NoParent(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-a", + CLI: market.CLIOverlay{ + ID: "svc-a", + Command: "alpha", + ToolOverrides: map[string]market.CLIToolOverride{ + "tool_a": {CLIName: "run"}, + }, + }, + }, + { + Endpoint: "https://endpoint-b", + CLI: market.CLIOverlay{ + ID: "svc-b", + Command: "beta", + ToolOverrides: map[string]market.CLIToolOverride{ + "tool_b": {CLIName: "exec"}, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + + if len(cmds) != 2 { + t.Fatalf("expected 2 top-level commands, got %d", len(cmds)) + } +} diff --git a/internal/market/registry.go b/internal/market/registry.go index ff7b6d8e..070b622e 100644 --- a/internal/market/registry.go +++ b/internal/market/registry.go @@ -87,6 +87,7 @@ type LifecycleInfo struct { type CLIOverlay struct { ID string `json:"id"` Command string `json:"command"` + Parent string `json:"parent,omitempty"` Description string `json:"description"` Prefixes []string `json:"prefixes"` Aliases []string `json:"aliases"` From c4bc460dc72f14df12a38167507c14c3e21f5574 Mon Sep 17 00:00:00 2001 From: audanye-sudo Date: Sun, 29 Mar 2026 16:29:56 +0800 Subject: [PATCH 5/6] feat: add @file input support for chat helper commands --- internal/helpers/chat.go | 78 +++-- internal/helpers/chat_input_test.go | 456 ++++++++++++++++++++++++++++ internal/helpers/common.go | 32 ++ 3 files changed, 542 insertions(+), 24 deletions(-) create mode 100644 internal/helpers/chat_input_test.go diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 31f56f40..d89360aa 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -17,6 +17,7 @@ import ( "context" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" @@ -92,12 +93,14 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command { return err } - result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( + invocation := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", tool, params, - )) + ) + invocation.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), invocation) if err != nil { return err } @@ -229,7 +232,7 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command { } allMembers := prependOwner(currentUserID, memberUserIDs) - result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( + inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", "create_internal_group", @@ -237,7 +240,9 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command { "groupMembers": stringSliceToAny(allMembers), "groupName": name, }, - )) + ) + inv.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), inv) if err != nil { return err } @@ -253,6 +258,8 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command { } func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (map[string]any, string, error) { + guard := cli.NewStdinGuard() + group, err := cmd.Flags().GetString("group") if err != nil { return nil, "", apperrors.NewInternal("failed to read --group") @@ -265,13 +272,14 @@ func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (map[string]any, st if err != nil { return nil, "", apperrors.NewInternal("failed to read --robot-code") } - title, err := cmd.Flags().GetString("title") + title, err := resolveStringFlag(cmd, "title", guard, false) if err != nil { - return nil, "", apperrors.NewInternal("failed to read --title") + return nil, "", err } - text, err := cmd.Flags().GetString("text") + // --text is the primary content flag: receives stdin pipe when empty. + text, err := resolveStringFlag(cmd, "text", guard, true) if err != nil { - return nil, "", apperrors.NewInternal("failed to read --text") + return nil, "", err } switch { @@ -448,9 +456,11 @@ func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command { "openConversationId": groupID, "processQueryKeys": processQueryKeys, } - result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( + inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "bot", "recall_robot_group_message", params, - )) + ) + inv.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), inv) if err != nil { return err } @@ -460,9 +470,11 @@ func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command { "robotCode": robotCode, "processQueryKeys": processQueryKeys, } - result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( + inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "bot", "batch_recall_robot_users_msg", params, - )) + ) + inv.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), inv) if err != nil { return err } @@ -488,9 +500,17 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command { Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { + guard := cli.NewStdinGuard() token, _ := cmd.Flags().GetString("token") - title, _ := cmd.Flags().GetString("title") - text, _ := cmd.Flags().GetString("text") + title, err := resolveStringFlag(cmd, "title", guard, false) + if err != nil { + return err + } + // --text is the primary content flag: receives stdin pipe when empty. + text, err := resolveStringFlag(cmd, "text", guard, true) + if err != nil { + return err + } if strings.TrimSpace(token) == "" { return apperrors.NewValidation("--token is required") } @@ -514,9 +534,11 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command { if v, _ := cmd.Flags().GetString("at-users"); v != "" { params["atUserIds"] = splitCSV(v) } - result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( + invocation := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "bot", "send_message_by_custom_robot", params, - )) + ) + invocation.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), invocation) if err != nil { return err } @@ -579,9 +601,11 @@ func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command { "openconversation_id": groupID, "group_name": name, } - result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( + inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", "update_group_name", params, - )) + ) + inv.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), inv) if err != nil { return err } @@ -616,9 +640,11 @@ func newChatGroupMemberAddCommand(runner executor.Runner) *cobra.Command { "openconversation_id": groupID, "userId": splitCSV(usersStr), } - result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( + inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", "add_group_member", params, - )) + ) + inv.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), inv) if err != nil { return err } @@ -653,9 +679,11 @@ func newChatGroupMemberRemoveCommand(runner executor.Runner) *cobra.Command { "openconversationId": groupID, "userIdList": splitCSV(usersStr), } - result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( + inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "chat", "remove_group_member", params, - )) + ) + inv.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), inv) if err != nil { return err } @@ -690,9 +718,11 @@ func newChatGroupMembersAddBotCommand(runner executor.Runner) *cobra.Command { "robotCode": robotCode, "openConversationId": groupID, } - result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation( + inv := executor.NewHelperInvocation( cobracmd.LegacyCommandPath(cmd), "bot", "add_robot_to_group", params, - )) + ) + inv.DryRun = commandDryRun(cmd) + result, err := runner.Run(cmd.Context(), inv) if err != nil { return err } diff --git a/internal/helpers/chat_input_test.go b/internal/helpers/chat_input_test.go new file mode 100644 index 00000000..4c2a92ab --- /dev/null +++ b/internal/helpers/chat_input_test.go @@ -0,0 +1,456 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/spf13/cobra" +) + +// inputCaptureRunner records invocations with a call counter. +type inputCaptureRunner struct { + last executor.Invocation + called int +} + +func (r *inputCaptureRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) { + r.last = inv + r.called++ + return executor.Result{Invocation: inv}, nil +} + +func writeInputTestFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("failed to write test file %s: %v", path, err) + } +} + +// newInputTestChatRoot builds the full chat command tree for testing. +func newInputTestChatRoot(t *testing.T, runner executor.Runner) *cobra.Command { + t.Helper() + h := chatHandler{} + root := &cobra.Command{Use: "dws"} + root.AddCommand(h.Command(runner)) + var out, errOut bytes.Buffer + root.SetOut(&out) + root.SetErr(&errOut) + return root +} + +// --------------------------------------------------------------------------- +// send-by-bot: --text @file +// --------------------------------------------------------------------------- + +func TestSendByBotTextFromFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "msg.md") + writeInputTestFile(t, filePath, []byte("# Weekly Report\n\nAll green.")) + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-bot", + "--robot-code", "BOT001", + "--group", "G001", + "--title", "周报", + "--text", "@" + filePath, + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runner.called != 1 { + t.Fatalf("runner called = %d, want 1", runner.called) + } + if runner.last.Params["markdown"] != "# Weekly Report\n\nAll green." { + t.Errorf("params[markdown] = %q, want file content", runner.last.Params["markdown"]) + } +} + +func TestSendByBotTitleFromFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "title.txt") + writeInputTestFile(t, filePath, []byte("Dynamic Title")) + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-bot", + "--robot-code", "BOT001", + "--group", "G001", + "--title", "@" + filePath, + "--text", "content here", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runner.last.Params["title"] != "Dynamic Title" { + t.Errorf("params[title] = %q, want %q", runner.last.Params["title"], "Dynamic Title") + } +} + +func TestSendByBotTextAndTitleBothFromFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + titlePath := filepath.Join(dir, "title.txt") + textPath := filepath.Join(dir, "body.md") + writeInputTestFile(t, titlePath, []byte("File Title")) + writeInputTestFile(t, textPath, []byte("File Body")) + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-bot", + "--robot-code", "BOT001", + "--group", "G001", + "--title", "@" + titlePath, + "--text", "@" + textPath, + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runner.last.Params["title"] != "File Title" { + t.Errorf("params[title] = %q, want %q", runner.last.Params["title"], "File Title") + } + if runner.last.Params["markdown"] != "File Body" { + t.Errorf("params[markdown] = %q, want %q", runner.last.Params["markdown"], "File Body") + } +} + +func TestSendByBotTextFromFileMissingReturnsError(t *testing.T) { + t.Parallel() + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-bot", + "--robot-code", "BOT001", + "--group", "G001", + "--title", "test", + "--text", "@/nonexistent/file.md", + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() should fail for missing @file") + } + if !strings.Contains(err.Error(), "--text") { + t.Errorf("error should mention --text, got: %v", err) + } + if runner.called != 0 { + t.Error("runner should not be called on @file error") + } +} + +func TestSendByBotTextUTF8Preserved(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "chinese.md") + content := "你好世界 🌍\n第二行" + writeInputTestFile(t, filePath, []byte(content)) + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-bot", + "--robot-code", "BOT001", + "--group", "G001", + "--title", "测试", + "--text", "@" + filePath, + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runner.last.Params["markdown"] != content { + t.Errorf("params[markdown] = %q, want %q", runner.last.Params["markdown"], content) + } +} + +// --------------------------------------------------------------------------- +// send-by-bot: backward compatibility (plain --text) +// --------------------------------------------------------------------------- + +func TestSendByBotPlainTextStillWorks(t *testing.T) { + t.Parallel() + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-bot", + "--robot-code", "BOT001", + "--group", "G001", + "--title", "test", + "--text", "plain message", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runner.last.Params["markdown"] != "plain message" { + t.Errorf("params[markdown] = %q, want %q", runner.last.Params["markdown"], "plain message") + } +} + +func TestSendByBotSingleChatStillWorks(t *testing.T) { + t.Parallel() + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-bot", + "--robot-code", "BOT001", + "--users", "u001,u002", + "--title", "test", + "--text", "hello", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runner.last.Tool != "batch_send_robot_msg_to_users" { + t.Errorf("tool = %q, want batch_send_robot_msg_to_users", runner.last.Tool) + } +} + +// --------------------------------------------------------------------------- +// send-by-bot: validation still works +// --------------------------------------------------------------------------- + +func TestSendByBotEmptyTextStillErrors(t *testing.T) { + t.Parallel() + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-bot", + "--robot-code", "BOT001", + "--group", "G001", + "--title", "test", + // --text not provided + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() should fail when --text is empty") + } + if !strings.Contains(err.Error(), "--text") { + t.Errorf("error should mention --text, got: %v", err) + } +} + +func TestSendByBotMissingGroupAndUsersStillErrors(t *testing.T) { + t.Parallel() + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-bot", + "--robot-code", "BOT001", + "--title", "test", + "--text", "hello", + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("Execute() should fail when --group and --users both missing") + } +} + +// --------------------------------------------------------------------------- +// send-by-webhook: --text @file and --title @file +// --------------------------------------------------------------------------- + +func TestWebhookTextFromFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "alert.md") + writeInputTestFile(t, filePath, []byte("CPU > 90%")) + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-webhook", + "--token", "TOKEN001", + "--title", "告警", + "--text", "@" + filePath, + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runner.last.Params["text"] != "CPU > 90%" { + t.Errorf("params[text] = %q, want %q", runner.last.Params["text"], "CPU > 90%") + } +} + +func TestWebhookTitleFromFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "title.txt") + writeInputTestFile(t, filePath, []byte("Alert Title")) + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-webhook", + "--token", "TOKEN001", + "--title", "@" + filePath, + "--text", "body content", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runner.last.Params["title"] != "Alert Title" { + t.Errorf("params[title] = %q, want %q", runner.last.Params["title"], "Alert Title") + } +} + +func TestWebhookPlainTextStillWorks(t *testing.T) { + t.Parallel() + + runner := &inputCaptureRunner{} + cmd := newInputTestChatRoot(t, runner) + cmd.SetArgs([]string{"chat", "message", "send-by-webhook", + "--token", "TOKEN001", + "--title", "test", + "--text", "plain webhook message", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if runner.last.Params["text"] != "plain webhook message" { + t.Errorf("params[text] = %q, want %q", runner.last.Params["text"], "plain webhook message") + } +} + +// --------------------------------------------------------------------------- +// resolveStringFlag unit tests +// --------------------------------------------------------------------------- + +func TestResolveStringFlagPlainValue(t *testing.T) { + t.Parallel() + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("text", "", "") + cmd.SetArgs([]string{"--text", "hello"}) + _ = cmd.Execute() + + guard := cli.NewStdinGuard() + val, err := resolveStringFlag(cmd, "text", guard, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "hello" { + t.Errorf("got %q, want %q", val, "hello") + } +} + +func TestResolveStringFlagAtFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + filePath := filepath.Join(dir, "data.txt") + writeInputTestFile(t, filePath, []byte("file content")) + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("body", "", "") + cmd.SetArgs([]string{"--body", "@" + filePath}) + _ = cmd.Execute() + + guard := cli.NewStdinGuard() + val, err := resolveStringFlag(cmd, "body", guard, false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "file content" { + t.Errorf("got %q, want %q", val, "file content") + } + if guard.Claimed() { + t.Error("@file should not claim stdin") + } +} + +func TestResolveStringFlagAtFileMissing(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("text", "", "") + cmd.SetArgs([]string{"--text", "@/no/such/file"}) + _ = cmd.Execute() + + guard := cli.NewStdinGuard() + _, err := resolveStringFlag(cmd, "text", guard, false) + if err == nil { + t.Fatal("expected error for missing @file") + } + if !strings.Contains(err.Error(), "--text") { + t.Errorf("error should mention flag name, got: %v", err) + } +} + +func TestResolveStringFlagPrimaryContentNoStdinInTerminal(t *testing.T) { + t.Parallel() + + // In go test context, stdin is a terminal — primary content fallback + // should NOT read stdin (StdinIsPipe returns false). + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("text", "", "") + cmd.SetArgs([]string{}) + _ = cmd.Execute() + + guard := cli.NewStdinGuard() + val, err := resolveStringFlag(cmd, "text", guard, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "" { + t.Errorf("got %q, want empty (no stdin pipe in terminal)", val) + } + if guard.Claimed() { + t.Error("guard should not be claimed in terminal context") + } +} + +func TestResolveStringFlagExplicitValueBlocksStdinFallback(t *testing.T) { + t.Parallel() + + // When --text has an explicit value, primaryContent stdin fallback + // should NOT activate even if it's the primary flag. + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("text", "", "") + cmd.SetArgs([]string{"--text", "explicit"}) + _ = cmd.Execute() + + guard := cli.NewStdinGuard() + val, err := resolveStringFlag(cmd, "text", guard, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "explicit" { + t.Errorf("got %q, want %q", val, "explicit") + } + if guard.Claimed() { + t.Error("explicit value should not claim stdin") + } +} diff --git a/internal/helpers/common.go b/internal/helpers/common.go index 9ab48e52..52cab375 100644 --- a/internal/helpers/common.go +++ b/internal/helpers/common.go @@ -15,6 +15,7 @@ package helpers import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -28,6 +29,37 @@ func preferLegacyLeaf(cmd *cobra.Command) { cli.SetOverridePriority(cmd, 100) } +// resolveStringFlag reads a string flag, resolves @file/@- input sources, +// and falls back to stdin pipe when the flag is the designated primary +// content flag and the user did not provide an explicit value. +// +// primaryContent indicates this flag is the default stdin receiver for the +// command (e.g. --text for chat send). When true and the flag value is empty, +// stdin pipe data is used automatically. +func resolveStringFlag(cmd *cobra.Command, flagName string, guard *cli.StdinGuard, primaryContent bool) (string, error) { + raw, err := cmd.Flags().GetString(flagName) + if err != nil { + return "", apperrors.NewInternal("failed to read --" + flagName) + } + + // Resolve @file / @- syntax. + resolved, err := cli.ResolveInputSource(raw, flagName, guard) + if err != nil { + return "", err + } + + // Implicit stdin fallback: only for the primary content flag, only when + // the user did not provide an explicit value and stdin is unclaimed. + if resolved == "" && primaryContent && !guard.Claimed() && cli.StdinIsPipe() { + if claimErr := guard.Claim("implicit stdin → --" + flagName); claimErr != nil { + return "", claimErr + } + return cli.ReadStdin() + } + + return resolved, nil +} + func commandDryRun(cmd *cobra.Command) bool { if cmd == nil { return false From 9c69dcc3e5f29d8a2b2ded962e907efa9186aa26 Mon Sep 17 00:00:00 2001 From: audanye-sudo Date: Sun, 29 Mar 2026 16:30:20 +0800 Subject: [PATCH 6/6] chore: wire pipeline engine into root command and bump version to v1.0.2 - Integrate pipeline engine with pre-parse input correction - Unhide schema command - Update CHANGELOG for v1.0.2 - Update README documentation --- CHANGELOG.md | 31 +- README.md | 377 ++++++++++++------ README_en.md | 248 ------------ internal/app/root.go | 47 ++- internal/app/version.go | 2 +- test/contract/mcp_docs_coverage_test.go | 2 +- .../extensions/cli_overlay_test.go | 2 +- 7 files changed, 320 insertions(+), 389 deletions(-) delete mode 100644 README_en.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca1e097..933004ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,36 @@ 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.1.0] - 2026-03-28 +## [1.0.2] - 2026-03-29 + +Deep workspace tooling upgrade: pipeline-based input correction, output filtering, enhanced stdin handling, and multi-endpoint routing. + +### Added + +- Pipeline engine (`internal/pipeline`) for pre-parse and post-parse input correction + - `AliasHandler`: normalises model-generated flag casing (e.g. `--userId` → `--user-id`) + - `StickyHandler`: splits glued flag values (e.g. `--limit100` → `--limit 100`) + - `ParamNameHandler`: fixes near-miss flag typos (e.g. `--limt` → `--limit`) + - `ParamValueHandler`: normalises structured parameter values after parsing +- Output filtering via `--fields` and `--jq` global flags (`internal/output/filter.go`) + - `--fields`: comma-separated field selection for top-level keys (case-insensitive) + - `--jq`: jq expression filtering powered by `gojq` library +- `StdinGuard` for safe single-read stdin across multiple flags in one invocation +- `ResolveInputSource` unified resolver supporting `@file`, `@-` (explicit stdin), and implicit pipe fallback +- `@file` / `@-` syntax support for all string-typed override flags in tool commands +- Chat helper support for `@file` input to read message content from files +- Tool-level endpoint routing (`dynamicToolEndpoints`) for multi-endpoint products +- Comprehensive test suites for pipeline handlers, stdin guard, canonical commands, and chat input + +### Changed + +- `directRuntimeEndpoint` now accepts tool name for finer-grained endpoint resolution +- `collectOverrides` resolves `@file` / `@-` for all string-typed flags +- `NewRootCommand` refactored to `NewRootCommandWithEngine` with optional pipeline engine +- `schema` command no longer hidden (visible in help output) +- Default output format changed from `table` to `json` + +## [1.0.1] - 2026-03-28 Backward-compatible feature and security update after the initial 1.0.0 release. diff --git a/README.md b/README.md index bf3b86a5..fa29bbbc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

DingTalk Workspace CLI (dws)

-

dws — 钉钉工作台命令行工具,为人类和 AI Agent 而生。

+

dws — DingTalk Workspace on the command line, built for humans and AI agents.

DWS Product Overview @@ -15,121 +15,124 @@

- 中文版 · English · 参考手册 · 更新日志 + 中文版 · English · Reference · Changelog

> [!IMPORTANT] -> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群完成白名单配置。详见下方 [开始使用](#开始使用)。 +> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Please join the DingTalk DWS co-creation group to complete whitelist configuration. See [Getting Started](#getting-started) below. > > DingTalk Group QR Code
-目录 +Table of Contents -- [为什么选择 dws?](#why-dws) -- [安装](#安装) -- [开始使用](#开始使用) -- [快速开始](#快速开始) -- [核心服务](#核心服务) -- [安全设计](#安全设计) +- [Why dws?](#why-dws) +- [Installation](#installation) +- [Getting Started](#getting-started) +- [Quick Start](#quick-start) +- [Using with Agents](#using-with-agents) +- [New Features](#new-features) +- [Key Services](#key-services) +- [Security by Design](#security-by-design) - [AI Agent Skills](#ai-agent-skills) -- [参考与文档](#参考与文档) -- [贡献指南](#贡献指南) +- [Reference & Docs](#reference--docs) +- [Contributing](#contributing)
--- -

为什么选择 dws?

+

Why dws?

-- **为人类而设计** — `--help` 查看用法,`--dry-run` 预览请求,`-f table/json/raw` 切换格式。 -- **为 AI Agent 而设计** — 结构化 JSON 响应 + 内置 Agent Skills,开箱即用。 -- **为企业管理员而设计** — 零信任架构:OAuth 设备流认证 + 域名白名单 + 权限最小化。**没有一个字节能绕过安全鉴权和审计。** +- **For humans** — `--help` for usage, `--dry-run` to preview requests, `-f table/json/raw` for output formats. +- **For AI agents** — structured JSON responses + built-in Agent Skills, ready out of the box. +- **For enterprise admins** — zero-trust architecture: OAuth device-flow auth + domain allowlisting + least-privilege scoping. **Not a single byte can bypass authentication and audit.** -## 安装 +## Installation -**macOS / Linux:** +**macOS / Linux:** ```bash curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh ``` -**Windows(PowerShell):** +**Windows (PowerShell):** ```powershell irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex ```
-其他安装方式 +Other install methods -**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。 +**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases). -**从源码构建**: +**Build from source**: ```bash git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git cd dingtalk-workspace-cli -make build +go build -o dws ./cmd # build to current directory +cp dws ~/.local/bin/ # install to PATH ``` -> 二进制文件默认安装到 `~/.local/bin`。如找不到 `dws`,请将其添加到 PATH:`export PATH="$HOME/.local/bin:$PATH"` +> Requires Go 1.25+. Use `make package` to cross-compile for all platforms (macOS / Linux / Windows x amd64 / arm64).
-## 开始使用 +## Getting Started -### 步骤 1:创建钉钉应用 +### Step 1: Create a DingTalk Application -进入 [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),在「企业内部应用 - 钉钉应用」点击**创建应用**。 +Go to the [Open Platform Console](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app). Under "Internal Enterprise Apps - DingTalk Apps", click **Create App**.
-查看截图 +View screenshot

- 创建应用 + Create Application

-### 步骤 2:配置重定向 URL +### Step 2: Configure Redirect URL -进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存: +Go to app settings → **Security Settings**. Add the following redirect URLs and save: ``` http://127.0.0.1 https://login.dingtalk.com ``` -> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。 +> `http://127.0.0.1` is for local browser login; `https://login.dingtalk.com` is for `--device` device-flow login (Docker containers, remote servers, and other headless environments). We recommend configuring both.
-查看截图 +View screenshot

- 配置重定向URL + Configure Redirect URL

-### 步骤 3:发布应用 +### Step 3: Publish the Application -点击「应用发布 - 版本管理与发布」,发布版本使应用上线。 +Click "App Release - Version Management & Release" to publish and go live.
-查看截图 +View screenshot

- 发布应用 + Publish Application

-### 步骤 4:申请白名单 +### Step 4: Request Whitelist Access -加入钉钉 DWS 共创群,提供 **Client ID** 和**管理员确认凭证**完成白名单配置。 +Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup. -### 步骤 5:登录认证 +### Step 5: Authenticate ```bash dws auth login --client-id --client-secret ``` -或通过环境变量: +Or via environment variables: ```bash export DWS_CLIENT_ID= @@ -137,114 +140,226 @@ export DWS_CLIENT_SECRET= dws auth login ``` -> CLI 参数优先于环境变量。凭证用于钉钉 OAuth 设备流认证。 +> CLI flags take precedence over environment variables. Credentials are used for DingTalk's OAuth device flow. -## 快速开始 +## Quick Start ```bash -dws contact user search --keyword "悟空" # 搜索联系人 -dws calendar event list # 查看日历日程 -dws todo task create --title "季度汇报" --executors "" # 创建待办 -dws todo task list --dry-run # 预览操作但不执行 +dws contact user search --keyword "Alice" # search contacts +dws calendar event list # list calendar events +dws todo task create --title "Quarterly report" --executors "" # create a todo +dws todo task list --dry-run # preview without executing ``` -## 核心服务 +## Using with Agents -| 服务 | 命令 | 描述 | -|---------|---------|-------------| -| 通讯录 | `contact` | 用户 / 部门 | -| 群聊 | `chat` | 群管理 / 群成员 / 机器人消息 / Webhook | -| 日历 | `calendar` | 日程 / 会议室 / 闲忙 | -| 待办 | `todo` | 任务管理 | -| 审批 | `approval` | 流程 / 表单 / 实例 | -| 考勤 | `attendance` | 打卡 / 排班 / 统计 | -| DING | `ding` | DING 消息 / 发送 / 撤回 | -| 日志 | `report` | 日志 / 模版 / 统计 | -| 智能表格 | `aitable` | AI 表格操作 | -| 工作台 | `workbench` | 应用查询 | -| 开发者文档 | `devdoc` | 开放平台文档搜索 | +dws is designed as an AI-native CLI. Here's how to integrate it with your agent: -运行 `dws --help` 查看完整列表,或 `dws --help` 查看子命令。 - -
-即将推出 - -`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场) - -
- -## 安全设计 - -`dws` 从架构层面将安全作为一等公民,而非事后补丁。**凭证不落盘、Token 不出域、权限不越界、操作不脱审** — 每一次 API 调用都必须经过钉钉开放平台的鉴权和审计链路,无例外。 - -
-开发者安全机制 - -| 机制 | 说明 | -|------|------| -| **Token 加密存储** | **PBKDF2(600,000 次迭代 + SHA-256)+ AES-256-GCM** 加密,密钥绑定设备物理 MAC 地址;macOS 集成系统 Keychain、Windows 集成 DPAPI 提供额外保护,跨设备无法解密 | -| **输入安全防护** | 路径遍历防护(符号链接解析 + 工作目录约束)、CRLF 注入拦截、Unicode 视觉欺骗字符过滤,防止 AI Agent 被恶意指令诱导 | -| **域名白名单** | `DWS_TRUSTED_DOMAINS` 默认仅信任 `*.dingtalk.com`,Bearer Token 不会发送到非白名单域 | -| **并发安全** | 双层锁机制(进程内 + 跨进程文件锁)保障 Token 刷新原子性,适配高并发 MCP Server 场景 | -| **数据完整性** | 所有配置写入采用原子操作(temp + fsync + rename),确保进程中断时数据不损坏 | -| **HTTPS 强制** | 除 loopback 开发调试外,所有请求强制 TLS | -| **Dry-run 预览** | `--dry-run` 展示调用参数但不执行,防止误操作生产数据 | -| **凭证零落盘** | Client ID / Secret 仅在内存中使用,不写入配置文件或日志 | - -
- -
-企业管理员安全机制 - -| 机制 | 说明 | -|------|------| -| **OAuth 设备流认证** | 用户必须通过管理员授权的钉钉应用认证,未授权应用无法获取 Token | -| **权限最小化** | CLI 仅能调用管理员授予该应用的 API 权限范围,无法越权 | -| **白名单准入** | 共创阶段需管理员主动确认开通,后续支持自助审批 | -| **操作全链路审计** | 每一次数据读写都经过钉钉开放平台 API,企业管理员可在管理后台实时追溯完整调用日志,任何异常操作无处隐藏 | - -
- -
-ISV / 企业服务商安全机制 - -| 机制 | 说明 | -|------|------| -| **租户数据隔离** | 以已授权应用身份调用 API,不同租户数据严格隔离 | -| **Skill 沙箱** | Agent Skills 是 Markdown 文档(`SKILL.md`),仅提供 prompt 描述,不执行任意代码 | -| **集成链路零盲区** | ISV Skill 与 dws Skill 联调时,每一次 API 调用都强制经过钉钉开放平台鉴权,完整调用链路可追溯,不存在绕过审计的旁路 | - -
- -> 发现安全漏洞?请通过 [GitHub Security Advisories](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/security/advisories/new) 报告,详见 [SECURITY.md](./SECURITY.md)。 - -## AI Agent Skills - -仓库为每个钉钉产品提供 Agent Skill(`SKILL.md`),安装脚本会自动部署到 `~/.agents/skills/dws`。 +### 1. Setup ```bash -# 仅安装 skills 到当前项目 +# Install dws +curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh + +# Configure auth via environment variables (recommended for agents, no interactive login) +export DWS_CLIENT_ID= +export DWS_CLIENT_SECRET= +dws auth login +``` + +### 2. Agent Invocation Patterns + +```bash +# Use --yes to skip confirmation prompts (required for agents) +dws todo task create --title "Review PR" --yes + +# Use --dry-run to preview operations (safe execution) +dws contact user search --keyword "test" --dry-run + +# Use --jq to extract precisely (save tokens) +dws contact user search --keyword "test" --jq '.response.content' + +# Use --fields to return only needed fields +dws calendar event list --fields response +``` + +### 3. Schema Discovery (Agent Self-Exploration) + +Agents don't need pre-built knowledge of every command. Use `dws schema` to dynamically discover capabilities: + +```bash +# Step 1: Discover all available products +dws schema --jq '.products[] | {id, tool_count: (.tools | length)}' + +# Step 2: Inspect target tool's parameter schema +dws schema aitable.query_records --jq '.tool.input_schema' + +# Step 3: Construct the correct call +dws aitable record query --base-id "xxx" --table-id "yyy" --limit 10 +``` + +### 4. Error Tolerance + +The smart correction engine is highly tolerant of common agent-generated mistakes: + +| Agent Output | dws Auto-Corrects To | +|-----------|--------------| +| `--userId` | `--user-id` | +| `--limit100` | `--limit 100` | +| `--tabel-id` | `--table-id` | +| `--USER-ID` | `--user-id` | +| `--user_name` | `--user-name` | + +### 5. Agent Skill Integration + +After installing Agent Skills, tools like Claude Code / Cursor can use DingTalk capabilities directly: + +```bash +# Install skills into current project curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh ``` -> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。 +> See the [AI Agent Skills](#ai-agent-skills) section for details. -### ISV Skill 联调 +## New Features -编写您自己的 Agent Skill,与 dws 内置 skill 搭配构建跨产品工作流:**ISV Skill → dws Skill → 钉钉开放平台 API(强制鉴权 + 全链路审计)**。 +### Smart Input Correction -**示例**:CRM Skill 调用日历 Skill 为客户创建会议,再通过待办 Skill 分配跟进任务 — AI Agent 在一次对话中完成跨系统协作。 +dws includes a built-in pipeline engine that auto-corrects common AI model parameter mistakes: -## 参考与文档 +```bash +# Naming convention auto-conversion (camelCase / snake_case / UPPER -> kebab-case) +dws aitable record query --baseId x --tableId y # auto-corrected to --base-id --table-id -- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全 -- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层 -- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明 +# Sticky argument splitting +dws contact user search --keyword "test" --timeout30 # auto-split to --timeout 30 -## 贡献指南 +# Fuzzy flag name matching +dws aitable record query --base-id x --tabel-id y # --tabel-id -> --table-id -参见 [CONTRIBUTING.md](./CONTRIBUTING.md) 了解构建、测试和开发工作流。 +# Value normalization (boolean / number / date / enum) +# "yes" -> true, "1,000" -> 1000, "2024/03/29" -> "2024-03-29", "ACTIVE" -> "active" +``` -## 许可证 +### JSON Field Selection & jq Filtering + +Fine-grained output control to reduce token consumption: + +```bash +# Return only specific fields +dws aitable record query --base-id x --table-id y --fields invocation,response + +# Built-in jq expressions +dws aitable record query --base-id x --table-id y --jq '.invocation.params' +dws schema --jq '.products[] | {id, tools: (.tools | length)}' +``` + +### Schema Introspection + +Agents can query parameter schemas before making calls: + +```bash +dws schema # list all products and tools +dws schema aitable.query_records # view parameter schema +dws schema aitable.query_records --jq '.tool.input_schema.required' # view required fields +dws schema --jq '.products[].id' # extract all product IDs +``` + +## Key Services + +| Service | Command | Description | +|---------|---------|-------------| +| Contact | `contact` | Users / departments | +| Chat | `chat` | Group management / members / bot messaging / webhook | +| Calendar | `calendar` | Events / meeting rooms / free-busy | +| Todo | `todo` | Task management | +| Approval | `approval` | Processes / forms / instances | +| Attendance | `attendance` | Clock-in / shifts / statistics | +| Ding | `ding` | DING messages / send / recall | +| Report | `report` | Reports / templates / statistics | +| AITable | `aitable` | AI table operations | +| Workbench | `workbench` | App query | +| DevDoc | `devdoc` | Open platform docs search | + +Run `dws --help` for the full list, or `dws --help` for subcommands. + +
+Coming soon + +`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace) + +
+ +

Security by Design

+ +`dws` treats security as a first-class architectural concern, not an afterthought. **Credentials never touch disk, tokens never leave trusted domains, permissions never exceed grants, operations never escape audit** — every API call must pass through DingTalk Open Platform's authentication and audit chain, no exceptions. + +
+For Developers + +| Mechanism | Details | +|-----------|----------| +| **Encrypted token storage** | **PBKDF2 + AES-256-GCM** encryption, keyed by device physical MAC address; cross-platform Keychain/DPAPI integration provides additional protection — tokens cannot be decrypted on another machine | +| **Input security** | Path traversal protection (symlink resolution + working directory containment), CRLF injection blocking, Unicode visual spoofing filtering — prevents AI Agents from being tricked by malicious instructions | +| **Domain allowlist** | `DWS_TRUSTED_DOMAINS` defaults to `*.dingtalk.com`; bearer tokens are never sent to non-allowlisted domains | +| **HTTPS enforced** | All requests require TLS; HTTP only permitted for loopback during development | +| **Dry-run preview** | `--dry-run` shows call parameters without executing, preventing accidental mutations | +| **Zero credential persistence** | Client ID / Secret used in memory only — never written to config files or logs | + +
+ +
+For Enterprise Admins + +| Mechanism | Details | +|-----------|---------| +| **OAuth device-flow auth** | Users must authenticate through an admin-authorized DingTalk application | +| **Least-privilege scoping** | CLI can only invoke APIs granted to the application — no privilege escalation | +| **Allowlist gating** | Admin confirmation required during co-creation phase; self-service approval planned | +| **Full-chain audit** | Every data read/write passes through the DingTalk Open Platform API — enterprise admins can trace complete call logs in real time; no anomalous operation can hide | + +
+ +
+For ISVs + +| Mechanism | Details | +|-----------|---------| +| **Tenant data isolation** | Operates under authorized app identity; cross-tenant access is impossible | +| **Skill sandbox** | Agent Skills are Markdown documents (`SKILL.md`) — prompt descriptions only, no arbitrary code execution | +| **Zero blind spots** | Every API call during ISV–dws skill orchestration is forced through DingTalk Open Platform authentication — full call chain is traceable with no bypass path | + +
+ +> Found a vulnerability? Report via [GitHub Security Advisories](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/security/advisories/new). See [SECURITY.md](./SECURITY.md). + +## AI Agent Skills + +The repo ships Agent Skills (`SKILL.md` files) for every DingTalk product. The install script deploys them to `~/.agents/skills/dws` automatically. + +```bash +# Install skills into the current project only +curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh +``` + +> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project). + +### ISV Skill Integration + +Author your own Agent Skills and orchestrate them with dws skills for cross-product workflows: **ISV Skill → dws Skill → DingTalk Open Platform API (enforced auth + full audit)**. + +**Example**: A CRM Skill invokes the Calendar Skill to create a client meeting, then triggers the Todo Skill to assign follow-ups — the AI agent completes cross-system collaboration in a single conversation. + +## Reference & Docs + +- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion +- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer +- [Changelog](./CHANGELOG.md) — release history and migration notes + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for build instructions, testing, and development workflow. + +## License Apache-2.0 diff --git a/README_en.md b/README_en.md deleted file mode 100644 index 2c32bad0..00000000 --- a/README_en.md +++ /dev/null @@ -1,248 +0,0 @@ -

DingTalk Workspace CLI (dws)

- -

dws — DingTalk Workspace on the command line, built for humans and AI agents.

- -

- DWS Product Overview -

- -

- Go 1.25+ - License Apache-2.0 - v1.1.0 - CI - Coverage -

- -

- 中文版 · English · Reference · Changelog -

- -> [!IMPORTANT] -> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Please join the DingTalk DWS co-creation group to complete whitelist configuration. See [Getting Started](#getting-started) below. -> -> DingTalk Group QR Code - -
-Table of Contents - -- [Why dws?](#why-dws) -- [Installation](#installation) -- [Getting Started](#getting-started) -- [Quick Start](#quick-start) -- [Key Services](#key-services) -- [Security by Design](#security-by-design) -- [AI Agent Skills](#ai-agent-skills) -- [Reference & Docs](#reference--docs) -- [Contributing](#contributing) - -
- ---- - -

Why dws?

- -- **For humans** — `--help` for usage, `--dry-run` to preview requests, `-f table/json/raw` for output formats. -- **For AI agents** — structured JSON responses + built-in Agent Skills, ready out of the box. -- **For enterprise admins** — zero-trust architecture: OAuth device-flow auth + domain allowlisting + least-privilege scoping. **Not a single byte can bypass authentication and audit.** - -## Installation - -**macOS / Linux:** - -```bash -curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh -``` - -**Windows (PowerShell):** - -```powershell -irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex -``` - -
-Other install methods - -**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases). - -**Build from source**: - -```bash -git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git -cd dingtalk-workspace-cli -make build -``` - -> The binary installs to `~/.local/bin` by default. If `dws` is not found, add it to PATH: `export PATH="$HOME/.local/bin:$PATH"` - -
- -## Getting Started - -### Step 1: Create a DingTalk Application - -Go to the [Open Platform Console](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app). Under "Internal Enterprise Apps - DingTalk Apps", click **Create App**. - -
-View screenshot -

- Create Application -

-
- -### Step 2: Configure Redirect URL - -Go to app settings → **Security Settings**. Add the following redirect URLs and save: - -``` -http://127.0.0.1 -https://login.dingtalk.com -``` - -> `http://127.0.0.1` is for local browser login; `https://login.dingtalk.com` is for `--device` device-flow login (Docker containers, remote servers, and other headless environments). We recommend configuring both. - -
-View screenshot -

- Configure Redirect URL -

-
- -### Step 3: Publish the Application - -Click "App Release - Version Management & Release" to publish and go live. - -
-View screenshot -

- Publish Application -

-
- -### Step 4: Request Whitelist Access - -Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup. - -### Step 5: Authenticate - -```bash -dws auth login --client-id --client-secret -``` - -Or via environment variables: - -```bash -export DWS_CLIENT_ID= -export DWS_CLIENT_SECRET= -dws auth login -``` - -> CLI flags take precedence over environment variables. Credentials are used for DingTalk's OAuth device flow. - -## Quick Start - -```bash -dws contact user search --keyword "Alice" # search contacts -dws calendar event list # list calendar events -dws todo task create --title "Quarterly report" --executors "" # create a todo -dws todo task list --dry-run # preview without executing -``` - -## Key Services - -| Service | Command | Description | -|---------|---------|-------------| -| Contact | `contact` | Users / departments | -| Chat | `chat` | Group management / members / bot messaging / webhook | -| Calendar | `calendar` | Events / meeting rooms / free-busy | -| Todo | `todo` | Task management | -| Approval | `approval` | Processes / forms / instances | -| Attendance | `attendance` | Clock-in / shifts / statistics | -| Ding | `ding` | DING messages / send / recall | -| Report | `report` | Reports / templates / statistics | -| AITable | `aitable` | AI table operations | -| Workbench | `workbench` | App query | -| DevDoc | `devdoc` | Open platform docs search | - -Run `dws --help` for the full list, or `dws --help` for subcommands. - -
-Coming soon - -`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace) - -
- -

Security by Design

- -`dws` treats security as a first-class architectural concern, not an afterthought. **Credentials never touch disk, tokens never leave trusted domains, permissions never exceed grants, operations never escape audit** — every API call must pass through DingTalk Open Platform's authentication and audit chain, no exceptions. - -
-For Developers - -| Mechanism | Details | -|-----------|----------| -| **Encrypted token storage** | **PBKDF2 + AES-256-GCM** encryption, keyed by device physical MAC address; cross-platform Keychain/DPAPI integration provides additional protection — tokens cannot be decrypted on another machine | -| **Input security** | Path traversal protection (symlink resolution + working directory containment), CRLF injection blocking, Unicode visual spoofing filtering — prevents AI Agents from being tricked by malicious instructions | -| **Domain allowlist** | `DWS_TRUSTED_DOMAINS` defaults to `*.dingtalk.com`; bearer tokens are never sent to non-allowlisted domains | -| **HTTPS enforced** | All requests require TLS; HTTP only permitted for loopback during development | -| **Dry-run preview** | `--dry-run` shows call parameters without executing, preventing accidental mutations | -| **Zero credential persistence** | Client ID / Secret used in memory only — never written to config files or logs | - -
- -
-For Enterprise Admins - -| Mechanism | Details | -|-----------|---------| -| **OAuth device-flow auth** | Users must authenticate through an admin-authorized DingTalk application | -| **Least-privilege scoping** | CLI can only invoke APIs granted to the application — no privilege escalation | -| **Allowlist gating** | Admin confirmation required during co-creation phase; self-service approval planned | -| **Full-chain audit** | Every data read/write passes through the DingTalk Open Platform API — enterprise admins can trace complete call logs in real time; no anomalous operation can hide | - -
- -
-For ISVs - -| Mechanism | Details | -|-----------|---------| -| **Tenant data isolation** | Operates under authorized app identity; cross-tenant access is impossible | -| **Skill sandbox** | Agent Skills are Markdown documents (`SKILL.md`) — prompt descriptions only, no arbitrary code execution | -| **Zero blind spots** | Every API call during ISV–dws skill orchestration is forced through DingTalk Open Platform authentication — full call chain is traceable with no bypass path | - -
- -> Found a vulnerability? Report via [GitHub Security Advisories](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/security/advisories/new). See [SECURITY.md](./SECURITY.md). - -## AI Agent Skills - -The repo ships Agent Skills (`SKILL.md` files) for every DingTalk product. The install script deploys them to `~/.agents/skills/dws` automatically. - -```bash -# Install skills into the current project only -curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh -``` - -> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project). - -### ISV Skill Integration - -Author your own Agent Skills and orchestrate them with dws skills for cross-product workflows: **ISV Skill → dws Skill → DingTalk Open Platform API (enforced auth + full audit)**. - -**Example**: A CRM Skill invokes the Calendar Skill to create a client meeting, then triggers the Todo Skill to assign follow-ups — the AI agent completes cross-system collaboration in a single conversation. - -## Reference & Docs - -- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion -- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer -- [Changelog](./CHANGELOG.md) — release history and migration notes - -## Contributing - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for build instructions, testing, and development workflow. - -## License - -Apache-2.0 diff --git a/internal/app/root.go b/internal/app/root.go index cfc9364e..509f6ea3 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -37,6 +37,8 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" "github.com/spf13/cobra" @@ -53,7 +55,14 @@ func Execute() int { defer cancel() recovery.ResetRuntimeState() - root := NewRootCommand(ctx) + engine := newPipelineEngine() + root := NewRootCommandWithEngine(ctx, engine) + + // Run PreParse handlers on raw argv before Cobra parses flags. + // This corrects model-generated errors like --userId → --user-id + // and --limit100 → --limit 100. + pipeline.RunPreParse(root, engine) + executed, err := root.ExecuteC() if err != nil { if executed == nil { @@ -165,7 +174,15 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command { var rootCtx context.Context if len(ctx) > 0 && ctx[0] != nil { rootCtx = ctx[0] - } else { + } + return NewRootCommandWithEngine(rootCtx, nil) +} + +// NewRootCommandWithEngine constructs the root CLI command with an +// optional pipeline engine for input correction. When engine is nil, +// no pipeline processing is applied. +func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command { + if rootCtx == nil { rootCtx = context.Background() } flags := &GlobalFlags{} @@ -209,10 +226,9 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command { bindPersistentFlags(root, flags) schemaCmd := newSchemaCommand(loader) - schemaCmd.Hidden = true genSkillsCmd := newGenerateSkillsCommand() genSkillsCmd.Hidden = true - mcpCmd := newMCPCommand(rootCtx, loader, runner) + mcpCmd := newMCPCommand(rootCtx, loader, runner, engine) mcpCmd.Hidden = true utilityCommands := []*cobra.Command{ @@ -512,8 +528,8 @@ func newGenerateSkillsCommand() *cobra.Command { return cmd } -func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner) *cobra.Command { - return cli.NewMCPCommand(ctx, loader, runner) +func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command { + return cli.NewMCPCommand(ctx, loader, runner, engine) } // hideNonDirectRuntimeCommands marks top-level product commands as hidden @@ -804,3 +820,22 @@ func CloseFileLogger() { fileLogger.Close() } } + +// newPipelineEngine creates and configures the pipeline engine with +// the standard set of handlers for model input correction. +func newPipelineEngine() *pipeline.Engine { + engine := pipeline.NewEngine() + engine.RegisterAll( + // PreParse handlers run in order: alias → sticky → paramname. + // Alias normalises case first (--userId → --user-id), then + // sticky splits glued values (--limit100 → --limit 100), then + // paramname fixes near-miss typos (--limt → --limit). + handlers.AliasHandler{}, + handlers.StickyHandler{}, + handlers.ParamNameHandler{}, + + // PostParse handlers normalise structured values. + handlers.ParamValueHandler{}, + ) + return engine +} diff --git a/internal/app/version.go b/internal/app/version.go index 43317dce..2942c201 100644 --- a/internal/app/version.go +++ b/internal/app/version.go @@ -13,7 +13,7 @@ package app -var version = "v1.1.0" +var version = "v1.0.2" // Version returns the current CLI version string, including build metadata // when injected via ldflags (buildTime, gitCommit). diff --git a/test/contract/mcp_docs_coverage_test.go b/test/contract/mcp_docs_coverage_test.go index 922f5e14..ceaeb7c9 100644 --- a/test/contract/mcp_docs_coverage_test.go +++ b/test/contract/mcp_docs_coverage_test.go @@ -173,7 +173,7 @@ func TestMCPProtocolDocsCoverageViaEndpointGeneratedCLI(t *testing.T) { } func inspectToolCommand(catalog ir.Catalog, productID, toolCommand string) (map[string]struct{}, string, error) { - root := cli.NewMCPCommand(context.Background(), cli.StaticLoader{Catalog: catalog}, executor.EchoRunner{}) + root := cli.NewMCPCommand(context.Background(), cli.StaticLoader{Catalog: catalog}, executor.EchoRunner{}, nil) toolCmd, _, err := root.Find([]string{productID, toolCommand}) if err != nil { diff --git a/test/integration/extensions/cli_overlay_test.go b/test/integration/extensions/cli_overlay_test.go index 25d4864f..b921811d 100644 --- a/test/integration/extensions/cli_overlay_test.go +++ b/test/integration/extensions/cli_overlay_test.go @@ -88,7 +88,7 @@ func TestCLIMetadataDrivesCanonicalCommandShape(t *testing.T) { t.Fatalf("tool metadata = %#v, want make-doc hidden", tool) } - cmd := cli.NewMCPCommand(context.Background(), cli.StaticLoader{Catalog: catalog}, executor.EchoRunner{}) + cmd := cli.NewMCPCommand(context.Background(), cli.StaticLoader{Catalog: catalog}, executor.EchoRunner{}, nil) module := cmd.Commands()[0] if !module.Hidden || module.Name() != "knowledge" { t.Fatalf("module hidden/name = (%t,%s), want (true,knowledge)", module.Hidden, module.Name())