源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
package framework
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
cliservice "github.com/tutti-os/tutti/services/tuttid/service/cli"
|
||||
)
|
||||
|
||||
func FromStruct[T any]() InputSpec {
|
||||
var zero T
|
||||
typ := reflect.TypeOf(zero)
|
||||
if typ == nil {
|
||||
typ = reflect.TypeOf((*T)(nil)).Elem()
|
||||
}
|
||||
if typ.Kind() == reflect.Pointer {
|
||||
typ = typ.Elem()
|
||||
}
|
||||
spec := InputSpec{InputType: typ.String(), AcceptsInput: false}
|
||||
if typ.Kind() != reflect.Struct {
|
||||
return spec
|
||||
}
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if field.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(field.Tag.Get("cli"))
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = kebabCase(field.Name)
|
||||
}
|
||||
fieldSpec := FieldSpec{
|
||||
Name: name,
|
||||
Type: schemaType(field.Type),
|
||||
Description: strings.TrimSpace(field.Tag.Get("description")),
|
||||
Hidden: field.Tag.Get("hidden") == "true",
|
||||
AdvertisedRequired: field.Tag.Get("advertise-required") == "true",
|
||||
Hint: strings.TrimSpace(field.Tag.Get("hint")),
|
||||
Default: typedDefault(field.Type, field.Tag.Get("default")),
|
||||
}
|
||||
applyValidateTag(&fieldSpec, field.Tag.Get("validate"))
|
||||
fieldSpec.Enum = parseCSVTag(field.Tag.Get("enum"))
|
||||
spec.Fields = append(spec.Fields, fieldSpec)
|
||||
spec.AcceptsInput = true
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
func Schema(input InputSpec) map[string]any {
|
||||
schema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
properties := schema["properties"].(map[string]any)
|
||||
required := []string{}
|
||||
for _, field := range input.Fields {
|
||||
if field.Hidden {
|
||||
continue
|
||||
}
|
||||
propertyType := field.Type
|
||||
if propertyType == "" {
|
||||
propertyType = "string"
|
||||
}
|
||||
property := map[string]any{"type": propertyType}
|
||||
if propertyType == "array" {
|
||||
property["items"] = map[string]any{"type": "string"}
|
||||
}
|
||||
if field.Description != "" {
|
||||
property["description"] = field.Description
|
||||
}
|
||||
if field.Min != nil {
|
||||
property["minimum"] = *field.Min
|
||||
}
|
||||
if field.Max != nil {
|
||||
property["maximum"] = *field.Max
|
||||
}
|
||||
if len(field.Enum) > 0 {
|
||||
property["enum"] = field.Enum
|
||||
}
|
||||
if field.Default != nil {
|
||||
property["default"] = field.Default
|
||||
}
|
||||
properties[field.Name] = property
|
||||
if field.Required || field.AdvertisedRequired {
|
||||
required = append(required, field.Name)
|
||||
}
|
||||
}
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func BindInput[T any](spec InputSpec, input map[string]any) (T, error) {
|
||||
var result T
|
||||
if input == nil {
|
||||
input = map[string]any{}
|
||||
}
|
||||
fields := map[string]FieldSpec{}
|
||||
for _, field := range spec.Fields {
|
||||
fields[field.Name] = field
|
||||
}
|
||||
|
||||
value := reflect.ValueOf(&result).Elem()
|
||||
typ := value.Type()
|
||||
if typ.Kind() == reflect.Pointer {
|
||||
if value.IsNil() {
|
||||
value.Set(reflect.New(typ.Elem()))
|
||||
}
|
||||
value = value.Elem()
|
||||
typ = value.Type()
|
||||
}
|
||||
if typ.Kind() != reflect.Struct {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
structField := typ.Field(i)
|
||||
if structField.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(structField.Tag.Get("cli"))
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = kebabCase(structField.Name)
|
||||
}
|
||||
fieldSpec, ok := fields[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
raw, exists := input[name]
|
||||
if (!exists || raw == nil) && fieldSpec.Default != nil {
|
||||
raw = fieldSpec.Default
|
||||
exists = true
|
||||
}
|
||||
if !exists || raw == nil {
|
||||
if fieldSpec.Required {
|
||||
return result, missingRequiredError(fieldSpec)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fieldSpec.Required && strings.TrimSpace(fmt.Sprint(raw)) == "" {
|
||||
return result, missingRequiredError(fieldSpec)
|
||||
}
|
||||
if err := setFieldValue(value.Field(i), fieldSpec, raw); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func typedDefault(fieldType reflect.Type, raw string) any {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
for fieldType.Kind() == reflect.Pointer {
|
||||
fieldType = fieldType.Elem()
|
||||
}
|
||||
switch fieldType.Kind() {
|
||||
case reflect.String:
|
||||
return raw
|
||||
case reflect.Bool:
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err == nil {
|
||||
return value
|
||||
}
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err == nil {
|
||||
return value
|
||||
}
|
||||
case reflect.Float32, reflect.Float64:
|
||||
value, err := strconv.ParseFloat(raw, 64)
|
||||
if err == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setFieldValue(field reflect.Value, spec FieldSpec, raw any) error {
|
||||
if !field.CanSet() {
|
||||
return nil
|
||||
}
|
||||
if field.Kind() == reflect.Pointer {
|
||||
value := reflect.New(field.Type().Elem())
|
||||
if err := setFieldValue(value.Elem(), spec, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
field.Set(value)
|
||||
return nil
|
||||
}
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return invalidInputError(spec.Name)
|
||||
}
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if err := validateEnum(spec, trimmed); err != nil {
|
||||
return err
|
||||
}
|
||||
field.SetString(trimmed)
|
||||
case reflect.Slice:
|
||||
if field.Type().Elem().Kind() != reflect.String {
|
||||
return invalidInputError(spec.Name)
|
||||
}
|
||||
values, ok := stringValues(raw)
|
||||
if !ok {
|
||||
return invalidInputError(spec.Name)
|
||||
}
|
||||
slice := reflect.MakeSlice(field.Type(), 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
slice = reflect.Append(slice, reflect.ValueOf(trimmed))
|
||||
}
|
||||
field.Set(slice)
|
||||
case reflect.Bool:
|
||||
value, err := parseBool(raw)
|
||||
if err != nil {
|
||||
return invalidInputError(spec.Name)
|
||||
}
|
||||
field.SetBool(value)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
value, err := parseInt(raw)
|
||||
if err != nil {
|
||||
return invalidInputError(spec.Name)
|
||||
}
|
||||
if spec.Min != nil && value < *spec.Min {
|
||||
return invalidInputErrorWithReason(spec.Name, fmt.Sprintf("must be >= %d", *spec.Min))
|
||||
}
|
||||
if spec.Max != nil && value > *spec.Max {
|
||||
return invalidInputErrorWithReason(spec.Name, fmt.Sprintf("must be <= %d", *spec.Max))
|
||||
}
|
||||
field.SetInt(value)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
value, err := parseFloat(raw)
|
||||
if err != nil {
|
||||
return invalidInputError(spec.Name)
|
||||
}
|
||||
if spec.Min != nil && value < float64(*spec.Min) {
|
||||
return invalidInputErrorWithReason(spec.Name, fmt.Sprintf("must be >= %d", *spec.Min))
|
||||
}
|
||||
if spec.Max != nil && value > float64(*spec.Max) {
|
||||
return invalidInputErrorWithReason(spec.Name, fmt.Sprintf("must be <= %d", *spec.Max))
|
||||
}
|
||||
field.SetFloat(value)
|
||||
default:
|
||||
return invalidInputError(spec.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringValues(raw any) ([]string, bool) {
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
return []string{value}, true
|
||||
case []string:
|
||||
return value, true
|
||||
case []any:
|
||||
result := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
text, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
return result, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseBool(raw any) (bool, error) {
|
||||
if value, ok := raw.(bool); ok {
|
||||
return value, nil
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("invalid bool")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(text)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true, nil
|
||||
case "0", "false", "no", "off":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid bool")
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt(raw any) (int64, error) {
|
||||
switch value := raw.(type) {
|
||||
case int:
|
||||
return int64(value), nil
|
||||
case int64:
|
||||
return value, nil
|
||||
case float64:
|
||||
if value != float64(int64(value)) {
|
||||
return 0, fmt.Errorf("invalid integer")
|
||||
}
|
||||
return int64(value), nil
|
||||
case string:
|
||||
return strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid integer")
|
||||
}
|
||||
}
|
||||
|
||||
func parseFloat(raw any) (float64, error) {
|
||||
switch value := raw.(type) {
|
||||
case int:
|
||||
return float64(value), nil
|
||||
case int64:
|
||||
return float64(value), nil
|
||||
case float32:
|
||||
return float64(value), nil
|
||||
case float64:
|
||||
return value, nil
|
||||
case string:
|
||||
return strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid number")
|
||||
}
|
||||
}
|
||||
|
||||
func missingRequiredError(field FieldSpec) error {
|
||||
message := fmt.Sprintf("required input %q is missing", field.Name)
|
||||
if field.Hint != "" {
|
||||
message += ". " + field.Hint
|
||||
}
|
||||
return fmt.Errorf("%w: %s", cliservice.ErrInvalidInput, message)
|
||||
}
|
||||
|
||||
func invalidInputError(name string) error {
|
||||
return fmt.Errorf("%w: invalid input %q", cliservice.ErrInvalidInput, name)
|
||||
}
|
||||
|
||||
func invalidInputErrorWithReason(name string, reason string) error {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return invalidInputError(name)
|
||||
}
|
||||
return fmt.Errorf("%w: invalid input %q: %s", cliservice.ErrInvalidInput, name, reason)
|
||||
}
|
||||
|
||||
func validateEnum(spec FieldSpec, value string) error {
|
||||
if len(spec.Enum) == 0 || strings.TrimSpace(value) == "" && !spec.Required {
|
||||
return nil
|
||||
}
|
||||
for _, allowed := range spec.Enum {
|
||||
if value == allowed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return invalidInputErrorWithReason(spec.Name, "must be one of "+strings.Join(spec.Enum, ", "))
|
||||
}
|
||||
|
||||
func applyValidateTag(field *FieldSpec, tag string) {
|
||||
for _, part := range strings.Split(tag, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if part == "required" {
|
||||
field.Required = true
|
||||
continue
|
||||
}
|
||||
if raw, ok := strings.CutPrefix(part, "min="); ok {
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
field.Min = &value
|
||||
}
|
||||
continue
|
||||
}
|
||||
if raw, ok := strings.CutPrefix(part, "max="); ok {
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
field.Max = &value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseCSVTag(tag string) []string {
|
||||
if strings.TrimSpace(tag) == "" {
|
||||
return nil
|
||||
}
|
||||
values := []string{}
|
||||
for _, part := range strings.Split(tag, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
values = append(values, part)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func schemaType(typ reflect.Type) string {
|
||||
if typ.Kind() == reflect.Pointer {
|
||||
typ = typ.Elem()
|
||||
}
|
||||
switch typ.Kind() {
|
||||
case reflect.Bool:
|
||||
return "boolean"
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return "integer"
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return "number"
|
||||
case reflect.Slice:
|
||||
return "array"
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
func kebabCase(value string) string {
|
||||
var builder strings.Builder
|
||||
for i, char := range value {
|
||||
if i > 0 && char >= 'A' && char <= 'Z' {
|
||||
builder.WriteByte('-')
|
||||
}
|
||||
builder.WriteRune(char)
|
||||
}
|
||||
return strings.ToLower(builder.String())
|
||||
}
|
||||
Reference in New Issue
Block a user