Files
e32fa1535c feat(auth): support multi-profile login (#500)
* feat(auth): support multi-profile login

* fix(auth): complete multi-org profile acceptance

* feat(auth): 完成多组织 profile 验收

* docs(auth): 补充多组织 Ralph 验收材料

* feat(auth): 支持 auth switch TUI 切换 profile

* feat(auth): logout 默认清理所有组织

* feat(auth): login 默认新增组织授权

* feat(profile): 使用 profile switch 切换组织

* docs(ralph): 更新 profile switch 验收材料

* fix(profile): 展示全部可切换组织

* feat(profile): support multi-org switch tui

* chore(install): add branch source installer

* fix(profile): keep global profile out of tool params

* feat(profile): support csv multi-profile runtime

* ci: add multi-profile e2e workflow

* ci: run multi-profile e2e on all branches

* docs: document multi-profile e2e ci gate

* docs: remove multi-profile test cases from pr

* ci: harden multi-profile e2e gates

* fix(auth): serialize profiles.json RMW and harden multi-profile persistence

Wrap all profiles.json read-modify-write paths (profile switch/use/remove,
status marking, token save, logout) in the existing dual-layer lock via a new
withProfilesLock helper. Split each writer into a public (locking) entry point
plus a lock-free *Locked variant so the non-reentrant lock is never re-acquired;
the refresh path (oauth_helpers) and the load-path legacy migration now call the
lock-free saver to avoid self-deadlock.

Also: write profiles.json and the token marker via per-write random temp names
(uuid) to stop concurrent writers from corrupting a fixed .tmp file; quarantine
an unparseable profiles.json and rebuild an empty config so the CLI can
self-heal instead of locking out auth reset/logout; make DeleteAllTokenData
proceed even if profiles.json cannot be read; and stop SyncLegacyTokenMirror
from deleting the legacy mirror on a transient keychain read error.

* fix(auth): do not fall back to a different org's legacy token slot

When no explicit --profile is given, LoadTokenDataForProfile resolves the
current/primary profile and reads its per-corp keychain slot. If that slot
read failed, the code silently fell through to the legacy single token slot,
which after any drift between the legacy mirror and the current profile could
belong to a different organization. The command would then run as the wrong
org with no indication to the user.

Reproduction (conceptual):
  - profiles.json currentProfile = corpA
  - corpA's keychain slot is unreadable, legacy single slot still holds corpB
  - any read command (no --profile) silently used corpB's token

Fix: when a profile is resolved but its slot read fails and no --profile was
given, only fall back to the legacy single slot when its CorpID matches the
resolved profile (same org); otherwise return the original error instead of
acting as a different organization. The no-profile legacy path (pre-migration
installs with no resolved profile) is unchanged.

Tests:
  - Covered by the existing internal/auth suite under go test -race; the
    same-org fallback preserves the legacy-mirror case while the cross-org
    case now surfaces the read error.

* feat(skill): document multi-org profile usage and always ship dws-shared

The skills had no guidance on the multi-profile capability, so an agent would
treat the CLI as single-org: when a lookup missed in the current org it would
give up or ask the user instead of searching other logged-in orgs. The multi
skill set also referenced a `dws-shared` prerequisite that was never actually
installed, and the only multi-org hints lived inline in three product skills.

This adds, in source only:
- A "multi-org / profile" section in the mono SKILL.md (concept, commands,
  cross-org rule, aggregation, safety guardrails) plus a decision-tree entry,
  trigger conditions, and a corrected logout danger-table row (logout removes
  all orgs by default; removing the primary silently re-elects a new primary,
  confirm before removing the primary).
- A standalone skills/multi/dingtalk-profile skill mirroring the same content.
- A new skills/multi/dws-shared skill that carries auth, global flags and the
  multi-org rule, so every product skill's PREREQUISITE resolves and all
  read/search skills inherit the cross-org behavior without per-skill edits.
- Cross-org fallback notes on dingtalk-aisearch / chat / contact.

To guarantee the prerequisite actually ships, multi-mode install now force-
includes dws-shared even when --skill / --exclude narrows the set (no-op when
the source has no dws-shared, preserving older layouts).

Tests:
  - internal/app: TestP1SharedAlwaysIncludedWithSkillFilter installs with
    `-s aitable` and asserts dws-shared still lands in the destination;
    TestP1SharedNoopWhenAbsent guards the older-layout no-op.
  - go test -race ./internal/auth/... ./internal/app/... passes.

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: qinze <audanye@gmail.com>
2026-06-29 18:27:25 +08:00

1149 lines
38 KiB
Go

// 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 (
"bufio"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"sync"
"unicode"
"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"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type ValueKind string
const (
ValueString ValueKind = "string"
ValueInt ValueKind = "int"
ValueFloat ValueKind = "float"
ValueBool ValueKind = "bool"
ValueStringSlice ValueKind = "string_slice"
ValueIntSlice ValueKind = "int_slice"
ValueFloatSlice ValueKind = "float_slice"
ValueBoolSlice ValueKind = "bool_slice"
ValueJSON ValueKind = "json"
)
type Target struct {
CanonicalProduct string
Tool string
}
type FlagBinding struct {
FlagName string
Alias string
// Aliases are additional hidden flag names that map to the same MCP
// parameter. Any of them being set satisfies Required, and the value
// is resolved via firstChangedFlag(FlagName, Alias, Aliases...).
// Mirrors cmdutil.ValidateRequiredFlagWithAliases / FlagOrFallback.
Aliases []string
// PipelineLocal, when true, marks this binding as CLI-side only — its
// value is consumed by the pipeline executor (e.g. as an HTTP
// download destination) and NOT forwarded to any MCP tool's params.
PipelineLocal bool
Short string
Property string
Kind ValueKind
Usage string
Required bool
// Default is the cobra-level flag default value as a string. Parsed
// into the Kind-appropriate primitive at registration time. Empty
// string keeps the existing zero-value default. This only affects
// what cobra renders in --help (the "(default ...)" suffix); it does
// NOT inject the value into MCP params on its own — CollectBindings
// still gates writes by user-changed flags via firstChangedFlag.
Default string
// Positional binds this parameter to a positional CLI argument rather
// than a --flag. PositionalIndex is the 0-based slot.
Positional bool
PositionalIndex int
}
type Normalizer func(cmd *cobra.Command, params map[string]any) error
type Route struct {
Use string
Aliases []string
Short string
Long string
Example string
Hidden bool
Target Target
Bindings []FlagBinding
// Pipeline, when non-empty, replaces the single-tool dispatch with a
// multi-step orchestration. NewDirectCommand sees this and wires the
// pipeline executor into RunE instead of the standard
// invoke-then-output flow. See internal/compat/pipeline.go.
Pipeline []market.PipelineStep
Normalizer Normalizer
// OutputTransform, when non-nil, post-processes the MCP response payload
// (rename / drop / columns) before the formatter emits it. Wired up from
// CLIToolOverride.OutputFormat. See discovery-schema-v3 §2.5.
OutputTransform func(map[string]any) map[string]any
// RejectPositional forces cobra.NoArgs on the generated leaf when no
// positional bindings exist, so stray positional tokens (e.g.
// `dws contact label list unexpected`) exit non-zero. Sourced from
// CLIToolOverride.RejectPositional. Ignored when the leaf already
// declares positional bindings — their arity validator wins.
RejectPositional bool
// RequireTogether groups flag aliases that must all be set together (or
// all left unset). Each inner slice produces one PreRunE cross-field
// check. Sourced from CLIToolOverride.RequireTogether. Unknown flag
// names are logged and skipped at command-build time (consistent with
// applyFlagConstraints semantics).
RequireTogether [][]string
}
type CommandFactory func(runner executor.Runner) *cobra.Command
var (
registryMu sync.Mutex
publicFactories []CommandFactory
fallbackFactories []CommandFactory
)
func RegisterPublic(factory CommandFactory) {
registryMu.Lock()
defer registryMu.Unlock()
publicFactories = append(publicFactories, factory)
}
func RegisterFallback(factory CommandFactory) {
registryMu.Lock()
defer registryMu.Unlock()
fallbackFactories = append(fallbackFactories, factory)
}
func NewPublicCommands(runner executor.Runner) []*cobra.Command {
return buildFactories(publicFactories, runner)
}
func NewFallbackCommands(runner executor.Runner) []*cobra.Command {
return buildFactories(fallbackFactories, runner)
}
// NewGroupCommand delegates to cobracmd.NewGroupCommand for backward compatibility.
var NewGroupCommand = cobracmd.NewGroupCommand
func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
// Compute positional arity. Two counts:
// - totalMax: the highest PositionalIndex+1 across all positional bindings
// (caps how many trailing args cobra accepts).
// - strictMin: the highest PositionalIndex+1 among "pure" positional
// bindings (no flag aliases). Backward-compat: any pure positional
// binding implies required arity at parse time, regardless of Required.
//
// Dual-mode positional bindings (positional + envelope-declared flag
// aliases, e.g. `{positional:true, alias:"query", aliases:["keyword"]}`)
// are counted into totalMax but excluded from strictMin so a flag-only
// invocation parses; their required-presence is enforced by
// validateRequiredPositionalBindings inside RunE.
//
// For positional bindings, buildOverrideBindings populates FlagName /
// Aliases only when the envelope explicitly declared them, so the
// dual-mode detection here is unambiguous.
strictMin := 0
totalMax := 0
for _, b := range route.Bindings {
if !b.Positional {
continue
}
if b.PositionalIndex+1 > totalMax {
totalMax = b.PositionalIndex + 1
}
hasFlagAlias := strings.TrimSpace(b.Alias) != "" || strings.TrimSpace(b.FlagName) != "" || len(b.Aliases) > 0
if !hasFlagAlias && b.PositionalIndex+1 > strictMin {
strictMin = b.PositionalIndex + 1
}
}
var argsValidator cobra.PositionalArgs = cobra.ArbitraryArgs
switch {
case totalMax == 0:
// No positional bindings: default to ArbitraryArgs unless the
// envelope explicitly asked for strict no-positional. We only
// honor RejectPositional in this branch because leaves with
// positional bindings already get a stricter validator below;
// flipping them to NoArgs would silently break valid invocations.
if route.RejectPositional {
argsValidator = cobra.NoArgs
} else {
argsValidator = cobra.ArbitraryArgs
}
case strictMin > 0 && strictMin == totalMax:
argsValidator = cobra.MinimumNArgs(strictMin)
case strictMin > 0:
argsValidator = cobra.RangeArgs(strictMin, totalMax)
default:
argsValidator = cobra.MaximumNArgs(totalMax)
}
// Extend Use with [<placeholder>] tokens for positional bindings so
// `--help` renders `cmd [arg1] [arg2] [flags]`, matching hardcoded
// helper commands' style (e.g. devdoc article search [keyword]).
use := route.Use
if totalMax > 0 {
ordered := make([]FlagBinding, 0, totalMax)
for _, b := range route.Bindings {
if b.Positional {
ordered = append(ordered, b)
}
}
sort.SliceStable(ordered, func(i, j int) bool {
return ordered[i].PositionalIndex < ordered[j].PositionalIndex
})
var sb strings.Builder
sb.WriteString(use)
for _, b := range ordered {
name := strings.TrimSpace(b.Property)
if name == "" {
name = strings.TrimSpace(b.FlagName)
}
if name == "" {
continue
}
sb.WriteString(" [")
sb.WriteString(name)
sb.WriteString("]")
}
use = sb.String()
}
cmd := &cobra.Command{
Use: use,
Aliases: append([]string(nil), route.Aliases...),
Short: route.Short,
Long: route.Long,
Example: route.Example,
Hidden: route.Hidden,
Args: argsValidator,
DisableAutoGenTag: true,
PreRunE: func(cmd *cobra.Command, args []string) error {
// Envelope-declared cross-field "must be set together" checks.
// Returns the first failing group so users see one actionable
// error per invocation (mirrors cobra's MarkFlagsOneRequired UX).
return validateRequireTogether(cmd, route.RequireTogether)
},
RunE: func(cmd *cobra.Command, args []string) error {
jsonPayload, err := cmd.Flags().GetString("json")
if err != nil {
return apperrors.NewInternal("failed to read --json")
}
paramsPayload, err := cmd.Flags().GetString("params")
if err != nil {
return apperrors.NewInternal("failed to read --params")
}
baseParams, err := executor.MergePayloads(jsonPayload, paramsPayload, nil)
if err != nil {
return err
}
bindingParams, err := CollectBindings(cmd, route.Bindings, baseParams)
if err != nil {
return err
}
params := baseParams
for key, value := range bindingParams {
params[key] = value
}
// Inject positional args into params according to each binding's
// PositionalIndex. Pure positional bindings are not registered as
// flags; dual-mode positional bindings (positional + alias) only
// fall through to positional injection when their flag aliases
// were not used (collectPositionalBindings skips when params
// already contains the property).
if err := collectPositionalBindings(args, route.Bindings, params); err != nil {
return err
}
// Collect schema-derived flags (from buildFlagsFromDetailSchema)
// that are not covered by explicit bindings.
collectSchemaFlags(cmd, route.Bindings, params)
// Required-presence check for positional bindings — must run after
// both flag (CollectBindings) and positional (collectPositionalBindings)
// have had a chance to populate params.
if err := validateRequiredPositionalBindings(cmd, route.Bindings, params); err != nil {
return err
}
if route.Normalizer != nil {
if err := route.Normalizer(cmd, params); err != nil {
return err
}
}
if blocked, _ := params["_blocked"].(bool); blocked {
// Interactive confirmation for destructive operations (consistent with Helper commands)
fmt.Fprintln(cmd.ErrOrStderr(), "⚠️ This is a destructive operation.")
fmt.Fprint(cmd.ErrOrStderr(), "Confirm? (yes/no): ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "yes" && answer != "y" {
fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled")
return nil
}
// User confirmed, continue execution
delete(params, "_blocked")
}
// §pipeline: when the override declares a multi-step pipeline,
// dispatch via the pipeline executor instead of the single-tool
// invoke-then-output flow. The executor reads flag values by
// alias (so $flag.<alias> templates resolve), walks each step,
// handles polling + downloads, and returns the last "call"
// step's response as the payload to the formatter.
if len(route.Pipeline) > 0 {
flagValues := extractFlagValuesByAlias(cmd, route.Bindings)
resp, err := runPipeline(cmd.Context(), cmd, runner, route, flagValues)
if err != nil {
return err
}
result := executor.Result{
Invocation: executor.NewCompatibilityInvocation(
cobracmd.LegacyCommandPath(cmd),
route.Target.CanonicalProduct,
"pipeline",
params,
),
Response: resp,
}
if route.OutputTransform != nil && result.Response != nil {
// Shape the MCP content payload (the actual data), not the
// {endpoint, content} runtime envelope, so rename/drop/table
// paths resolve against response fields (e.g. result.items)
// rather than the wrapper. Falls back to the whole Response
// when no content map is present (degraded/echo paths).
if content, ok := result.Response["content"].(map[string]any); ok {
result.Response["content"] = route.OutputTransform(content)
} else {
result.Response = route.OutputTransform(result.Response)
}
}
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
}
invocation := executor.NewCompatibilityInvocation(
cobracmd.LegacyCommandPath(cmd),
route.Target.CanonicalProduct,
route.Target.Tool,
params,
)
if dryRun, _ := cmd.Root().PersistentFlags().GetBool("dry-run"); dryRun {
invocation.DryRun = true
}
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
if route.OutputTransform != nil && result.Response != nil {
result.Response = route.OutputTransform(result.Response)
}
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
},
}
ApplyBindings(cmd, route.Bindings)
return cmd
}
// NewCuratedCommand creates a DirectCommand with override priority so it wins
// over auto-generated MCP overlay commands during command tree merging.
func NewCuratedCommand(route Route, runner executor.Runner) *cobra.Command {
cmd := NewDirectCommand(route, runner)
cli.SetOverridePriority(cmd, 100)
return cmd
}
// parseFlagDefault converts a string-form envelope default into the typed
// primitives used by pflag's *P helpers. Unparseable values silently fall
// back to the type's zero value so a malformed envelope downgrades to
// "no default in --help" rather than a panic at startup. The slice form
// splits on commas and trims whitespace, mirroring pflag.StringSlice
// behavior; empty/whitespace-only segments are dropped.
func parseFlagDefault(kind ValueKind, raw string) (defStr string, defInt int, defFloat float64, defBool bool, defSlice []string) {
trimmed := strings.TrimSpace(raw)
switch kind {
case ValueString, ValueJSON:
// Preserve raw (not trimmed) so explicitly-padded defaults survive.
defStr = raw
case ValueInt:
if trimmed != "" {
if v, err := strconv.Atoi(trimmed); err == nil {
defInt = v
}
}
case ValueFloat:
if trimmed != "" {
if v, err := strconv.ParseFloat(trimmed, 64); err == nil {
defFloat = v
}
}
case ValueBool:
if trimmed != "" {
if v, err := strconv.ParseBool(trimmed); err == nil {
defBool = v
}
}
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
if trimmed != "" {
for _, p := range strings.Split(trimmed, ",") {
if t := strings.TrimSpace(p); t != "" {
defSlice = append(defSlice, t)
}
}
}
}
return
}
// canRegisterFlag reports whether a long flag named name can be registered
// on cmd without panicking pflag ("flag redefined"). The reserved payload
// names are excluded too: ApplyBindings unconditionally registers hidden
// --json/--params after the bindings loop. The envelope is remote data —
// a duplicate or reserved name there must degrade to "flag unavailable",
// never abort the process.
func canRegisterFlag(cmd *cobra.Command, name string) bool {
if name == "" || name == "json" || name == "params" {
return false
}
return cmd.Flags().Lookup(name) == nil
}
// safeShorthand returns short when it is a single-character shorthand not
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
// pflag panics on both multi-character and duplicate shorthands.
func safeShorthand(cmd *cobra.Command, short string) string {
short = strings.TrimSpace(short)
if len(short) != 1 {
return ""
}
if cmd.Flags().ShorthandLookup(short) != nil {
return ""
}
return short
}
func ApplyBindings(cmd *cobra.Command, bindings []FlagBinding) {
for _, binding := range bindings {
// Positional bindings are collected from cobra args rather than flags.
// Exception: dual-mode bindings (positional + envelope-declared flag
// aliases) also register the aliases so users can pass either
// `cmd VALUE` or `cmd --primary VALUE`. Required-presence is enforced
// later by validateRequiredPositionalBindings instead of MarkFlagRequired.
if binding.Positional {
primary := strings.TrimSpace(binding.FlagName)
alias := strings.TrimSpace(binding.Alias)
if primary == "" && alias == "" && len(binding.Aliases) == 0 {
continue
}
registerPositionalAliasFlags(cmd, binding)
continue
}
primary := strings.TrimSpace(binding.FlagName)
if primary == "" {
continue
}
alias := strings.TrimSpace(binding.Alias)
if alias == primary {
alias = ""
}
// Dedupe extra aliases against primary + single alias and each other.
var extras []string
if len(binding.Aliases) > 0 {
seen := map[string]bool{primary: true, "json": true, "params": true}
if alias != "" {
seen[alias] = true
}
extras = make([]string, 0, len(binding.Aliases))
for _, a := range binding.Aliases {
a = strings.TrimSpace(a)
if a == "" || seen[a] {
continue
}
seen[a] = true
extras = append(extras, a)
}
}
// Parse binding.Default once per binding into Kind-typed values used
// by both the primary and hidden-alias registrations below. Hidden
// aliases share the same default so users typing the legacy alias
// see consistent --help text and zero-value behavior.
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
registerHidden := func(name string, suffix string) {
if !canRegisterFlag(cmd, name) {
return
}
switch binding.Kind {
case ValueString:
cmd.Flags().String(name, defStr, binding.Usage+suffix)
case ValueInt:
cmd.Flags().Int(name, defInt, binding.Usage+suffix)
case ValueFloat:
cmd.Flags().Float64(name, defFloat, binding.Usage+suffix)
case ValueBool:
cmd.Flags().Bool(name, defBool, binding.Usage+suffix)
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
cmd.Flags().StringSlice(name, defSlice, binding.Usage+suffix)
case ValueJSON:
cmd.Flags().String(name, defStr, binding.Usage+suffix)
}
_ = cmd.Flags().MarkHidden(name)
}
if !canRegisterFlag(cmd, primary) {
// Duplicate or reserved primary name in the envelope. Skip the
// whole binding: CollectBindings tolerates the missing flag
// (Lookup → nil → continue) and the value can still be supplied
// via the --params payload.
continue
}
short := safeShorthand(cmd, binding.Short)
switch binding.Kind {
case ValueString:
cmd.Flags().StringP(primary, short, defStr, binding.Usage)
case ValueInt:
cmd.Flags().IntP(primary, short, defInt, binding.Usage)
case ValueFloat:
cmd.Flags().Float64P(primary, short, defFloat, binding.Usage)
case ValueBool:
cmd.Flags().BoolP(primary, short, defBool, binding.Usage)
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
cmd.Flags().StringSliceP(primary, short, defSlice, binding.Usage)
case ValueJSON:
cmd.Flags().StringP(primary, short, defStr, binding.Usage+" (JSON)")
}
registerHidden(alias, " (alias)")
for _, extra := range extras {
registerHidden(extra, " (alias)")
}
if binding.Required {
// When no hidden aliases exist, lean on cobra's native required
// validation for the best UX (colored error, shown in --help).
// When aliases exist, CollectBindings does its own "any-of-these
// is set" check so users who type the hidden alias do not hit
// cobra yelling about the primary being missing.
if alias == "" && len(extras) == 0 {
_ = cmd.MarkFlagRequired(primary)
}
}
}
if cmd.Flags().Lookup("json") == nil {
cmd.Flags().String("json", "", "Base JSON object payload for this command")
}
if cmd.Flags().Lookup("params") == nil {
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
}
_ = cmd.Flags().MarkHidden("json")
_ = cmd.Flags().MarkHidden("params")
}
// registerPositionalAliasFlags registers the visible primary flag and any
// hidden aliases for a "dual-mode" positional binding (envelope:
// `{positional:true, alias:"X", aliases:["Y"]}`). Required-presence is
// intentionally deferred to validateRequiredPositionalBindings — cobra's
// MarkFlagRequired would yell even when the user supplied the value as a
// positional arg.
func registerPositionalAliasFlags(cmd *cobra.Command, binding FlagBinding) {
primary := strings.TrimSpace(binding.FlagName)
alias := strings.TrimSpace(binding.Alias)
if alias == primary {
alias = ""
}
// Dedupe extras against primary + alias and reserved internal names.
seen := map[string]bool{"json": true, "params": true}
if primary != "" {
seen[primary] = true
}
if alias != "" {
seen[alias] = true
}
extras := make([]string, 0, len(binding.Aliases))
for _, a := range binding.Aliases {
a = strings.TrimSpace(a)
if a == "" || seen[a] {
continue
}
seen[a] = true
extras = append(extras, a)
}
defStr, defInt, defFloat, defBool, defSlice := parseFlagDefault(binding.Kind, binding.Default)
register := func(name string, withShort bool, hidden bool, usageSuffix string) {
if !canRegisterFlag(cmd, name) {
return
}
short := ""
if withShort {
short = safeShorthand(cmd, binding.Short)
}
usage := binding.Usage + usageSuffix
switch binding.Kind {
case ValueString:
cmd.Flags().StringP(name, short, defStr, usage)
case ValueInt:
cmd.Flags().IntP(name, short, defInt, usage)
case ValueFloat:
cmd.Flags().Float64P(name, short, defFloat, usage)
case ValueBool:
cmd.Flags().BoolP(name, short, defBool, usage)
case ValueStringSlice, ValueIntSlice, ValueFloatSlice, ValueBoolSlice:
cmd.Flags().StringSliceP(name, short, defSlice, usage)
case ValueJSON:
cmd.Flags().StringP(name, short, defStr, usage+" (JSON)")
default:
cmd.Flags().StringP(name, short, defStr, usage)
}
if hidden {
_ = cmd.Flags().MarkHidden(name)
}
}
register(primary, true, false, "")
register(alias, false, true, " (alias)")
for _, e := range extras {
register(e, false, true, " (alias)")
}
}
// collectPositionalBindings pulls positional args according to the bindings
// and injects them into params[property]. Missing slots are skipped (cobra
// arity validation already ran before RunE).
func collectPositionalBindings(args []string, bindings []FlagBinding, params map[string]any) error {
for _, binding := range bindings {
if !binding.Positional {
continue
}
property := strings.TrimSpace(binding.Property)
if property == "" {
continue
}
// Dual-mode positional: if the user already provided the value via a
// flag alias (CollectBindings wrote it), honor flag > positional.
if _, ok := params[property]; ok {
continue
}
if binding.PositionalIndex < 0 || binding.PositionalIndex >= len(args) {
continue
}
raw := args[binding.PositionalIndex]
switch binding.Kind {
case ValueInt:
v, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be int", binding.PositionalIndex, property))
}
params[property] = v
case ValueFloat:
v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be float", binding.PositionalIndex, property))
}
params[property] = v
case ValueBool:
v, err := strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
return apperrors.NewValidation(fmt.Sprintf("positional argument %d (%s) must be bool", binding.PositionalIndex, property))
}
params[property] = v
default:
params[property] = raw
}
}
return nil
}
// validateRequiredPositionalBindings enforces required-presence for positional
// bindings whose original envelope spec set required=true. The arity validator
// for dual-mode positionals is intentionally relaxed (MaximumNArgs / RangeArgs
// excluding the dual slot) so a flag-only invocation is permitted; this check
// closes the loop by rejecting the case where neither the positional arg nor
// any flag alias was supplied.
func validateRequiredPositionalBindings(cmd *cobra.Command, bindings []FlagBinding, params map[string]any) error {
for _, binding := range bindings {
if !binding.Positional || !binding.Required {
continue
}
property := strings.TrimSpace(binding.Property)
if property == "" {
continue
}
if v, ok := params[property]; ok {
if s, isStr := v.(string); !isStr || strings.TrimSpace(s) != "" {
continue
}
}
// Compose candidate flag names so the error message points users at
// the first writable label even for flag-only invocations.
primary := strings.TrimSpace(binding.FlagName)
alias := strings.TrimSpace(binding.Alias)
if _, changed := firstChangedFlag(cmd, append([]string{primary, alias}, binding.Aliases...)...); changed {
continue
}
display := primary
if display == "" {
display = alias
}
if display == "" && len(binding.Aliases) > 0 {
display = binding.Aliases[0]
}
if display == "" {
return apperrors.NewValidation(fmt.Sprintf("positional argument <%s> is required", property))
}
return apperrors.NewValidation(fmt.Sprintf("--%s (or positional <%s>) is required", display, property))
}
return nil
}
// collectSchemaFlags picks up flags created by buildFlagsFromDetailSchema that
// have no explicit FlagBinding. This bridges the gap for plugin-defined tools
// whose parameters come from the MCP inputSchema rather than CLIToolOverride.Flags.
func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[string]any) {
// Build a set of flag names already covered by bindings.
bound := make(map[string]bool, len(bindings)*2)
for _, b := range bindings {
if n := strings.TrimSpace(b.FlagName); n != "" {
bound[n] = true
}
if a := strings.TrimSpace(b.Alias); a != "" {
bound[a] = true
}
for _, extra := range b.Aliases {
if e := strings.TrimSpace(extra); e != "" {
bound[e] = true
}
}
}
// Reserved/internal flags that should never be forwarded as tool params.
skip := map[string]bool{
"json": true, "params": true, "help": true,
"format": true, "fields": true, "jq": true,
"debug": true, "verbose": true, "dry-run": true,
"yes": true, "mock": true, "profile": true, "timeout": true,
"client-id": true, "client-secret": true,
}
cmd.Flags().Visit(func(f *pflag.Flag) {
if bound[f.Name] || skip[f.Name] {
return
}
// Convert flag name back to the original parameter name (kebab → snake/camel)
// For simplicity, use the flag name as-is since MCP tools typically
// use snake_case which maps to kebab-case flags.
paramName := toOriginalParamName(f.Name)
if _, exists := params[paramName]; exists {
return // already set by --json/--params
}
switch f.Value.Type() {
case "int":
if v, err := cmd.Flags().GetInt(f.Name); err == nil {
params[paramName] = v
}
case "bool":
if v, err := cmd.Flags().GetBool(f.Name); err == nil {
params[paramName] = v
}
case "stringSlice":
if v, err := cmd.Flags().GetStringSlice(f.Name); err == nil {
params[paramName] = v
}
default:
if v, err := cmd.Flags().GetString(f.Name); err == nil {
params[paramName] = v
}
}
})
}
// toOriginalParamName converts a kebab-case flag name back to the original
// MCP parameter name. Since toKebabCase converts both camelCase and snake_case
// to kebab-case, we default to snake_case (the MCP convention).
func toOriginalParamName(flagName string) string {
return strings.ReplaceAll(flagName, "-", "_")
}
// firstChangedFlag returns the first name (in order) whose cobra flag has
// been set by the user. Whitespace-only or empty entries are skipped.
// Mirrors wukong cmdutil.FlagOrFallback precedence: primary > alias >
// extraAliases in declaration order.
func firstChangedFlag(cmd *cobra.Command, names ...string) (name string, changed bool) {
for _, n := range names {
n = strings.TrimSpace(n)
if n == "" {
continue
}
if cobracmd.FlagChanged(cmd, n) {
return n, true
}
}
return "", false
}
func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[string]any) (map[string]any, error) {
if existing == nil {
existing = map[string]any{}
}
params := make(map[string]any)
for _, binding := range bindings {
// Pipeline-local flags exist purely for the pipeline executor
// (e.g. --output destination paths) and must never be forwarded
// to MCP tools as params, otherwise the upstream API would
// either reject the unknown field or silently store junk.
if binding.PipelineLocal {
continue
}
if binding.Positional {
// Pure positional (no flag aliases) is handled by
// collectPositionalBindings. Dual-mode positional bindings
// (envelope: positional + alias/aliases) fall through so any
// user-supplied flag value wins over the positional arg.
primary := strings.TrimSpace(binding.FlagName)
alias := strings.TrimSpace(binding.Alias)
if primary == "" && alias == "" && len(binding.Aliases) == 0 {
continue
}
}
primaryName := strings.TrimSpace(binding.FlagName)
if primaryName == "" {
continue
}
aliasName := strings.TrimSpace(binding.Alias)
// Candidate flag names in precedence order: primary, single alias,
// then extra aliases. Whichever is set first wins; mirrors the
// semantics of cmdutil.FlagOrFallback.
candidates := make([]string, 0, 2+len(binding.Aliases))
candidates = append(candidates, primaryName)
if aliasName != "" && aliasName != primaryName {
candidates = append(candidates, aliasName)
}
for _, extra := range binding.Aliases {
e := strings.TrimSpace(extra)
if e == "" || e == primaryName || e == aliasName {
continue
}
candidates = append(candidates, e)
}
flagName, anyChanged := firstChangedFlag(cmd, candidates...)
if !anyChanged {
flagName = primaryName
}
flag := cmd.Flags().Lookup(flagName)
if flag == nil {
continue
}
if binding.Required && !anyChanged && !binding.Positional {
if _, ok := existing[binding.Property]; ok {
continue
}
return nil, apperrors.NewValidation(fmt.Sprintf("missing required flag: --%s is required", primaryName))
}
if !anyChanged {
continue
}
switch binding.Kind {
case ValueString:
value, err := cmd.Flags().GetString(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
params[binding.Property] = value
case ValueJSON:
value, err := cmd.Flags().GetString(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
var parsed any
if jsonErr := json.Unmarshal([]byte(value), &parsed); jsonErr != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("invalid JSON for --%s: %v", flagName, jsonErr))
}
params[binding.Property] = parsed
case ValueInt:
value, err := cmd.Flags().GetInt(flagName)
if err != nil {
// Flag may be wrapped by typedValue (display-only override);
// fall back to parsing the raw string representation.
raw := flag.Value.String()
parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw))
if parseErr != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
params[binding.Property] = parsed
continue
}
params[binding.Property] = value
case ValueFloat:
value, err := cmd.Flags().GetFloat64(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
params[binding.Property] = value
case ValueBool:
value, err := cmd.Flags().GetBool(flagName)
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
params[binding.Property] = value
case ValueStringSlice:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
// Flag may be wrapped by typedValue (display-only override);
// fall back to reading the raw string and splitting by comma.
raw := strings.TrimSpace(flag.Value.String())
// pflag StringSlice wraps values in [brackets]
raw = strings.TrimPrefix(raw, "[")
raw = strings.TrimSuffix(raw, "]")
var parts []string
for _, s := range strings.Split(raw, ",") {
t := strings.TrimSpace(s)
// pflag StringSlice internally quotes each element
t = strings.Trim(t, "\"")
t = strings.TrimSpace(t)
if t != "" {
parts = append(parts, t)
}
}
params[binding.Property] = convert.StringsToAny(parts)
continue
}
params[binding.Property] = convert.StringsToAny(value)
case ValueIntSlice:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
// Fallback: parse raw string
raw := strings.TrimSpace(flag.Value.String())
raw = strings.TrimPrefix(raw, "[")
raw = strings.TrimSuffix(raw, "]")
value = nil
for _, s := range strings.Split(raw, ",") {
if t := strings.TrimSpace(s); t != "" {
value = append(value, t)
}
}
}
parsed, parseErr := convert.ParseStringList(value, strconv.Atoi)
if parseErr != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
}
params[binding.Property] = convert.IntsToAny(parsed)
case ValueFloatSlice:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
// Fallback: parse raw string
raw := strings.TrimSpace(flag.Value.String())
raw = strings.TrimPrefix(raw, "[")
raw = strings.TrimSuffix(raw, "]")
value = nil
for _, s := range strings.Split(raw, ",") {
if t := strings.TrimSpace(s); t != "" {
value = append(value, t)
}
}
_ = err // clear error after fallback
}
parsed, parseErr := convert.ParseStringList(value, func(raw string) (float64, error) {
return strconv.ParseFloat(raw, 64)
})
if parseErr != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
}
params[binding.Property] = convert.FloatsToAny(parsed)
case ValueBoolSlice:
value, err := cmd.Flags().GetStringSlice(flagName)
if err != nil {
// Fallback: parse raw string
raw := strings.TrimSpace(flag.Value.String())
raw = strings.TrimPrefix(raw, "[")
raw = strings.TrimSuffix(raw, "]")
value = nil
for _, s := range strings.Split(raw, ",") {
if t := strings.TrimSpace(s); t != "" {
value = append(value, t)
}
}
}
parsed, parseErr := convert.ParseStringList(value, strconv.ParseBool)
if parseErr != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr))
}
params[binding.Property] = convert.BoolsToAny(parsed)
}
}
return params, nil
}
func MustString(params map[string]any, key string) (string, error) {
value, ok := params[key]
if !ok {
return "", apperrors.NewValidation(fmt.Sprintf("%s is required", key))
}
raw, ok := value.(string)
if !ok || strings.TrimSpace(raw) == "" {
return "", apperrors.NewValidation(fmt.Sprintf("%s must be a non-empty string", key))
}
return raw, nil
}
func MoveParam(params map[string]any, from, to string) {
value, ok := params[from]
if !ok {
return
}
delete(params, from)
params[to] = value
}
func buildFactories(factories []CommandFactory, runner executor.Runner) []*cobra.Command {
registryMu.Lock()
defer registryMu.Unlock()
out := make([]*cobra.Command, 0, len(factories))
for _, factory := range factories {
out = append(out, factory(runner))
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].Use < out[j].Use
})
return mergeRootCommands(out)
}
func mergeRootCommands(commands []*cobra.Command) []*cobra.Command {
byName := make(map[string]*cobra.Command, len(commands))
for _, cmd := range commands {
if cmd == nil || cmd.Name() == "" {
continue
}
if existing, ok := byName[cmd.Name()]; ok {
cobracmd.MergeCommandTree(existing, cmd)
continue
}
byName[cmd.Name()] = cmd
}
out := make([]*cobra.Command, 0, len(byName))
for _, cmd := range byName {
out = append(out, cmd)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Use < out[j].Use
})
return out
}
// requireYesForDelete enforces --yes confirmation for destructive operations.
// If the user has not passed --yes, the command is blocked (params["_blocked"] = true).
func requireYesForDelete(cmd *cobra.Command, params map[string]any) error {
yes, _ := cmd.Flags().GetBool("yes")
delete(params, "_yes")
if !yes {
params["_blocked"] = true
return nil
}
return nil
}
// compatFlagName converts a camelCase or snake_case parameter name to
// kebab-case suitable for CLI flags.
func compatFlagName(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var builder strings.Builder
lastDash := false
for idx, r := range raw {
switch {
case r == '_' || r == ' ' || r == '-':
if builder.Len() > 0 && !lastDash {
builder.WriteByte('-')
lastDash = true
}
case unicode.IsUpper(r):
if idx > 0 && !lastDash {
builder.WriteByte('-')
}
builder.WriteRune(unicode.ToLower(r))
lastDash = false
default:
builder.WriteRune(unicode.ToLower(r))
lastDash = false
}
}
return strings.Trim(builder.String(), "-")
}
// validateRequireTogether enforces "either all set, or all unset" semantics
// for each group of flag aliases. Returns a validation error pointing at the
// first failing group; nil if every group satisfies the check or no groups
// were declared. Unknown flag names in a group are skipped silently — the
// envelope load path already warned about them when applyFlagConstraints
// validated the same shape for MutuallyExclusive / RequireOneOf.
func validateRequireTogether(cmd *cobra.Command, groups [][]string) error {
for _, group := range groups {
set := make([]string, 0, len(group))
unset := make([]string, 0, len(group))
for _, raw := range group {
name := strings.TrimSpace(raw)
if name == "" {
continue
}
if cmd.Flags().Lookup(name) == nil {
continue
}
if cobracmd.FlagChanged(cmd, name) {
set = append(set, name)
} else {
unset = append(unset, name)
}
}
if len(set) == 0 || len(unset) == 0 {
continue
}
// Render a stable, human-readable list of the group's flag names so
// the error matches what the user typed. Example output:
// --start 和 --end 必须同时设置或同时不设置
return apperrors.NewValidation(fmt.Sprintf(
"--%s 和 --%s 必须同时设置或同时不设置",
strings.Join(set, " --"),
strings.Join(unset, " --"),
))
}
return nil
}