Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

151 lines
5.2 KiB
Go

package framework
import (
"fmt"
"regexp"
"strings"
cliservice "github.com/tutti-os/tutti/services/tuttid/service/cli"
)
var kebabNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`)
func ValidateSpec[T any](spec CommandSpec[T]) error {
if strings.TrimSpace(spec.ID) == "" {
return fmt.Errorf("%w: id is required", cliservice.ErrInvalidCommand)
}
if len(spec.Path) == 0 {
return fmt.Errorf("%w: path is required", cliservice.ErrInvalidCommand)
}
for _, segment := range spec.Path {
if !kebabNamePattern.MatchString(segment) {
return fmt.Errorf("%w: invalid path segment %q", cliservice.ErrInvalidCommand, segment)
}
}
if strings.TrimSpace(spec.Summary) == "" {
return fmt.Errorf("%w: summary is required", cliservice.ErrInvalidCommand)
}
if strings.TrimSpace(spec.Description) == "" {
return fmt.Errorf("%w: description is required", cliservice.ErrInvalidCommand)
}
if spec.Kind != KindList && spec.Kind != KindGet && spec.Kind != KindAction {
return fmt.Errorf("%w: invalid command kind %q", cliservice.ErrInvalidCommand, spec.Kind)
}
if spec.Run == nil {
return fmt.Errorf("%w: run is required", cliservice.ErrInvalidCommand)
}
if spec.Output.DefaultMode == "" {
return fmt.Errorf("%w: default output mode is required", cliservice.ErrInvalidCommand)
}
if err := validateExecution(spec.Execution, spec.Output); err != nil {
return err
}
if err := validateInputs(spec.Inputs); err != nil {
return err
}
return validateOutput(spec.Kind, spec.Output)
}
func validateExecution(execution *cliservice.CommandExecution, output OutputSpec) error {
if execution == nil {
return nil
}
if execution.Mode != cliservice.CommandExecutionModeWait {
return fmt.Errorf("%w: invalid execution mode %q", cliservice.ErrInvalidCommand, execution.Mode)
}
if output.DefaultMode != cliservice.OutputModeJSON || output.Continuation == nil {
return fmt.Errorf("%w: wait execution requires json default output and a continuation formatter", cliservice.ErrInvalidCommand)
}
return nil
}
func validateInputs(input InputSpec) error {
seen := map[string]struct{}{}
for _, field := range input.Fields {
if !kebabNamePattern.MatchString(field.Name) {
return fmt.Errorf("%w: invalid input name %q", cliservice.ErrInvalidCommand, field.Name)
}
if _, ok := seen[field.Name]; ok {
return fmt.Errorf("%w: duplicate input name %q", cliservice.ErrInvalidCommand, field.Name)
}
seen[field.Name] = struct{}{}
if field.Required && strings.Contains(field.Name, "topic-id") && strings.TrimSpace(field.Hint) == "" {
return fmt.Errorf("%w: required input %q needs a recovery hint", cliservice.ErrInvalidCommand, field.Name)
}
}
return nil
}
func validateOutput(kind CommandKind, output OutputSpec) error {
switch output.DefaultMode {
case cliservice.OutputModeJSON:
if !hasJSONFormatter(output) {
return fmt.Errorf("%w: default json output has no formatter", cliservice.ErrInvalidCommand)
}
case cliservice.OutputModeTable:
if output.Table == nil || output.Table.Rows == nil {
return fmt.Errorf("%w: default table output has no formatter", cliservice.ErrInvalidCommand)
}
case cliservice.OutputModePlain:
if output.PlainText == nil {
return fmt.Errorf("%w: default plain output has no formatter", cliservice.ErrInvalidCommand)
}
case cliservice.OutputModeMarkdown:
if output.Markdown == nil {
return fmt.Errorf("%w: default markdown output has no formatter", cliservice.ErrInvalidCommand)
}
default:
return fmt.Errorf("%w: invalid default output mode %q", cliservice.ErrInvalidCommand, output.DefaultMode)
}
if output.JSON || output.DefaultMode == cliservice.OutputModeJSON || output.JSONValue != nil || len(output.JSONViews) > 0 {
if err := validateJSONOutput(kind, output); err != nil {
return err
}
}
if kind == KindList && !output.ListCompact {
return fmt.Errorf("%w: list command must declare compact output or opt out", cliservice.ErrInvalidCommand)
}
return nil
}
func validateJSONOutput(kind CommandKind, output OutputSpec) error {
expectedView := defaultViewForKind(kind)
if output.DefaultView == "" {
return fmt.Errorf("%w: json output default view is required", cliservice.ErrInvalidCommand)
}
if output.DefaultView != expectedView {
return fmt.Errorf("%w: %s command json default view must be %q", cliservice.ErrInvalidCommand, kind, expectedView)
}
if len(output.JSONViews) > 0 {
if output.JSONViews[output.DefaultView] == nil {
return fmt.Errorf("%w: json output has no formatter for default view %q", cliservice.ErrInvalidCommand, output.DefaultView)
}
if output.JSONValue != nil && !output.RawJSON {
return fmt.Errorf("%w: json output cannot mix views with bare JSONValue", cliservice.ErrInvalidCommand)
}
}
if output.JSONValue != nil {
if !output.RawJSON {
return fmt.Errorf("%w: bare JSONValue requires RawJSON", cliservice.ErrInvalidCommand)
}
if strings.TrimSpace(output.RawJSONReason) == "" {
return fmt.Errorf("%w: RawJSON requires a reason", cliservice.ErrInvalidCommand)
}
}
return nil
}
func defaultViewForKind(kind CommandKind) OutputView {
if kind == KindGet {
return ViewDetail
}
return ViewSummary
}
func hasJSONFormatter(output OutputSpec) bool {
if output.DefaultView != "" && output.JSONViews[output.DefaultView] != nil {
return true
}
return output.JSONValue != nil
}