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
437 lines
10 KiB
Go
437 lines
10 KiB
Go
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())
|
|
}
|