1587 lines
58 KiB
Go
1587 lines
58 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 corecmd 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, SafetySpec-driven confirmation, toolArgs
|
||
// assembly, and Agent Runtime Schema projection.
|
||
//
|
||
// Declaration vs execution (framework rule):
|
||
//
|
||
// - Declare = Spec data fields (Flags, Constraints, Safety,
|
||
// ConstParams, Use/Short/Long/Example). New 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. a cross-flag constraint or parameter metadata fact).
|
||
// Inference-only Schema/help is forbidden.
|
||
// - Selection / product routing prose is declared on ContractDecl /
|
||
// ProductDecl (delivered as contract_final). schema_hints/ is retired.
|
||
// Identity is collected from ContractFinal.Identity on the live leaves.
|
||
// Interface / dry-run reviewed sources 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. LeafSpec
|
||
// (internal/helpers) and Shortcut (internal/shortcut) wrap these primitives and
|
||
// supply dispatch. Invoke / Orchestrate / Ctx are the #830 transitional
|
||
// dispatch API still used in production; the RFC target is mcpbind + Handler
|
||
// (do not treat removal of Invoke/Orchestrate as already landed).
|
||
//
|
||
// Behavioral contract: flag registration, value fallback, required/constraint
|
||
// semantics, confirmation behavior, and schema projection stay shared across
|
||
// Leaf and Shortcut. Evidence is split: check-generated-drift proves build-time
|
||
// projection; runtime pipeline order is covered by this package's tests plus
|
||
// leaf/risk/constraint unit tests.
|
||
package corecmd
|
||
|
||
import (
|
||
"bufio"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/mattn/go-isatty"
|
||
"github.com/spf13/cobra"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||
"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
|
||
)
|
||
|
||
// FlagValidationMode selects runtime validation semantics for a flag.
|
||
//
|
||
// The zero value keeps LeafSpec's fallback-aware effective-value semantics.
|
||
// ValidationShortcut preserves Shortcut's declaration-order checks: Required
|
||
// means the user must explicitly provide the flag token (even with a default),
|
||
// followed immediately by that flag's Enum validation.
|
||
type FlagValidationMode string
|
||
|
||
// ValidationShortcut is the declaration-order explicit-token mode described on
|
||
// FlagValidationMode.
|
||
const ValidationShortcut FlagValidationMode = "shortcut"
|
||
|
||
// 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)
|
||
Shorthand string // optional one-character Cobra shorthand
|
||
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
|
||
Hidden bool // hide the real flag from help/Schema while keeping it invocable
|
||
|
||
// 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
|
||
ValidationMode FlagValidationMode
|
||
RequiredError string // exact missing-token error for ValidationShortcut
|
||
RequiredHint string
|
||
// MarkRequired, when true, calls cobra MarkFlagRequired (the hard floor for
|
||
// the catalog required projection); cobra errors before RunE. It cannot be
|
||
// combined with Aliases (RegisterFlags panics on that declaration).
|
||
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
|
||
}
|
||
|
||
// 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"
|
||
// Custom documents validation implemented by Spec.Validate. command
|
||
// validates the declaration and renders its help, but does not infer the
|
||
// command-specific runtime rule.
|
||
Custom ConstraintKind = "custom"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
// ParameterProjectionMode selects how declared flags are embedded into Runtime
|
||
// Schema annotations. The zero value makes the declaration the final parameter
|
||
// authority (the LeafSpec/command default).
|
||
type ParameterProjectionMode string
|
||
|
||
const (
|
||
// ProjectCobraParameters preserves Cobra usage/type/default provenance and
|
||
// annotates only facts Cobra cannot express: Required and Enum. Shortcut
|
||
// uses this mode to converge its runtime without rewriting Catalog facts.
|
||
ProjectCobraParameters ParameterProjectionMode = "cobra"
|
||
)
|
||
|
||
// Spec 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, Safety, ConstParams,
|
||
// Use/Short/Long/Example, Contract (ToolSpec groups)
|
||
//
|
||
// Schema assembly pass-throughs embedded dws.schema.* — no reviewed/hints
|
||
// parallel authority for declared fields. Safety uses contract.SafetySpec
|
||
// directly: confirmation drives the runtime gate, while effect/risk/idempotency
|
||
// are published unchanged. No safety field is inferred from another.
|
||
//
|
||
// Execution surface (hooks — not declaration):
|
||
//
|
||
// - RunE — full escape hatch: the framework only registers flags/constraints/
|
||
// help and hands control over.
|
||
// - Invoke — #830 transitional single-step dispatch: runs after required/
|
||
// constraint/Validate checks, args assembly and the Safety confirmation
|
||
// gate, receiving the assembled toolArgs. Target: mcpbind Bind.
|
||
// - Orchestrate — #830 transitional multi-step dispatch: same checks and
|
||
// confirmation, receives only the Ctx. Target: Handler / orchestration.
|
||
// - 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; New validates this at
|
||
// construction time. corecmd stays dispatch-agnostic and never calls a backend:
|
||
// the adapters (FromLeafSpec / FromShortcut) supply the body.
|
||
type Spec struct {
|
||
Use string
|
||
Short string
|
||
Long string
|
||
Example string
|
||
Hidden bool
|
||
OutputRollout output.RolloutState
|
||
|
||
Flags []FlagSpec
|
||
Constraints []Constraint
|
||
// ParameterProjection controls whether parameter facts are final
|
||
// declaration annotations or Cobra-backed compatibility facts.
|
||
ParameterProjection ParameterProjectionMode
|
||
// Safety is the command's single safety source. The same contract.SafetySpec
|
||
// is used for runtime confirmation and the published Schema. A completely
|
||
// empty value keeps the historical read-only default; a non-empty value
|
||
// must declare effect/risk/confirmation/idempotency together.
|
||
Safety contract.SafetySpec
|
||
// ConfirmFirst runs the Safety confirmation before required/constraint/
|
||
// Validate checks instead of after them. Use it where the legacy semantics
|
||
// were guard-first (a write without --yes fails fast with
|
||
// confirmation_required regardless of parameter completeness). The default
|
||
// preserves the shortcut order (checks first, confirmation just before the
|
||
// backend call).
|
||
ConfirmFirst bool
|
||
// 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
|
||
// Contract is the authoring-time leaf contract declaration (selection /
|
||
// interface / parameters / dry-run / identity). When non-empty, embed
|
||
// converts it once to ContractFinal for Catalog pass-through.
|
||
Contract ContractDecl
|
||
|
||
// 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
|
||
// ResultInvoke executes once and returns an immutable framework 2.0 result.
|
||
ResultInvoke func(c *Ctx, toolArgs map[string]any) (output.CommandResult, 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 Spec. It is the single
|
||
// orchestration path: dispatch declaration check → flag registration →
|
||
// constraint declaration checks → Runtime Schema projection → constraint help →
|
||
// PostMount → generated RunE{ [ConfirmFirst: ConfirmSafety →]
|
||
// required → constraints → Validate → BuildArgs → ConfirmSafety →
|
||
// 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 New(spec Spec) *cobra.Command {
|
||
validateDispatchDecl(spec)
|
||
validateSafetySpec(spec)
|
||
validateContractDecl(spec)
|
||
// Help prose inherits the declaration when not authored separately:
|
||
// Selection.Examples (already contract-validated against the real flags)
|
||
// double as the --help Example block, keeping one authored source.
|
||
example := spec.Example
|
||
if strings.TrimSpace(example) == "" && len(spec.Contract.Selection.Examples) > 0 {
|
||
example = " " + strings.Join(spec.Contract.Selection.Examples, "\n ")
|
||
}
|
||
cmd := &cobra.Command{
|
||
Use: spec.Use,
|
||
Short: spec.Short,
|
||
Long: spec.Long,
|
||
Example: example,
|
||
Hidden: spec.Hidden,
|
||
}
|
||
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.OutputRollout != "" {
|
||
output.SetCommandRollout(cmd, spec.OutputRollout)
|
||
}
|
||
if spec.ConfirmFirst {
|
||
if cmd.Annotations == nil {
|
||
cmd.Annotations = map[string]string{}
|
||
}
|
||
cmd.Annotations[ConfirmFirstAnnotation] = "true"
|
||
}
|
||
if spec.RunE != nil {
|
||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||
if err := runDeclaredPreflight(cmd, args, spec); err != nil {
|
||
return err
|
||
}
|
||
if !spec.ConfirmFirst {
|
||
if err := ConfirmSafety(cmd, spec.Safety); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return spec.RunE(cmd, args)
|
||
}
|
||
return cmd
|
||
}
|
||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||
if err := runDeclaredPreflight(cmd, args, spec); err != nil {
|
||
return err
|
||
}
|
||
ctx := newCtx(cmd, args, spec.Flags)
|
||
if spec.Orchestrate != nil {
|
||
if !spec.ConfirmFirst {
|
||
if err := ConfirmSafety(cmd, spec.Safety); 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 !spec.ConfirmFirst {
|
||
if err := ConfirmSafety(cmd, spec.Safety); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if spec.ResultInvoke != nil {
|
||
if !output.UsesUnifiedResult(cmd) {
|
||
return fmt.Errorf("command %q uses ResultInvoke without an active unified-result rollout", cmd.CommandPath())
|
||
}
|
||
result, err := spec.ResultInvoke(ctx, toolArgs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return output.StoreResult(cmd.Context(), result)
|
||
}
|
||
return spec.Invoke(ctx, toolArgs)
|
||
}
|
||
return cmd
|
||
}
|
||
|
||
// ConfirmFirstAnnotation marks commands whose Spec declared ConfirmFirst. The
|
||
// delivery gate reads it to tell a declared guard-first command apart from an
|
||
// accidental confirm-before-validate inversion: guard-first is only legitimate
|
||
// when the declaration says so.
|
||
const ConfirmFirstAnnotation = "dws.contract.confirm_first"
|
||
|
||
// HasDeclaredConfirmFirst reports whether cmd was built from a Spec that
|
||
// declared ConfirmFirst.
|
||
func HasDeclaredConfirmFirst(cmd *cobra.Command) bool {
|
||
return cmd != nil && cmd.Annotations != nil && cmd.Annotations[ConfirmFirstAnnotation] == "true"
|
||
}
|
||
|
||
// runDeclaredPreflight runs the checks the Spec itself declares, in the one
|
||
// order both dispatch paths share. ConfirmFirst is the declared opt-out for
|
||
// legacy guard-first commands: those confirm before parameter completeness is
|
||
// known. Keeping this in one function is deliberate — when the RunE escape
|
||
// hatch carried its own copy it silently dropped every declared check, so a
|
||
// spec could publish Required flags that nothing enforced.
|
||
func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error {
|
||
if spec.ConfirmFirst {
|
||
if err := ConfirmSafety(cmd, spec.Safety); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := ValidateRequired(cmd, spec.Flags); err != nil {
|
||
return err
|
||
}
|
||
if err := ValidateEnums(cmd, spec.Flags); err != nil {
|
||
return err
|
||
}
|
||
if err := ValidateConstraints(cmd, spec.Flags, spec.Constraints); err != nil {
|
||
return err
|
||
}
|
||
if spec.Validate != nil {
|
||
return spec.Validate(cmd, args)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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 Spec) {
|
||
declared := 0
|
||
if spec.RunE != nil {
|
||
declared++
|
||
}
|
||
if spec.Invoke != nil {
|
||
declared++
|
||
}
|
||
if spec.ResultInvoke != 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 (ResultInvoke is also a dispatcher)",
|
||
spec.Use, declared))
|
||
}
|
||
// ConfirmFirst only changes the ordering of a declared confirmation gate.
|
||
if spec.ConfirmFirst && strings.TrimSpace(spec.Safety.Confirmation) != "user_required" {
|
||
panic(fmt.Sprintf(
|
||
"command %q sets ConfirmFirst but Safety.Confirmation is not user_required",
|
||
spec.Use))
|
||
}
|
||
}
|
||
|
||
// validateSafetySpec rejects partial safety declarations. A zero value remains
|
||
// the historical read-only default, but once any field is authored all four
|
||
// independent Schema dimensions must be explicit.
|
||
func validateSafetySpec(spec Spec) {
|
||
safety := spec.Safety
|
||
fields := []struct {
|
||
name string
|
||
value string
|
||
}{
|
||
{"Safety.Effect", safety.Effect},
|
||
{"Safety.Risk", safety.Risk},
|
||
{"Safety.Confirmation", safety.Confirmation},
|
||
{"Safety.Idempotency", safety.Idempotency},
|
||
}
|
||
declared := false
|
||
for _, field := range fields {
|
||
if strings.TrimSpace(field.value) != "" {
|
||
declared = true
|
||
break
|
||
}
|
||
}
|
||
if !declared {
|
||
return
|
||
}
|
||
missing := make([]string, 0, len(fields))
|
||
for _, field := range fields {
|
||
if strings.TrimSpace(field.value) == "" {
|
||
missing = append(missing, field.name)
|
||
}
|
||
}
|
||
if len(missing) > 0 {
|
||
panic(fmt.Sprintf(
|
||
"command %q declares partial SafetySpec; missing %s: safety fields are independent and are never inferred from one another",
|
||
spec.Use, strings.Join(missing, ", ")))
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
// MarkFlagRequired only knows the main name: a user passing a declared
|
||
// alias would get Cobra's "required flag(s) not set" even though the
|
||
// framework validation considers the alias provided. The combination is
|
||
// unsupported and rejected at build time.
|
||
if flag.MarkRequired && len(flag.Aliases) > 0 {
|
||
panic(fmt.Sprintf(
|
||
"flag %q: MarkRequired cannot be combined with Aliases: cobra MarkFlagRequired only recognizes the main name, so a value passed via an alias would be rejected",
|
||
flag.Name))
|
||
}
|
||
registerFlagP(cmd, flag.Kind, flag.Name, flag.Shorthand, 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 registered := cmd.Flags().Lookup(alias); registered != nil {
|
||
runtimeannotate.SetFlagAnnotation(
|
||
registered,
|
||
runtimeannotate.AnnotationFlagAliasOf,
|
||
flag.Name,
|
||
)
|
||
runtimeannotate.SetFlagAnnotation(
|
||
registered,
|
||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||
)
|
||
}
|
||
}
|
||
if flag.MarkRequired {
|
||
_ = cmd.MarkFlagRequired(flag.Name)
|
||
}
|
||
if flag.Hidden {
|
||
_ = cmd.Flags().MarkHidden(flag.Name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// RegisterFlag registers one flag by Kind. Default is applied at registration
|
||
// for every kind so --help DefValue matches the declared fallback.
|
||
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
|
||
// instead of silently degrading to 0 / false.
|
||
func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
|
||
registerFlagP(cmd, kind, name, "", def, usage)
|
||
}
|
||
|
||
func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usage string) {
|
||
switch kind {
|
||
case KindInt:
|
||
defInt := 0
|
||
if def != "" {
|
||
v, err := strconv.Atoi(def)
|
||
if err != nil {
|
||
panic(fmt.Sprintf("flag %q: invalid KindInt Default %q", name, def))
|
||
}
|
||
defInt = v
|
||
}
|
||
cmd.Flags().IntP(name, shorthand, defInt, usage)
|
||
case KindBool:
|
||
defBool := false
|
||
if def != "" {
|
||
switch def {
|
||
case "true":
|
||
defBool = true
|
||
case "false":
|
||
defBool = false
|
||
default:
|
||
panic(fmt.Sprintf("flag %q: invalid KindBool Default %q (want \"true\" or \"false\")", name, def))
|
||
}
|
||
}
|
||
cmd.Flags().BoolP(name, shorthand, defBool, usage)
|
||
case KindStringSlice:
|
||
var defaults []string
|
||
if value := strings.TrimSpace(def); value != "" {
|
||
defaults = strings.Split(value, ",")
|
||
}
|
||
cmd.Flags().StringSliceP(name, shorthand, defaults, usage)
|
||
default:
|
||
cmd.Flags().StringP(name, shorthand, 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. Shortcut mode keeps its stricter contract: it demands an explicit
|
||
// token, so the main flag or an alias must be Changed on the command line — a
|
||
// registration default does not satisfy it.
|
||
func ValidateRequired(cmd *cobra.Command, flags []FlagSpec) error {
|
||
for _, flag := range flags {
|
||
if flag.ValidationMode != ValidationShortcut {
|
||
continue
|
||
}
|
||
if flag.Required {
|
||
if !flagNameProvided(cmd, flag) {
|
||
message := strings.TrimSpace(flag.RequiredError)
|
||
if message == "" {
|
||
message = fmt.Sprintf("缺少必填参数 --%s", flag.Name)
|
||
}
|
||
return apperrors.NewValidation(message)
|
||
}
|
||
switch flag.Kind {
|
||
case KindStringSlice:
|
||
if !sliceHasValue(sliceValue(cmd, flag)) {
|
||
return apperrors.NewValidation(fmt.Sprintf("必填参数 --%s 不能为空", flag.Name))
|
||
}
|
||
case KindString:
|
||
if strings.TrimSpace(EffectiveValue(cmd, flag)) == "" {
|
||
return apperrors.NewValidation(fmt.Sprintf("必填参数 --%s 不能为空", flag.Name))
|
||
}
|
||
}
|
||
}
|
||
if err := validateEnum(cmd, flag); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
var plain []string
|
||
for _, flag := range flags {
|
||
if flag.Required && flag.ValidationMode != ValidationShortcut &&
|
||
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.ValidationMode == ValidationShortcut ||
|
||
(flag.EnvVar == "" && flag.RequiredHint == "") {
|
||
continue
|
||
}
|
||
if !hasEffectiveValue(cmd, flag) {
|
||
hint := flag.RequiredHint
|
||
if hint == "" {
|
||
hint = fmt.Sprintf("flag --%s is required", flag.Name)
|
||
}
|
||
return apperrors.NewValidation(hint)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ValidateEnums enforces the accepted values declared on changed flags. A
|
||
// registration default does not trigger validation, matching Shortcut's
|
||
// historical behavior.
|
||
func ValidateEnums(cmd *cobra.Command, flags []FlagSpec) error {
|
||
for _, flag := range flags {
|
||
if flag.ValidationMode == ValidationShortcut {
|
||
continue
|
||
}
|
||
if err := validateEnum(cmd, flag); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateEnum enforces the declared accepted values. Explicit CLI tokens
|
||
// (main name or alias) are always validated. An env-sourced value is validated
|
||
// too when the flag was not explicitly provided but resolves a non-empty
|
||
// EnvVar value: the env path feeds Required checks and BuildArgs, so an
|
||
// out-of-enum env value must not ship to the backend (bool flags are skipped —
|
||
// booleans have no env fallback). Env still never counts as "provided" for
|
||
// explicit-token checks; this only closes the enum gap. Registration defaults
|
||
// remain unvalidated.
|
||
func validateEnum(cmd *cobra.Command, flag FlagSpec) error {
|
||
if len(flag.Enum) == 0 {
|
||
return nil
|
||
}
|
||
var values []string
|
||
switch {
|
||
case flagNameProvided(cmd, flag):
|
||
if flag.Kind == KindStringSlice {
|
||
values = sliceValue(cmd, flag)
|
||
} else {
|
||
values = []string{EffectiveValue(cmd, flag)}
|
||
}
|
||
case flag.Kind != KindBool && flag.Kind != KindStringSlice && flag.EnvVar != "":
|
||
// Not explicitly provided: validate the env fallback when it resolves
|
||
// a non-empty string-ish value. Slices never consume env (explicit
|
||
// tokens only), and a value invalid for the flag kind — e.g. a
|
||
// non-integer env value on KindInt — is left to the integer parse
|
||
// path, which reports the precise error.
|
||
if env := strings.TrimSpace(os.Getenv(flag.EnvVar)); env != "" {
|
||
values = []string{env}
|
||
}
|
||
}
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
valid := false
|
||
for _, allowed := range flag.Enum {
|
||
if value == allowed {
|
||
valid = true
|
||
break
|
||
}
|
||
}
|
||
if !valid {
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"参数 --%s 取值 %q 不合法,允许值:%s",
|
||
flag.Name, value, strings.Join(flag.Enum, ", ")))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// flagNameProvided reports whether the flag was explicitly passed on the
|
||
// command line under its main name or any declared alias. Registration
|
||
// defaults and environment variables do not count as provided: those feed the
|
||
// effective-value chain, not the explicit-token checks used by Shortcut
|
||
// Required. validateEnum additionally validates a resolved EnvVar value, but
|
||
// env still never counts as provided.
|
||
func flagNameProvided(cmd *cobra.Command, flag FlagSpec) bool {
|
||
if cmd.Flags().Changed(flag.Name) {
|
||
return true
|
||
}
|
||
for _, alias := range flag.Aliases {
|
||
if cmd.Flags().Changed(alias) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 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:
|
||
// Bool presence is any declared name changed (booleans have no
|
||
// env/default fallback semantics).
|
||
return flagNameProvided(cmd, flag)
|
||
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
|
||
}
|
||
|
||
// bindKey is the toolArgs key for a flag: the declared Bind, or the flag name
|
||
// converted kebab-to-camel.
|
||
//
|
||
// The alternative default — sending the kebab name verbatim — is accepted by no
|
||
// backend, so a forgotten Bind used to produce a silently wrong payload key;
|
||
// deriving it makes that failure mode correct instead.
|
||
//
|
||
// It is deliberately *not* a licence to drop the explicit declarations: Bind is
|
||
// also the declared source of the Schema property mapping, and relying on this
|
||
// derivation downgrades that field's provenance to flag_name_inference, which
|
||
// the "declared or annotated, never inference-only" rule forbids (measured:
|
||
// dropping the 93 mechanical declarations produced 552 lines of catalog drift).
|
||
func bindKey(flag FlagSpec) string {
|
||
if flag.Bind != "" {
|
||
return flag.Bind
|
||
}
|
||
parts := strings.Split(flag.Name, "-")
|
||
if len(parts) <= 1 {
|
||
return flag.Name
|
||
}
|
||
for i := 1; i < len(parts); i++ {
|
||
if parts[i] != "" {
|
||
parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
|
||
}
|
||
}
|
||
return strings.Join(parts, "")
|
||
}
|
||
|
||
// 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 := bindKey(flag)
|
||
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 (main OR alias): explicit false is still
|
||
// sent (matching the handwritten "transmit on Changed" semantics).
|
||
if flagNameProvided(cmd, flag) {
|
||
v, _ := cmd.Flags().GetBool(flag.Name)
|
||
if !cmd.Flags().Changed(flag.Name) {
|
||
for _, alias := range flag.Aliases {
|
||
if cmd.Flags().Changed(alias) {
|
||
v, _ = cmd.Flags().GetBool(alias)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
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
|
||
}
|
||
// Required is checked on the pre-transform string. A transform that
|
||
// collapses separator-only input ("," / ";") to nil/empty must still
|
||
// fail required flags locally — otherwise BuildArgs would omit the
|
||
// key and ConfirmFirst write paths could reach the backend.
|
||
if value == nil || emptyTransformResult(value) {
|
||
if flag.Required {
|
||
message := strings.TrimSpace(flag.RequiredHint)
|
||
if message == "" {
|
||
message = strings.TrimSpace(flag.RequiredError)
|
||
}
|
||
if message == "" {
|
||
message = fmt.Sprintf("必填参数 --%s 不能为空", flag.Name)
|
||
}
|
||
return nil, apperrors.NewValidation(message)
|
||
}
|
||
continue
|
||
}
|
||
toolArgs[bind] = value
|
||
continue
|
||
}
|
||
toolArgs[bind] = effective
|
||
}
|
||
return toolArgs, nil
|
||
}
|
||
|
||
// emptyTransformResult reports whether a Transform produced an empty payload
|
||
// that must not satisfy a Required flag (nil is handled by the caller).
|
||
func emptyTransformResult(value any) bool {
|
||
switch v := value.(type) {
|
||
case string:
|
||
return strings.TrimSpace(v) == ""
|
||
case []string:
|
||
return len(v) == 0
|
||
case []any:
|
||
return len(v) == 0
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 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:
|
||
if len(constraint.Flags) < 2 {
|
||
panic(fmt.Sprintf("command %q: constraint %s needs at least two flags", use, constraint.Kind))
|
||
}
|
||
case Custom:
|
||
if len(constraint.Flags) < 1 {
|
||
panic(fmt.Sprintf("command %q: constraint %s needs at least one flag", use, constraint.Kind))
|
||
}
|
||
if strings.TrimSpace(constraint.Description) == "" {
|
||
panic(fmt.Sprintf("command %q: custom constraint requires a description", use))
|
||
}
|
||
default:
|
||
panic(fmt.Sprintf("command %q: unknown constraint kind %q", 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
|
||
// counts Changed on any declared name (booleans have no env fallback
|
||
// semantics).
|
||
func constraintProvided(cmd *cobra.Command, flag FlagSpec) bool {
|
||
switch flag.Kind {
|
||
case KindBool:
|
||
return flagNameProvided(cmd, flag)
|
||
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)))
|
||
}
|
||
case Custom:
|
||
// The declaration is published and rendered in help. Its actual
|
||
// command-specific rule remains owned by Spec.Validate.
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ConfirmSafety enforces the command's declared confirmation requirement.
|
||
// Effect, risk and idempotency are metadata only and never imply confirmation.
|
||
// Semantics:
|
||
//
|
||
// - read-only, --dry-run, --yes, or --user-say-yes → nil (proceed);
|
||
// - interactive yes/y → nil;
|
||
// - interactive decline → validation "用户取消了操作" (existing command path);
|
||
// - no interactive answer (EOF / closed stdin) → confirmation_required.
|
||
//
|
||
// EOF must not be treated as decline: that silently drops writes in agent/CI.
|
||
// Prompt text is terminal-gated; a readable piped answer is still honored for
|
||
// non-Sheet leaves. Sheet destructive commands keep a separate --yes-only
|
||
// outer gate (helpers.protectSheetMutationCommand) so agents cannot authorize
|
||
// those via stdin alone.
|
||
func ConfirmSafety(cmd *cobra.Command, safety contract.SafetySpec) error {
|
||
if strings.TrimSpace(safety.Confirmation) != "user_required" ||
|
||
confirmationBypass(cmd) {
|
||
return nil
|
||
}
|
||
// Only print the interactive prompt on a real terminal. In non-interactive
|
||
// environments (agent/CI: pipe, closed stdin, /dev/null) the prompt is
|
||
// noise on stderr ahead of the structured confirmation_required error —
|
||
// callers there should pass --yes/--dry-run. A piped answer
|
||
// (printf 'yes\n' | cmd) is still honored for general ConfirmSafety; Sheet
|
||
// mutations additionally require --yes via protectSheetMutationCommand.
|
||
if stdinIsTerminalFn(cmd.InOrStdin()) {
|
||
fmt.Fprintf(
|
||
cmd.ErrOrStderr(),
|
||
"即将执行 %s(effect=%s, risk=%s),确认继续?(yes/no): ",
|
||
cmd.CommandPath(), strings.TrimSpace(safety.Effect), strings.TrimSpace(safety.Risk),
|
||
)
|
||
}
|
||
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("用户取消了操作")
|
||
}
|
||
|
||
// stdinIsTerminalFn is the ConfirmSafety TTY probe. Tests may stub it; production
|
||
// keeps the real ioctl-backed check.
|
||
var stdinIsTerminalFn = stdinIsTerminal
|
||
|
||
// stdinIsTerminal reports whether the given input is a real terminal. Only
|
||
// *os.File inputs can be terminals; cobra SetIn buffers and other readers are
|
||
// treated as non-interactive. An ioctl-level TTY check is required — a plain
|
||
// character-device stat would misclassify redirects like `< /dev/null`.
|
||
func stdinIsTerminal(in io.Reader) bool {
|
||
file, ok := in.(*os.File)
|
||
if !ok {
|
||
return false
|
||
}
|
||
fd := file.Fd()
|
||
return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)
|
||
}
|
||
|
||
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).
|
||
//
|
||
// It ORs across flagsets, matching confirmationBypass. Returning the first
|
||
// resolving flagset instead let a leaf-local --dry-run (default false) shadow a
|
||
// root persistent --dry-run=true, so confirmation could be bypassed as a dry run
|
||
// while Ctx.DryRun() reported false — a real write with no confirmation.
|
||
func BoolFlag(cmd *cobra.Command, name string) bool {
|
||
if cmd == nil {
|
||
return false
|
||
}
|
||
for _, get := range boolFlagGetters(cmd) {
|
||
if v, err := get(name); err == nil && v {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// confirmationBypass reports whether any flagset on the command tree has
|
||
// --yes / --dry-run / --user-say-yes set true. BoolFlag already ORs across
|
||
// flagsets, so a leaf-local --dry-run defaulting to false cannot shadow a
|
||
// root persistent --dry-run=true (markdown overwrite / agent global dry-run).
|
||
func confirmationBypass(cmd *cobra.Command) bool {
|
||
for _, name := range []string{"yes", "dry-run", "user-say-yes"} {
|
||
if BoolFlag(cmd, name) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func boolFlagGetters(cmd *cobra.Command) []func(string) (bool, error) {
|
||
if cmd == nil {
|
||
return nil
|
||
}
|
||
getters := []func(string) (bool, error){
|
||
cmd.Flags().GetBool,
|
||
cmd.InheritedFlags().GetBool,
|
||
}
|
||
if root := cmd.Root(); root != nil {
|
||
getters = append(getters, root.PersistentFlags().GetBool)
|
||
}
|
||
return getters
|
||
}
|
||
|
||
// embedContractIntoSchema projects Spec declaration onto the live Cobra
|
||
// leaf as the final Schema payload (dws.schema.*). Assembly pass-throughs
|
||
// these annotations; declared fields are the final source with no parallel
|
||
// authority.
|
||
func embedContractIntoSchema(cmd *cobra.Command, spec Spec) {
|
||
if spec.ParameterProjection == ProjectCobraParameters {
|
||
for _, flag := range spec.Flags {
|
||
name := strings.TrimSpace(flag.Name)
|
||
if name == "" || flag.Hidden {
|
||
continue
|
||
}
|
||
if flag.Required || flag.MarkRequired {
|
||
runtimeannotate.AnnotateRuntimeRequiredFlags(cmd, name)
|
||
}
|
||
if len(flag.Enum) > 0 {
|
||
runtimeannotate.AnnotateRuntimeFlagEnum(cmd, name, flag.Enum...)
|
||
}
|
||
// Same class as Enum: a declared rule the collected identity
|
||
// cannot derive. Without it a credential that is mandatory under
|
||
// one identity is published as plainly optional.
|
||
if flag.RequiredWhen != "" {
|
||
runtimeannotate.AnnotateRuntimeFlagRequiredWhen(cmd, name, flag.RequiredWhen)
|
||
}
|
||
}
|
||
embedContractDecl(cmd, spec)
|
||
return
|
||
}
|
||
|
||
runtimeannotate.AnnotateRuntimeContract(cmd)
|
||
required := make([]string, 0, len(spec.Flags))
|
||
for _, flag := range spec.Flags {
|
||
name := strings.TrimSpace(flag.Name)
|
||
if name == "" || flag.Hidden {
|
||
continue
|
||
}
|
||
requiredFlag := flag.Required || flag.MarkRequired
|
||
runtimeannotate.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 != "" {
|
||
runtimeannotate.AnnotateRuntimeFlagDescription(cmd, name, desc)
|
||
}
|
||
if flag.RequiredWhen != "" {
|
||
runtimeannotate.AnnotateRuntimeFlagRequiredWhen(cmd, name, flag.RequiredWhen)
|
||
}
|
||
if flag.Format != "" {
|
||
runtimeannotate.AnnotateRuntimeFlagFormat(cmd, name, flag.Format)
|
||
}
|
||
if flag.Example != "" {
|
||
runtimeannotate.AnnotateRuntimeFlagExample(cmd, name, flag.Example)
|
||
}
|
||
if len(flag.Enum) > 0 {
|
||
runtimeannotate.AnnotateRuntimeFlagEnum(cmd, name, flag.Enum...)
|
||
}
|
||
if requiredFlag {
|
||
required = append(required, name)
|
||
}
|
||
}
|
||
runtimeannotate.AnnotateRuntimeRequiredFlags(cmd, required...)
|
||
embedContractDecl(cmd, spec)
|
||
}
|
||
|
||
// embedContractDecl does a light runtime write: only when ContractDecl is authored,
|
||
// convert once through contractfinal.RegisterRuntimeContractFinal (annotate + store).
|
||
func embedContractDecl(cmd *cobra.Command, spec Spec) {
|
||
if spec.Contract.empty() {
|
||
return
|
||
}
|
||
AttachContract(cmd, spec.Safety, spec.Contract, spec.Short, spec.Long)
|
||
}
|
||
|
||
// AttachContract registers a ContractFinal overlay on an existing leaf without
|
||
// replacing its RunE/Execute body. Used to migrate reviewed facts onto helpers
|
||
// while keeping execution substance frozen. Overwrites any prior ContractFinal
|
||
// on cmd; does not alter an already-installed ConfirmSafety closure.
|
||
//
|
||
// Production registration always goes through contractfinal.RegisterRuntimeContractFinal
|
||
// (annotate + store). Do not call the store/Register APIs except via
|
||
// contractfinal — no cli-root wrapper exists.
|
||
//
|
||
// Title/Description stored on the payload are the declared Contract values
|
||
// only. Catalog assembly may prefer Cobra Long for delivered description
|
||
// (Short never enters description) and must stamp provenance to the real
|
||
// winner (cobra_help vs contract_final). Declared Title still wins over Short.
|
||
func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl ContractDecl, short, long string) {
|
||
if cmd == nil || decl.empty() {
|
||
return
|
||
}
|
||
// short/long remain in the signature so call sites keep passing Cobra prose;
|
||
// Catalog assembly (not this store) prefers Long for description and may
|
||
// use Short only as a title fallback after declared Title.
|
||
_, _ = short, long
|
||
// Reuse NewCommand's completeness rules so bind-time attaches cannot ship
|
||
// a partial declaration that would only fail in generated artifacts.
|
||
validateContractDecl(Spec{Use: cmd.Name(), Safety: safety, Contract: decl})
|
||
validateSafetySpec(Spec{Use: cmd.Name(), Safety: safety})
|
||
|
||
payload := contract.ContractFinalPayload{
|
||
Title: strings.TrimSpace(decl.Title),
|
||
Description: strings.TrimSpace(decl.Description),
|
||
Safety: schemaSafetyFromDecl(safety),
|
||
}
|
||
if n := len(decl.Positionals); n > 0 {
|
||
payload.Positionals = append([]contract.RuntimeSchemaPositional(nil), decl.Positionals...)
|
||
}
|
||
if decl.DryRun != nil && strings.TrimSpace(decl.DryRun.PreviewKind) != "" {
|
||
d := *decl.DryRun
|
||
d.PreviewKind = strings.TrimSpace(d.PreviewKind)
|
||
payload.DryRun = &d
|
||
}
|
||
if decl.Result != nil {
|
||
result, err := contract.NormalizeResultSpec(decl.Result, decl.Identity.CanonicalPath)
|
||
if err != nil {
|
||
panic(fmt.Sprintf("command %q has invalid Contract.Result: %v", cmd.Name(), err))
|
||
}
|
||
payload.Result = result
|
||
}
|
||
if decl.Pagination != nil {
|
||
pagination, err := contract.NormalizePaginationSpec(decl.Pagination, decl.Identity.CanonicalPath)
|
||
if err != nil {
|
||
panic(fmt.Sprintf("command %q has invalid Contract.Pagination: %v", cmd.Name(), err))
|
||
}
|
||
payload.Pagination = pagination
|
||
}
|
||
if decl.Interface != nil {
|
||
iface := &contract.InterfaceSpec{
|
||
Mode: strings.TrimSpace(decl.Interface.Mode),
|
||
Availability: strings.TrimSpace(decl.Interface.Availability),
|
||
Reason: strings.TrimSpace(decl.Interface.Reason),
|
||
}
|
||
if decl.Interface.Ref != nil {
|
||
ref := *decl.Interface.Ref
|
||
ref.ProductID = strings.TrimSpace(ref.ProductID)
|
||
ref.RPCName = strings.TrimSpace(ref.RPCName)
|
||
if ref.ProductID != "" || ref.RPCName != "" {
|
||
iface.Ref = &ref
|
||
}
|
||
}
|
||
if iface.Mode != "" || iface.Ref != nil || iface.Availability != "" || iface.Reason != "" {
|
||
payload.Interface = iface
|
||
}
|
||
}
|
||
if sel := decl.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 {
|
||
copied := sel
|
||
copied.AgentSummary = strings.TrimSpace(sel.AgentSummary)
|
||
copied.AgentSummarySource = "corecmd.ContractDecl"
|
||
copied.SourceRefs = []string{"corecmd.ContractDecl"}
|
||
copied.MetadataSource = "corecmd.contract"
|
||
copied.Reviewed = nil
|
||
// Match the product path: trim-free, deduped selection lists so leaf
|
||
// and product pass-throughs never diverge.
|
||
copied = copied.Normalized()
|
||
payload.Selection = &copied
|
||
}
|
||
if id := decl.Identity; strings.TrimSpace(id.ProductID) != "" || strings.TrimSpace(id.Name) != "" ||
|
||
strings.TrimSpace(id.CanonicalPath) != "" || strings.TrimSpace(id.CLIPath) != "" ||
|
||
strings.TrimSpace(id.PrimaryCLIPath) != "" {
|
||
copied := id
|
||
copied.ProductID = strings.TrimSpace(id.ProductID)
|
||
copied.SourceProductID = strings.TrimSpace(id.SourceProductID)
|
||
copied.Name = strings.TrimSpace(id.Name)
|
||
copied.Path = strings.TrimSpace(id.Path)
|
||
copied.CLIName = strings.TrimSpace(id.CLIName)
|
||
copied.CanonicalPath = strings.TrimSpace(id.CanonicalPath)
|
||
copied.CLIPath = strings.TrimSpace(id.CLIPath)
|
||
copied.PrimaryCLIPath = strings.TrimSpace(id.PrimaryCLIPath)
|
||
if copied.CLIPath == "" {
|
||
copied.CLIPath = copied.PrimaryCLIPath
|
||
}
|
||
if copied.PrimaryCLIPath == "" {
|
||
copied.PrimaryCLIPath = copied.CLIPath
|
||
}
|
||
copied.Group = strings.TrimSpace(id.Group)
|
||
copied.Source = strings.TrimSpace(id.Source)
|
||
if len(id.Aliases) > 0 {
|
||
copied.Aliases = make([]string, 0, len(id.Aliases))
|
||
for _, alias := range id.Aliases {
|
||
copied.Aliases = append(copied.Aliases, strings.TrimSpace(alias))
|
||
}
|
||
}
|
||
payload.Identity = &copied
|
||
}
|
||
if len(decl.Parameters) > 0 {
|
||
payload.Parameters = append([]contract.ParamDecl(nil), decl.Parameters...)
|
||
}
|
||
contractfinal.RegisterRuntimeContractFinal(cmd, payload)
|
||
}
|
||
|
||
// schemaSafetyFromDecl copies the single command SafetySpec into the final
|
||
// Schema payload. The zero value keeps the historical read-only default.
|
||
func schemaSafetyFromDecl(safety contract.SafetySpec) *contract.SafetySpec {
|
||
out := effectiveSafetySpec(safety)
|
||
out.EffectSource = "corecmd.contract"
|
||
return &out
|
||
}
|
||
|
||
func effectiveSafetySpec(safety contract.SafetySpec) contract.SafetySpec {
|
||
out := contract.SafetySpec{
|
||
Effect: strings.TrimSpace(safety.Effect),
|
||
Risk: strings.TrimSpace(safety.Risk),
|
||
Confirmation: strings.TrimSpace(safety.Confirmation),
|
||
Idempotency: strings.TrimSpace(safety.Idempotency),
|
||
}
|
||
if out.Effect == "" && out.Risk == "" && out.Confirmation == "" && out.Idempotency == "" {
|
||
out.Effect = "read"
|
||
out.Risk = "low"
|
||
out.Confirmation = "not_required"
|
||
out.Idempotency = "idempotent"
|
||
}
|
||
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).
|
||
//
|
||
// When a group still has hidden siblings, the full declared flag list is
|
||
// projected (not collapsed to a single visible "required"). ValidateConstraints
|
||
// accepts any member of the declared group — including hidden — so marking the
|
||
// sole visible flag required would falsely claim declare ≡ execute.
|
||
func AnnotateConstraints(cmd *cobra.Command, constraints []Constraint) {
|
||
var projected runtimeannotate.RuntimeSchemaConstraints
|
||
var required []string
|
||
for _, constraint := range constraints {
|
||
visible := make([]string, 0, len(constraint.Flags))
|
||
for _, name := range constraint.Flags {
|
||
flag := cmd.Flags().Lookup(name)
|
||
if flag != nil && !flag.Hidden {
|
||
visible = append(visible, name)
|
||
}
|
||
}
|
||
flags := visible
|
||
if len(visible) < len(constraint.Flags) {
|
||
flags = append([]string(nil), constraint.Flags...)
|
||
}
|
||
switch constraint.Kind {
|
||
case AtLeastOne:
|
||
if len(flags) == 1 {
|
||
required = append(required, flags[0])
|
||
} else if len(flags) > 1 {
|
||
projected.RequireOneOf = append(projected.RequireOneOf, flags)
|
||
}
|
||
case ExactlyOne:
|
||
if len(flags) == 1 {
|
||
required = append(required, flags[0])
|
||
} else if len(flags) > 1 {
|
||
projected.RequireOneOf = append(projected.RequireOneOf, flags)
|
||
projected.MutuallyExclusive = append(projected.MutuallyExclusive, flags)
|
||
}
|
||
case MutuallyExclusive:
|
||
if len(flags) > 1 {
|
||
projected.MutuallyExclusive = append(projected.MutuallyExclusive, flags)
|
||
}
|
||
}
|
||
}
|
||
runtimeannotate.AnnotateRuntimeRequiredFlags(cmd, required...)
|
||
runtimeannotate.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
|
||
}
|