- SchemaDecl on CommandSpec/LeafSpec declares the final ToolSpec payload; framework converts in-process (no JSON bridge) and Schema assembly pass-throughs it. - Assembly fails closed on declared identity mismatched with the bound entry and on reviewed fields in the declaration payload. - RFC/homology/AGENTS docs pin declare=final-source, safety precedence Final > Risk > gate, and light runtime write semantics. Co-authored-by: Cursor <cursoragent@cursor.com>
1058 lines
37 KiB
Go
1058 lines
37 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 cmdcore is the shared, dispatch-agnostic base for building leaf
|
||
// commands. It concentrates flag registration, the alias/env/default effective
|
||
// value fallback chain, required validation, cross-flag constraint declaration
|
||
// checks + runtime enforcement, Risk-driven write confirmation, toolArgs
|
||
// assembly, and Agent Runtime Schema projection.
|
||
//
|
||
// Declaration vs execution (framework rule):
|
||
//
|
||
// - Declare = CommandSpec data fields (Flags, Constraints, non-empty Risk,
|
||
// ConstParams, Use/Short/Long/Example). NewCommand registers, validates,
|
||
// confirms, and embeds those facts into dws.schema.*.
|
||
// - Execute = Validate / Invoke / Orchestrate / RunE / PostMount. Hooks
|
||
// consume assembled args; they must not invent the CLI surface.
|
||
// - Annotate = explicit cobra annotations when a fact is not (yet) a Contract
|
||
// field (e.g. write-guard runtime_gate). Inference-only Schema/help is
|
||
// forbidden.
|
||
// - Reviewed non-Contract Schema (identity, selection, interface_*, dry-run,
|
||
// idempotency) has its own sources and must not create CLI flags.
|
||
//
|
||
// Full ToolSpec field authority: RFC §5.0.4 / homology §1.4.
|
||
//
|
||
// It is deliberately dispatch-agnostic: it never calls an MCP tool. The
|
||
// LeafSpec framework (internal/helpers) and, later, the Shortcut framework wrap
|
||
// these primitives and supply their own dispatch (single-step MCP / multi-step
|
||
// orchestration / escape hatch). Extracting the primitives here lets both
|
||
// frameworks share one flag + constraint + risk + schema base, differing only
|
||
// in how they dispatch — the first step toward a single typed command registry.
|
||
//
|
||
// Behavioral contract: this package is a pure extraction of the logic that
|
||
// previously lived in internal/helpers/leaf.go, so flag registration, value
|
||
// fallback, required/constraint semantics, risk wording, and schema projection
|
||
// stay semantically identical. The evidence is split: check-generated-drift
|
||
// (catalog unchanged) proves only the build-time projection — identity, flags,
|
||
// help and annotations — while the runtime pipeline (required validation,
|
||
// toolArgs assembly, write confirmation, dispatch order) is evidenced solely by
|
||
// this package's own tests plus the leaf/risk/constraint unit tests.
|
||
package cmdcore
|
||
|
||
import (
|
||
"bufio"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/spf13/cobra"
|
||
|
||
"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/pkg/cmdutil"
|
||
)
|
||
|
||
// FlagKind is the value type of a flag.
|
||
type FlagKind int
|
||
|
||
const (
|
||
// KindString is a string flag (default).
|
||
KindString FlagKind = iota
|
||
// KindInt is an integer flag (registered as cobra Int); it enters toolArgs
|
||
// only when the value is non-zero, matching the handwritten "putInt only
|
||
// when non-zero" semantics (e.g. devapp app-group-id).
|
||
KindInt
|
||
// KindBool is a boolean flag (registered as cobra Bool); it enters toolArgs
|
||
// only when the user explicitly provided it (Changed), matching the
|
||
// handwritten "transmit on Changed, explicit false is still sent" semantics.
|
||
// Booleans do not participate in the alias/env fallback chain.
|
||
KindBool
|
||
// KindStringSlice is a string-list flag (registered as cobra StringSlice);
|
||
// it enters toolArgs only when a non-empty element exists, elements are
|
||
// always TrimSpace'd and empties dropped.
|
||
KindStringSlice
|
||
)
|
||
|
||
// FlagSpec declares how a flag is registered and bound into MCP toolArgs. Its
|
||
// fields intentionally mirror the former helpers.LeafFlag one-for-one so that
|
||
// helpers can alias to it without touching any call site.
|
||
type FlagSpec struct {
|
||
Name string // flag name (kebab-case)
|
||
Usage string // registration usage text
|
||
Kind FlagKind // value type, defaults to KindString
|
||
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
|
||
|
||
// Required, when true, validates a non-empty effective value in RunE. Plain
|
||
// Required flags aggregate into a cmdutil.ValidateRequiredFlags-compatible
|
||
// error; when EnvVar is configured the env var is a fallback and, still
|
||
// empty, RequiredHint (or a default hint) is reported.
|
||
Required bool
|
||
RequiredHint string
|
||
// MarkRequired, when true, calls cobra MarkFlagRequired (the hard floor for
|
||
// the catalog required projection); cobra errors before RunE.
|
||
MarkRequired bool
|
||
|
||
Aliases []string // hidden aliases, registered with the main flag's Kind; used in order when the main flag is not explicitly provided
|
||
EnvVar string // environment variable consulted when the effective value is empty (an integer flag's env value must be parseable)
|
||
// ArgDefault covers the case where the registration default is empty but
|
||
// toolArgs still needs a fallback. For KindString it is used when the
|
||
// effective value is empty. For KindInt it is also the floor: when the
|
||
// resolved integer is < 1, ArgDefault is emitted instead (cursor page-size
|
||
// semantics).
|
||
ArgDefault string
|
||
// Bind is the toolArgs key; empty uses Name.
|
||
Bind string
|
||
// Transform converts a string effective value into the arg value; nil sends
|
||
// it as-is. Returning (nil, nil) skips the key (for "nullable numeric: skip
|
||
// on empty or parse failure" semantics).
|
||
Transform func(raw string) (any, error)
|
||
// OmitEmpty, when true, drops an empty effective value from toolArgs (KindInt
|
||
// is always "non-zero only" and ignores this field).
|
||
OmitEmpty bool
|
||
// Trim, when true, TrimSpace's the effective value (main flag/alias/env
|
||
// alike) and makes a whitespace-only value count as empty in required checks.
|
||
Trim bool
|
||
|
||
// Schema parameter final facts (embedded to dws.schema.*; assembly pass-through).
|
||
Enum []string // accepted values
|
||
Format string // machine-readable format (e.g. uri)
|
||
Example string // representative CLI value
|
||
RequiredWhen string // conditional required expression (descriptive)
|
||
SchemaDescription string // Schema description; empty uses Usage
|
||
}
|
||
|
||
// Risk declares a leaf command's side-effect level, driving pre-dispatch write
|
||
// confirmation. Values match the shortcut framework's Risk verbatim.
|
||
type Risk string
|
||
|
||
const (
|
||
// RiskRead is a read-only operation; never prompts. Empty value == RiskRead.
|
||
RiskRead Risk = "read"
|
||
// RiskWrite mutates state; prompts unless --yes.
|
||
RiskWrite Risk = "write"
|
||
// RiskHighWrite is a destructive/irreversible operation; prompts unless --yes.
|
||
RiskHighWrite Risk = "high-risk-write"
|
||
)
|
||
|
||
// Effective returns the effective risk, defaulting an empty value to read-only.
|
||
func (r Risk) Effective() Risk {
|
||
if r == "" {
|
||
return RiskRead
|
||
}
|
||
return r
|
||
}
|
||
|
||
// ConstraintKind is the type of a cross-flag relationship constraint. Values
|
||
// match the shortcut framework's ConstraintKind verbatim.
|
||
type ConstraintKind string
|
||
|
||
const (
|
||
// AtLeastOne requires at least one of Flags to be provided.
|
||
AtLeastOne ConstraintKind = "at_least_one"
|
||
// ExactlyOne requires exactly one of Flags to be provided.
|
||
ExactlyOne ConstraintKind = "exactly_one"
|
||
// MutuallyExclusive allows at most one of Flags.
|
||
MutuallyExclusive ConstraintKind = "mutually_exclusive"
|
||
)
|
||
|
||
// Constraint declares a relationship over a group of flags. It is enforced
|
||
// after required validation and before the framework's Validate hook;
|
||
// "provided" reuses the effective-value fallback chain (explicit main flag →
|
||
// alias → env), so passing a compatible alias counts as provided — a capability
|
||
// the shortcut framework's bare Changed check lacks. The constraint is also
|
||
// projected into the Agent Runtime Schema (mutually_exclusive / require_one_of)
|
||
// and rendered into the --help "参数约束" section.
|
||
type Constraint struct {
|
||
Kind ConstraintKind
|
||
Flags []string
|
||
// Description, when non-empty, replaces the constraint's default help text.
|
||
Description string
|
||
}
|
||
|
||
// CommandSpec is the single typed definition of a leaf command, shared by the
|
||
// LeafSpec and (via FromShortcut) Shortcut frameworks.
|
||
//
|
||
// Declaration surface is the final Schema data source for managed leaves:
|
||
//
|
||
// Flags (+ parameter Schema fields), Constraints, Risk, ConstParams,
|
||
// Use/Short/Long/Example, Schema (ToolSpec groups)
|
||
//
|
||
// Schema assembly pass-throughs embedded dws.schema.* — no reviewed/hints
|
||
// parallel authority for declared fields. Empty Risk means runtime read
|
||
// confirmation (no prompt) and does NOT embed dws.schema.risk unless
|
||
// Schema.Safety supplies risk/confirmation.
|
||
//
|
||
// Execution surface (hooks — not declaration):
|
||
//
|
||
// - RunE — full escape hatch: the framework only registers flags/constraints/
|
||
// help and hands control over.
|
||
// - Invoke — single-step: runs after required/constraint/Validate checks, args
|
||
// assembly and the Risk write-confirmation, receiving the assembled toolArgs.
|
||
// - Orchestrate — multi-step: runs after the same checks and confirmation but
|
||
// receives only the Ctx, so it can chain several backend calls itself.
|
||
// - Validate / PostMount — orchestration only; must not register business flags
|
||
// or assemble business params that belong in Flags/ConstParams.
|
||
//
|
||
// Exactly one of RunE / Invoke / Orchestrate must be set; NewCommand validates
|
||
// this at construction time. cmdcore itself stays dispatch-agnostic and never
|
||
// calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the body.
|
||
type CommandSpec struct {
|
||
Use string
|
||
Short string
|
||
Long string
|
||
Example string
|
||
|
||
Flags []FlagSpec
|
||
Constraints []Constraint
|
||
Risk Risk
|
||
// ConstParams are fixed toolArgs merged after flag assembly (e.g. precheckOnly).
|
||
// They are payload declaration, not user flags, and never satisfy Required.
|
||
ConstParams map[string]any
|
||
// Schema is the final ToolSpec payload (identity/selection/safety/…).
|
||
// When non-empty, embed marks the leaf for Schema pass-through.
|
||
Schema SchemaDecl
|
||
|
||
// Validate is the cross-flag validation hook, run after required/constraint
|
||
// checks and before args assembly; nil skips it. Not a declaration surface.
|
||
Validate func(cmd *cobra.Command, args []string) error
|
||
// PostMount adjusts the built command after flag registration and before
|
||
// RunE is set (Args/DisableAutoGenTag/annotate/…); always runs. Business
|
||
// flags belong in Flags, not here.
|
||
PostMount func(cmd *cobra.Command)
|
||
// RunE fully replaces the generated body (escape hatch).
|
||
RunE func(cmd *cobra.Command, args []string) error
|
||
// Invoke executes a single-step command with the assembled toolArgs.
|
||
Invoke func(c *Ctx, toolArgs map[string]any) error
|
||
// Orchestrate executes a multi-step command; it assembles whatever payloads
|
||
// it needs from the Ctx.
|
||
Orchestrate func(c *Ctx) error
|
||
}
|
||
|
||
// Ctx is the framework-neutral execution context handed to Invoke/Orchestrate.
|
||
// It deliberately knows nothing about MCP or any other backend: it exposes the
|
||
// command, its positional args, and typed flag access that reuses the declared
|
||
// alias → env → default fallback chain, so a consumer reading a flag through Ctx
|
||
// gets exactly the value the required/constraint checks saw.
|
||
type Ctx struct {
|
||
cmd *cobra.Command
|
||
args []string
|
||
flags map[string]FlagSpec
|
||
}
|
||
|
||
// newCtx builds the execution context for one command invocation.
|
||
func newCtx(cmd *cobra.Command, args []string, flags []FlagSpec) *Ctx {
|
||
byName := make(map[string]FlagSpec, len(flags))
|
||
for _, flag := range flags {
|
||
byName[flag.Name] = flag
|
||
}
|
||
return &Ctx{cmd: cmd, args: args, flags: byName}
|
||
}
|
||
|
||
// Command returns the running cobra command.
|
||
func (c *Ctx) Command() *cobra.Command { return c.cmd }
|
||
|
||
// Args returns the positional arguments.
|
||
func (c *Ctx) Args() []string { return c.args }
|
||
|
||
// Str returns a flag's effective string value (explicit → alias → env →
|
||
// default). An undeclared name yields "".
|
||
func (c *Ctx) Str(name string) string {
|
||
flag, ok := c.flags[name]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return EffectiveValue(c.cmd, flag)
|
||
}
|
||
|
||
// Int returns a flag's effective integer value; an unparseable or undeclared
|
||
// value yields 0 (BuildArgs reports the precise parse error for Invoke specs).
|
||
func (c *Ctx) Int(name string) int {
|
||
flag, ok := c.flags[name]
|
||
if !ok {
|
||
return 0
|
||
}
|
||
v, err := integerValue(c.cmd, flag)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return int(v)
|
||
}
|
||
|
||
// Bool returns a boolean flag's value.
|
||
func (c *Ctx) Bool(name string) bool {
|
||
v, _ := c.cmd.Flags().GetBool(name)
|
||
return v
|
||
}
|
||
|
||
// StrSlice returns a list flag's effective elements (trimmed, empties dropped).
|
||
func (c *Ctx) StrSlice(name string) []string {
|
||
flag, ok := c.flags[name]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return sliceValue(c.cmd, flag)
|
||
}
|
||
|
||
// Changed reports whether the user explicitly passed the flag.
|
||
func (c *Ctx) Changed(name string) bool { return c.cmd.Flags().Changed(name) }
|
||
|
||
// DryRun reports the effective global --dry-run.
|
||
func (c *Ctx) DryRun() bool { return BoolFlag(c.cmd, "dry-run") }
|
||
|
||
// Yes reports the effective global --yes.
|
||
func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") }
|
||
|
||
// NewCommand builds a cobra command from a CommandSpec. It is the single
|
||
// orchestration path: dispatch declaration check → flag registration →
|
||
// constraint declaration checks → Runtime Schema projection → constraint help →
|
||
// PostMount → (RunE escape) → generated RunE{ required → constraints → Validate
|
||
// → BuildArgs → ConfirmRisk → Invoke/Orchestrate }.
|
||
//
|
||
// Behavior matches the former helpers.NewLeafCommand, which always dispatched
|
||
// (Call → Server → callMCPTool) and therefore could not express a dispatcher-less
|
||
// spec. Here that is a programming error caught at construction time, so a
|
||
// malformed spec can never run the pipeline — write-confirmation prompt
|
||
// included — and then silently exit 0 having done nothing.
|
||
func NewCommand(spec CommandSpec) *cobra.Command {
|
||
validateDispatchDecl(spec)
|
||
cmd := &cobra.Command{
|
||
Use: spec.Use,
|
||
Short: spec.Short,
|
||
Long: spec.Long,
|
||
Example: spec.Example,
|
||
}
|
||
RegisterFlags(cmd, spec.Flags)
|
||
ValidateConstraintDecls(spec.Use, spec.Flags, spec.Constraints)
|
||
embedContractIntoSchema(cmd, spec)
|
||
AnnotateConstraints(cmd, spec.Constraints)
|
||
if help := ConstraintHelp(spec.Constraints); help != "" {
|
||
cmd.Long = strings.TrimRight(cmd.Long, "\n") + help
|
||
}
|
||
if spec.PostMount != nil {
|
||
spec.PostMount(cmd)
|
||
}
|
||
if spec.RunE != nil {
|
||
cmd.RunE = spec.RunE
|
||
return cmd
|
||
}
|
||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||
if err := ValidateRequired(cmd, spec.Flags); err != nil {
|
||
return err
|
||
}
|
||
if err := ValidateConstraints(cmd, spec.Flags, spec.Constraints); err != nil {
|
||
return err
|
||
}
|
||
if spec.Validate != nil {
|
||
if err := spec.Validate(cmd, args); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
ctx := newCtx(cmd, args, spec.Flags)
|
||
if spec.Orchestrate != nil {
|
||
if err := ConfirmRisk(cmd, spec.Risk); err != nil {
|
||
return err
|
||
}
|
||
return spec.Orchestrate(ctx)
|
||
}
|
||
toolArgs, err := BuildArgs(cmd, spec.Flags)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for key, value := range spec.ConstParams {
|
||
toolArgs[key] = value
|
||
}
|
||
if err := ConfirmRisk(cmd, spec.Risk); err != nil {
|
||
return err
|
||
}
|
||
return spec.Invoke(ctx, toolArgs)
|
||
}
|
||
return cmd
|
||
}
|
||
|
||
// validateDispatchDecl enforces "exactly one dispatcher" at build time. Like
|
||
// ValidateConstraintDecls this panics: a spec with no runnable body (or with two
|
||
// competing ones) is a programming error that every test and startup path should
|
||
// trip immediately, not a condition to surface at user run time.
|
||
func validateDispatchDecl(spec CommandSpec) {
|
||
declared := 0
|
||
if spec.RunE != nil {
|
||
declared++
|
||
}
|
||
if spec.Invoke != nil {
|
||
declared++
|
||
}
|
||
if spec.Orchestrate != nil {
|
||
declared++
|
||
}
|
||
if declared != 1 {
|
||
panic(fmt.Sprintf(
|
||
"command %q must declare exactly one of RunE/Invoke/Orchestrate, got %d",
|
||
spec.Use, declared))
|
||
}
|
||
}
|
||
|
||
// RegisterFlags registers every flag (plus hidden aliases and MarkFlagRequired)
|
||
// declared by the spec set onto cmd.
|
||
func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||
for _, flag := range flags {
|
||
RegisterFlag(cmd, flag.Kind, flag.Name, flag.Default, flag.Usage)
|
||
// Aliases are registered with the main flag's Kind, otherwise an integer
|
||
// alias's value would never be readable (silently dropped).
|
||
for _, alias := range flag.Aliases {
|
||
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
|
||
_ = cmd.Flags().MarkHidden(alias)
|
||
}
|
||
if flag.MarkRequired {
|
||
_ = cmd.MarkFlagRequired(flag.Name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// RegisterFlag registers one flag by Kind. Default is applied at registration
|
||
// for every kind so --help DefValue matches the declared fallback.
|
||
func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
|
||
switch kind {
|
||
case KindInt:
|
||
defInt := 0
|
||
if def != "" {
|
||
if v, err := strconv.Atoi(def); err == nil {
|
||
defInt = v
|
||
}
|
||
}
|
||
cmd.Flags().Int(name, defInt, usage)
|
||
case KindBool:
|
||
cmd.Flags().Bool(name, def == "true", usage)
|
||
case KindStringSlice:
|
||
cmd.Flags().StringSlice(name, nil, usage)
|
||
default:
|
||
cmd.Flags().String(name, def, usage)
|
||
}
|
||
}
|
||
|
||
// ValidateRequired reproduces the handwritten required semantics: plain Required
|
||
// flags report a unified "missing required flag(s)" error; Required flags with
|
||
// EnvVar/RequiredHint report their hint separately. The plain group is checked
|
||
// before the env group to preserve the handwritten order. Both groups use the
|
||
// declared "main flag → alias → env" fallback: a compatible alias counts as
|
||
// provided.
|
||
func ValidateRequired(cmd *cobra.Command, flags []FlagSpec) error {
|
||
var plain []string
|
||
for _, flag := range flags {
|
||
if flag.Required && flag.EnvVar == "" && flag.RequiredHint == "" && !hasEffectiveValue(cmd, flag) {
|
||
plain = append(plain, flag.Name)
|
||
}
|
||
}
|
||
if err := cmdutil.MissingRequiredFlagsError(cmd, plain...); err != nil {
|
||
return err
|
||
}
|
||
for _, flag := range flags {
|
||
if !flag.Required || (flag.EnvVar == "" && flag.RequiredHint == "") {
|
||
continue
|
||
}
|
||
if !hasEffectiveValue(cmd, flag) {
|
||
hint := flag.RequiredHint
|
||
if hint == "" {
|
||
hint = fmt.Sprintf("flag --%s is required", flag.Name)
|
||
}
|
||
return fmt.Errorf("%s", hint)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// hasEffectiveValue decides whether a Required flag is satisfied, matching the
|
||
// BuildArgs entry predicate (KindInt non-zero, string non-empty, KindBool
|
||
// explicitly provided, KindStringSlice has a non-empty element). Integer parse
|
||
// failure counts as provided so BuildArgs reports the precise invalid-integer
|
||
// error.
|
||
func hasEffectiveValue(cmd *cobra.Command, flag FlagSpec) bool {
|
||
switch flag.Kind {
|
||
case KindInt:
|
||
v, err := integerValue(cmd, flag)
|
||
if err != nil {
|
||
return true
|
||
}
|
||
return v != 0
|
||
case KindBool:
|
||
return cmd.Flags().Changed(flag.Name)
|
||
case KindStringSlice:
|
||
return sliceValue(cmd, flag) != nil
|
||
}
|
||
return EffectiveValue(cmd, flag) != ""
|
||
}
|
||
|
||
// sliceValue reads a list flag's effective value by "explicit main flag →
|
||
// explicit alias" order: elements are TrimSpace'd and empties dropped, and an
|
||
// all-empty result counts as not provided (returns nil). Lists do not
|
||
// participate in the env/Default fallback chain.
|
||
func sliceValue(cmd *cobra.Command, flag FlagSpec) []string {
|
||
names := append([]string{flag.Name}, flag.Aliases...)
|
||
for _, name := range names {
|
||
if !cmd.Flags().Changed(name) {
|
||
continue
|
||
}
|
||
raw, _ := cmd.Flags().GetStringSlice(name)
|
||
var out []string
|
||
for _, value := range raw {
|
||
if value = strings.TrimSpace(value); value != "" {
|
||
out = append(out, value)
|
||
}
|
||
}
|
||
if len(out) > 0 {
|
||
return out
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// BuildArgs assembles toolArgs from the flag set by binding relationship.
|
||
func BuildArgs(cmd *cobra.Command, flags []FlagSpec) (map[string]any, error) {
|
||
toolArgs := map[string]any{}
|
||
for _, flag := range flags {
|
||
bind := flag.Bind
|
||
if bind == "" {
|
||
bind = flag.Name
|
||
}
|
||
if flag.Kind == KindInt {
|
||
v, err := integerValue(cmd, flag)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// ArgDefault floors values < 1 (cursor page-size: 0/-1 → default).
|
||
if v < 1 && flag.ArgDefault != "" {
|
||
parsed, err := strconv.ParseInt(flag.ArgDefault, 10, 64)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("flag --%s: invalid ArgDefault %q", flag.Name, flag.ArgDefault)
|
||
}
|
||
v = parsed
|
||
}
|
||
// Keep "non-zero only" (putInt semantics).
|
||
if v != 0 {
|
||
toolArgs[bind] = int(v)
|
||
}
|
||
continue
|
||
}
|
||
if flag.Kind == KindBool {
|
||
// Enter on Changed only: explicit false is still sent (matching the
|
||
// handwritten "transmit on Changed" semantics).
|
||
if cmd.Flags().Changed(flag.Name) {
|
||
v, _ := cmd.Flags().GetBool(flag.Name)
|
||
toolArgs[bind] = v
|
||
}
|
||
continue
|
||
}
|
||
if flag.Kind == KindStringSlice {
|
||
if v := sliceValue(cmd, flag); v != nil {
|
||
toolArgs[bind] = v
|
||
}
|
||
continue
|
||
}
|
||
effective := EffectiveValue(cmd, flag)
|
||
if effective == "" && flag.ArgDefault != "" {
|
||
effective = flag.ArgDefault
|
||
}
|
||
if effective == "" && flag.OmitEmpty {
|
||
continue
|
||
}
|
||
if flag.Transform != nil {
|
||
value, err := flag.Transform(effective)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if value == nil {
|
||
continue
|
||
}
|
||
toolArgs[bind] = value
|
||
continue
|
||
}
|
||
toolArgs[bind] = effective
|
||
}
|
||
return toolArgs, nil
|
||
}
|
||
|
||
// EffectiveValue reads the value by "explicit main flag → alias → env →
|
||
// registration default" order (string form, integers uniformly formatted);
|
||
// Trim TrimSpace's the result.
|
||
func EffectiveValue(cmd *cobra.Command, flag FlagSpec) string {
|
||
v := rawValue(cmd, flag)
|
||
if flag.Trim {
|
||
v = strings.TrimSpace(v)
|
||
}
|
||
return v
|
||
}
|
||
|
||
// rawValue reads the un-trimmed effective value. The main flag wins only when
|
||
// explicitly provided (Changed) and non-empty; the registration default is
|
||
// demoted to a chain tail and no longer shadows aliases/env. When Trim is set,
|
||
// candidates are judged empty after trimming, so whitespace-only and empty fall
|
||
// through to the next fallback level.
|
||
func rawValue(cmd *cobra.Command, flag FlagSpec) string {
|
||
usable := func(v string) bool {
|
||
if flag.Trim {
|
||
v = strings.TrimSpace(v)
|
||
}
|
||
return v != ""
|
||
}
|
||
if cmd.Flags().Changed(flag.Name) {
|
||
if v := flagString(cmd, flag.Kind, flag.Name); usable(v) {
|
||
return v
|
||
}
|
||
}
|
||
for _, alias := range flag.Aliases {
|
||
if !cmd.Flags().Changed(alias) {
|
||
continue
|
||
}
|
||
if v := flagString(cmd, flag.Kind, alias); usable(v) {
|
||
return v
|
||
}
|
||
}
|
||
if flag.EnvVar != "" {
|
||
if v := os.Getenv(flag.EnvVar); usable(v) {
|
||
return v
|
||
}
|
||
}
|
||
return flag.Default
|
||
}
|
||
|
||
// flagString reads a flag by registered type and normalizes to string form so
|
||
// integer flags can reuse the same fallback chain (required checks, aliases, env).
|
||
func flagString(cmd *cobra.Command, kind FlagKind, name string) string {
|
||
switch kind {
|
||
case KindInt:
|
||
v, _ := cmd.Flags().GetInt(name)
|
||
return strconv.Itoa(v)
|
||
default:
|
||
return cmdutil.MustGetFlag(cmd, name)
|
||
}
|
||
}
|
||
|
||
// integerValue reads an integer flag's effective value by the fallback chain; an
|
||
// env-provided string must be parseable, otherwise it errors rather than
|
||
// silently dropping.
|
||
func integerValue(cmd *cobra.Command, flag FlagSpec) (int64, error) {
|
||
raw := EffectiveValue(cmd, flag)
|
||
if raw == "" {
|
||
return 0, nil
|
||
}
|
||
v, err := strconv.ParseInt(raw, 10, 64)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("flag --%s: invalid integer value %q", flag.Name, raw)
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
// ValidateConstraintDecls validates constraint declarations at build time: an
|
||
// unknown kind, an under-sized flag group, or a reference to an undeclared flag
|
||
// is a programming error and panics so any test/startup path fails immediately
|
||
// rather than at user runtime. use is only used for the panic message.
|
||
func ValidateConstraintDecls(use string, flags []FlagSpec, constraints []Constraint) {
|
||
declared := map[string]bool{}
|
||
for _, flag := range flags {
|
||
declared[flag.Name] = true
|
||
}
|
||
for _, constraint := range constraints {
|
||
switch constraint.Kind {
|
||
case AtLeastOne, ExactlyOne, MutuallyExclusive:
|
||
default:
|
||
panic(fmt.Sprintf("command %q: unknown constraint kind %q", use, constraint.Kind))
|
||
}
|
||
if len(constraint.Flags) < 2 {
|
||
panic(fmt.Sprintf("command %q: constraint %s needs at least two flags", use, constraint.Kind))
|
||
}
|
||
for _, name := range constraint.Flags {
|
||
if !declared[name] {
|
||
panic(fmt.Sprintf("command %q: constraint %s references undeclared flag %q", use, constraint.Kind, name))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// constraintProvided decides whether a flag is "provided" for constraint
|
||
// purposes: an explicit main flag, explicit alias, or env var counts; the
|
||
// registration default/ArgDefault does not — otherwise a defaulted flag would
|
||
// always satisfy at_least_one and always trip mutually_exclusive. KindBool only
|
||
// counts Changed (booleans have no alias/env fallback semantics).
|
||
func constraintProvided(cmd *cobra.Command, flag FlagSpec) bool {
|
||
switch flag.Kind {
|
||
case KindBool:
|
||
return cmd.Flags().Changed(flag.Name)
|
||
case KindStringSlice:
|
||
if cmd.Flags().Changed(flag.Name) {
|
||
if v, _ := cmd.Flags().GetStringSlice(flag.Name); sliceHasValue(v) {
|
||
return true
|
||
}
|
||
}
|
||
for _, alias := range flag.Aliases {
|
||
if !cmd.Flags().Changed(alias) {
|
||
continue
|
||
}
|
||
if v, _ := cmd.Flags().GetStringSlice(alias); sliceHasValue(v) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
usable := func(v string) bool { return strings.TrimSpace(v) != "" }
|
||
if cmd.Flags().Changed(flag.Name) && usable(flagString(cmd, flag.Kind, flag.Name)) {
|
||
return true
|
||
}
|
||
for _, alias := range flag.Aliases {
|
||
if cmd.Flags().Changed(alias) && usable(flagString(cmd, flag.Kind, alias)) {
|
||
return true
|
||
}
|
||
}
|
||
return flag.EnvVar != "" && usable(os.Getenv(flag.EnvVar))
|
||
}
|
||
|
||
// ValidateConstraints enforces the relationship constraints. Error wording
|
||
// matches the shortcut framework's RuntimeContext.AtLeastOne/ExactlyOne/
|
||
// MutuallyExclusive verbatim, so atomic commands and smart shortcuts fail
|
||
// identically for users and agents.
|
||
func ValidateConstraints(cmd *cobra.Command, flags []FlagSpec, constraints []Constraint) error {
|
||
flagsByName := map[string]FlagSpec{}
|
||
for _, flag := range flags {
|
||
flagsByName[flag.Name] = flag
|
||
}
|
||
for _, constraint := range constraints {
|
||
var set []string
|
||
for _, name := range constraint.Flags {
|
||
if constraintProvided(cmd, flagsByName[name]) {
|
||
set = append(set, name)
|
||
}
|
||
}
|
||
switch constraint.Kind {
|
||
case AtLeastOne:
|
||
if len(set) == 0 {
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"请至少指定 %s 之一", dashed(constraint.Flags)))
|
||
}
|
||
case ExactlyOne:
|
||
switch len(set) {
|
||
case 1:
|
||
case 0:
|
||
return apperrors.NewValidation(fmt.Sprintf("请指定 %s 之一", dashed(constraint.Flags)))
|
||
default:
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"参数 %s 只能指定其一(当前指定了 %s)", dashed(constraint.Flags), dashed(set)))
|
||
}
|
||
case MutuallyExclusive:
|
||
if len(set) > 1 {
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"参数 %s 互斥,只能指定其一(当前指定了 %s)", dashed(constraint.Flags), dashed(set)))
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ConfirmRisk reproduces the shortcut framework's confirmRisk write-confirmation
|
||
// prompt wording (command path substitutes Service+Command). Semantics:
|
||
//
|
||
// - read-only, --dry-run, or --yes → nil (proceed);
|
||
// - interactive yes/y → nil;
|
||
// - interactive decline → validation "用户取消了操作" (existing cmdcore path);
|
||
// - no interactive answer (EOF / closed stdin) → confirmation_required.
|
||
//
|
||
// EOF must not be treated as decline: that silently drops writes in agent/CI.
|
||
func ConfirmRisk(cmd *cobra.Command, risk Risk) error {
|
||
if risk.Effective() == RiskRead || BoolFlag(cmd, "dry-run") || BoolFlag(cmd, "yes") {
|
||
return nil
|
||
}
|
||
output := cmd.ErrOrStderr()
|
||
fmt.Fprintf(output, "即将执行 %s(%s),确认继续?(yes/no): ", cmd.CommandPath(), risk.Effective())
|
||
reader := bufio.NewReader(cmd.InOrStdin())
|
||
answer, err := reader.ReadString('\n')
|
||
if errors.Is(err, io.EOF) && strings.TrimSpace(answer) == "" {
|
||
return confirmationRequiredError(cmd.CommandPath())
|
||
}
|
||
if err != nil && !errors.Is(err, io.EOF) {
|
||
return confirmationRequiredError(cmd.CommandPath())
|
||
}
|
||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||
if answer == "yes" || answer == "y" {
|
||
return nil
|
||
}
|
||
return apperrors.NewValidation("用户取消了操作")
|
||
}
|
||
|
||
func confirmationRequiredError(operation string) error {
|
||
return apperrors.NewValidation(
|
||
fmt.Sprintf("%s 是写操作,当前环境无法交互确认;加 --dry-run 预览,或确认后加 --yes 执行", operation),
|
||
apperrors.WithReason("confirmation_required"),
|
||
apperrors.WithHint("非交互环境(agent/CI)必须显式传入 --yes,不能依赖 stdin 提示"),
|
||
apperrors.WithActions("确认目标与变更影响", "以相同参数追加 --yes 执行"),
|
||
)
|
||
}
|
||
|
||
// BoolFlag robustly reads a bool flag that may live on the command, its
|
||
// inherited flags, or the root's persistent flags (e.g. root-injected global
|
||
// --yes / --dry-run). Returns the first flagset that resolves the name.
|
||
func BoolFlag(cmd *cobra.Command, name string) bool {
|
||
if cmd == nil {
|
||
return false
|
||
}
|
||
getters := []func(string) (bool, error){
|
||
cmd.Flags().GetBool,
|
||
cmd.InheritedFlags().GetBool,
|
||
}
|
||
if root := cmd.Root(); root != nil {
|
||
getters = append(getters, root.PersistentFlags().GetBool)
|
||
}
|
||
for _, get := range getters {
|
||
if v, err := get(name); err == nil {
|
||
return v
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// embedContractIntoSchema projects CommandSpec declaration onto the live Cobra
|
||
// leaf as the final Schema payload (dws.schema.*). Assembly pass-throughs these
|
||
// annotations; declared fields do not compete with reviewed hints.
|
||
func embedContractIntoSchema(cmd *cobra.Command, spec CommandSpec) {
|
||
cli.AnnotateRuntimeContract(cmd)
|
||
if strings.TrimSpace(string(spec.Risk)) != "" {
|
||
cli.AnnotateRuntimeRisk(cmd, string(spec.Risk.Effective()))
|
||
}
|
||
required := make([]string, 0, len(spec.Flags))
|
||
for _, flag := range spec.Flags {
|
||
name := strings.TrimSpace(flag.Name)
|
||
if name == "" {
|
||
continue
|
||
}
|
||
requiredFlag := flag.Required || flag.MarkRequired
|
||
cli.AnnotateRuntimeFlag(cmd, name, strings.TrimSpace(flag.Bind), flagKindSchemaType(flag.Kind), requiredFlag, "")
|
||
desc := strings.TrimSpace(flag.SchemaDescription)
|
||
if desc == "" {
|
||
desc = strings.TrimSpace(flag.Usage)
|
||
}
|
||
if desc != "" {
|
||
cli.AnnotateRuntimeFlagDescription(cmd, name, desc)
|
||
}
|
||
if flag.RequiredWhen != "" {
|
||
cli.AnnotateRuntimeFlagRequiredWhen(cmd, name, flag.RequiredWhen)
|
||
}
|
||
if flag.Format != "" {
|
||
cli.AnnotateRuntimeFlagFormat(cmd, name, flag.Format)
|
||
}
|
||
if flag.Example != "" {
|
||
cli.AnnotateRuntimeFlagExample(cmd, name, flag.Example)
|
||
}
|
||
if len(flag.Enum) > 0 {
|
||
cli.AnnotateRuntimeFlagEnum(cmd, name, flag.Enum...)
|
||
}
|
||
if requiredFlag {
|
||
required = append(required, name)
|
||
}
|
||
}
|
||
cli.AnnotateRuntimeRequiredFlags(cmd, required...)
|
||
embedSchemaDecl(cmd, spec)
|
||
}
|
||
|
||
// embedSchemaDecl does a light runtime write: only when SchemaDecl is authored,
|
||
// convert once and RegisterRuntimeContractFinal. Risk-only leaves keep
|
||
// dws.schema.risk from embedContractIntoSchema — no Final registration.
|
||
func embedSchemaDecl(cmd *cobra.Command, spec CommandSpec) {
|
||
schema := spec.Schema
|
||
if schema.empty() {
|
||
return
|
||
}
|
||
payload := cli.ContractFinalPayload{
|
||
Title: firstNonEmpty(schema.Title, spec.Short),
|
||
Description: firstNonEmpty(schema.Description, spec.Long),
|
||
Safety: schemaSafetyFromDecl(spec.Risk, schema.Safety),
|
||
}
|
||
if n := len(schema.Positionals); n > 0 {
|
||
payload.Positionals = make([]cli.RuntimeSchemaPositional, n)
|
||
for i, p := range schema.Positionals {
|
||
payload.Positionals[i] = cli.RuntimeSchemaPositional{
|
||
Name: p.Name, Type: p.Type, Description: p.Description,
|
||
Required: p.Required, Variadic: p.Variadic, Index: p.Index,
|
||
}
|
||
}
|
||
}
|
||
if schema.DryRun != nil && strings.TrimSpace(schema.DryRun.PreviewKind) != "" {
|
||
payload.DryRun = &cli.DryRunSpec{
|
||
PreviewKind: strings.TrimSpace(schema.DryRun.PreviewKind),
|
||
RemoteReads: schema.DryRun.RemoteReads,
|
||
}
|
||
}
|
||
if schema.Interface != nil {
|
||
iface := &cli.InterfaceSpec{
|
||
Mode: strings.TrimSpace(schema.Interface.Mode),
|
||
Availability: strings.TrimSpace(schema.Interface.Availability),
|
||
Reason: strings.TrimSpace(schema.Interface.Reason),
|
||
}
|
||
if pid := strings.TrimSpace(schema.Interface.ProductID); pid != "" {
|
||
iface.Ref = &cli.InterfaceRefSpec{ProductID: pid, RPCName: strings.TrimSpace(schema.Interface.RPCName)}
|
||
}
|
||
if iface.Mode != "" || iface.Ref != nil || iface.Availability != "" || iface.Reason != "" {
|
||
payload.Interface = iface
|
||
}
|
||
}
|
||
if sel := schema.Selection; strings.TrimSpace(sel.AgentSummary) != "" || len(sel.UseWhen) > 0 ||
|
||
len(sel.AvoidWhen) > 0 || len(sel.Examples) > 0 || len(sel.Prerequisites) > 0 ||
|
||
len(sel.Tips) > 0 || len(sel.WorkflowRefs) > 0 {
|
||
payload.Selection = &cli.SelectionSpec{
|
||
AgentSummary: strings.TrimSpace(sel.AgentSummary),
|
||
UseWhen: sel.UseWhen, AvoidWhen: sel.AvoidWhen,
|
||
Prerequisites: sel.Prerequisites, Tips: sel.Tips,
|
||
WorkflowRefs: sel.WorkflowRefs, Examples: sel.Examples,
|
||
}
|
||
}
|
||
if id := schema.Identity; strings.TrimSpace(id.ProductID) != "" || strings.TrimSpace(id.Name) != "" {
|
||
payload.Identity = &cli.ToolIdentitySpec{
|
||
ProductID: strings.TrimSpace(id.ProductID), SourceProductID: strings.TrimSpace(id.SourceProductID),
|
||
Name: strings.TrimSpace(id.Name), CLIName: strings.TrimSpace(id.CLIName),
|
||
CanonicalPath: strings.TrimSpace(id.CanonicalPath), CLIPath: strings.TrimSpace(id.CLIPath),
|
||
PrimaryCLIPath: strings.TrimSpace(id.PrimaryCLIPath), Group: strings.TrimSpace(id.Group),
|
||
Aliases: id.Aliases, Source: strings.TrimSpace(id.Source),
|
||
}
|
||
}
|
||
cli.RegisterRuntimeContractFinal(cmd, payload)
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, v := range values {
|
||
if s := strings.TrimSpace(v); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func schemaSafetyFromDecl(risk Risk, decl SafetyDecl) *cli.SafetySpec {
|
||
out := cli.SafetySpec{
|
||
Effect: strings.TrimSpace(decl.Effect),
|
||
Risk: strings.TrimSpace(decl.Risk),
|
||
Confirmation: strings.TrimSpace(decl.Confirmation),
|
||
Idempotency: strings.TrimSpace(decl.Idempotency),
|
||
}
|
||
if strings.TrimSpace(string(risk)) != "" {
|
||
switch risk.Effective() {
|
||
case RiskWrite:
|
||
if out.Effect == "" {
|
||
out.Effect = "write"
|
||
}
|
||
if out.Risk == "" {
|
||
out.Risk = "medium"
|
||
}
|
||
if out.Confirmation == "" {
|
||
out.Confirmation = "user_required"
|
||
}
|
||
case RiskHighWrite:
|
||
if out.Effect == "" {
|
||
out.Effect = "destructive"
|
||
}
|
||
if out.Risk == "" {
|
||
out.Risk = "high"
|
||
}
|
||
if out.Confirmation == "" {
|
||
out.Confirmation = "user_required"
|
||
}
|
||
case RiskRead:
|
||
if out.Effect == "" {
|
||
out.Effect = "read"
|
||
}
|
||
if out.Risk == "" {
|
||
out.Risk = "low"
|
||
}
|
||
if out.Confirmation == "" {
|
||
out.Confirmation = "not_required"
|
||
}
|
||
}
|
||
out.EffectSource = "cmdcore.contract"
|
||
}
|
||
if out.Effect == "" && out.Risk == "" && out.Confirmation == "" && out.Idempotency == "" {
|
||
return nil
|
||
}
|
||
return &out
|
||
}
|
||
|
||
func flagKindSchemaType(kind FlagKind) string {
|
||
switch kind {
|
||
case KindInt:
|
||
return "integer"
|
||
case KindBool:
|
||
return "boolean"
|
||
case KindStringSlice:
|
||
return "array"
|
||
default:
|
||
return "string"
|
||
}
|
||
}
|
||
|
||
// AnnotateConstraints projects the relationship constraints into the Agent
|
||
// Runtime Schema: exactly_one decomposes into require_one_of + mutually_exclusive
|
||
// (matching the handwritten commands' use of AnnotateRuntimeConstraints).
|
||
func AnnotateConstraints(cmd *cobra.Command, constraints []Constraint) {
|
||
var projected cli.RuntimeSchemaConstraints
|
||
for _, constraint := range constraints {
|
||
flags := append([]string(nil), constraint.Flags...)
|
||
switch constraint.Kind {
|
||
case AtLeastOne:
|
||
projected.RequireOneOf = append(projected.RequireOneOf, flags)
|
||
case ExactlyOne:
|
||
projected.RequireOneOf = append(projected.RequireOneOf, flags)
|
||
projected.MutuallyExclusive = append(projected.MutuallyExclusive, flags)
|
||
case MutuallyExclusive:
|
||
projected.MutuallyExclusive = append(projected.MutuallyExclusive, flags)
|
||
}
|
||
}
|
||
cli.AnnotateRuntimeConstraints(cmd, projected)
|
||
}
|
||
|
||
// ConstraintHelp renders the --help "参数约束" section, matching the shortcut
|
||
// leaf help shape; returns "" when there are no constraints.
|
||
func ConstraintHelp(constraints []Constraint) string {
|
||
if len(constraints) == 0 {
|
||
return ""
|
||
}
|
||
lines := make([]string, 0, len(constraints))
|
||
for _, constraint := range constraints {
|
||
text := strings.TrimSpace(constraint.Description)
|
||
if text == "" {
|
||
switch constraint.Kind {
|
||
case AtLeastOne:
|
||
text = fmt.Sprintf("%s 至少指定一个", dashed(constraint.Flags))
|
||
case ExactlyOne:
|
||
text = fmt.Sprintf("%s 必须且只能指定一个", dashed(constraint.Flags))
|
||
case MutuallyExclusive:
|
||
text = fmt.Sprintf("%s 互斥,最多指定一个", dashed(constraint.Flags))
|
||
}
|
||
}
|
||
lines = append(lines, " - "+text)
|
||
}
|
||
return "\n\n参数约束:\n" + strings.Join(lines, "\n")
|
||
}
|
||
|
||
func dashed(flags []string) string {
|
||
out := make([]string, len(flags))
|
||
for i, f := range flags {
|
||
out[i] = "--" + f
|
||
}
|
||
return strings.Join(out, "、")
|
||
}
|
||
|
||
func sliceHasValue(values []string) bool {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|