From 5c3559508f615160300f6e98aab710762b685b75 Mon Sep 17 00:00:00 2001 From: Somansh Reddy Satish Date: Tue, 31 Mar 2026 17:20:04 +0000 Subject: [PATCH] add command.Spec and command.Invocation types New internal/command package with the core type system for generated commands. Spec is the immutable definition (endpoint, method, flags, args, behavioral metadata). Invocation holds per-run resolved values (path params, query params, body). BuildInvocation handles merge order: --json-body first, then positional args, then flags overlay. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/command/spec.go | 245 +++++++++++++++++++++++++ internal/command/spec_test.go | 334 ++++++++++++++++++++++++++++++++++ 2 files changed, 579 insertions(+) create mode 100644 internal/command/spec.go create mode 100644 internal/command/spec_test.go diff --git a/internal/command/spec.go b/internal/command/spec.go new file mode 100644 index 00000000..ebd2f3fe --- /dev/null +++ b/internal/command/spec.go @@ -0,0 +1,245 @@ +package command + +import ( + "fmt" + "net/url" + "slices" + "strconv" + + clierrors "github.com/heygen-com/heygen-cli/internal/errors" + "github.com/spf13/cobra" +) + +// Spec is the generated, immutable definition of a CLI command. +// Codegen produces these from the OpenAPI spec. The builder converts +// them into Cobra commands; the executor reads the HTTP identity and +// behavioral fields when executing a request. +// +// Example (generated): +// +// var VideoList = &command.Spec{ +// Group: "video", Name: "list", Summary: "List videos", +// Endpoint: "/v3/videos", Method: "GET", +// TokenField: "next_token", DataField: "data", +// Flags: []command.FlagSpec{{Name: "limit", Type: "int", Source: "query", JSONName: "limit"}}, +// } +type Spec struct { + // CLI presentation (used by builder, ignored by executor) + Group string // parent command group ("video") + Name string // subcommand name ("list") + Summary string // cobra.Command.Short + Description string // cobra.Command.Long + Args []ArgSpec // positional arguments + Flags []FlagSpec // CLI flags (from query params + body fields) + Examples []string // usage examples shown in --help; mandatory for every command + + // HTTP identity (used by executor) + Endpoint string // "/v3/videos/{video_id}" — template with placeholders + Method string // "GET", "POST", "PUT", "PATCH", "DELETE" + BodyEncoding string // "json", "multipart", or "" (no body). Builder adds -d/--data when "json". + + // Execution behavior (used by executor) + TokenField string // non-empty → paginated; response field with next cursor + DataField string // response field containing the result array (e.g., "data") + PollConfig *PollConfig // non-nil → pollable; defines polling behavior for --wait (future) + Destructive bool // triggers --force confirmation prompt (future) + Columns []Column // TUI table column definitions (future) +} + +// ArgSpec defines a positional argument and where its value is routed. +// +// Unlike FlagSpec (which always maps to --name value), positional args +// have no flag prefix — their meaning comes from position. Target determines +// the destination: +// +// - "path": URL template substitution. heygen video get → PathParams["video_id"] = "abc123" +// - "body": JSON body field. heygen voice speech → Body["text"] = "Hello" +// - "file": Multipart file upload path. heygen asset upload → FilePath = "./video.mp4" +type ArgSpec struct { + Name string // display name, kebab-case ("video-id") + Target string // "path", "body", or "file" + Param string // target key: path template var ("video_id") or body field name ("prompt") + Help string +} + +// FlagSpec defines a named CLI flag (--name value). Source determines +// whether the resolved value becomes a query parameter or a JSON body field. +// +// FlagSpec differs from ArgSpec in that flags are named and optional by default, +// while positional args are unnamed and required. Flags map to query params or +// body fields; args map to path params, body fields, or file paths. +// +// Example: +// +// FlagSpec{Name: "limit", Type: "int", Source: "query", JSONName: "limit"} +// → user passes --limit 10 → inv.QueryParams["limit"] = "10" +// +// FlagSpec{Name: "title", Type: "string", Source: "body", JSONName: "title"} +// → user passes --title "Hello" → inv.Body["title"] = "Hello" +type FlagSpec struct { + Name string // kebab-case ("folder-id") + Type string // "string", "int", "bool", "float64", "string-slice" + Default string // default value as string + Help string // from OpenAPI description + Required bool // from OpenAPI required + Enum []string // from OpenAPI enum (empty = any value) + Min *int // from OpenAPI minimum (nil if not defined) + Max *int // from OpenAPI maximum (nil if not defined) + Source string // "query" or "body" + JSONName string // original API parameter/field name ("folder_id") +} + +// PollConfig defines how --wait polling works for async commands (future). +// Will be implemented alongside Track B (polling framework). +type PollConfig struct { + StatusEndpoint string // GET endpoint to check status + StatusField string // JSON field containing status (e.g., "status") + TerminalOK []string // success states: ["completed"] + TerminalFail []string // failure states: ["failed", "error"] + IDField string // field in create response containing the resource ID +} + +// Column defines a TUI table column for --human output (future). +// Will be implemented alongside M3 (TUI formatting). +type Column struct { + Header string // table column header ("Status") + Field string // JSON field path, supports dot notation ("avatar.name") + Width int // optional fixed width (0 = auto-size) +} + +// Invocation holds the per-invocation resolved values — what the user +// actually provided. Built fresh by the builder each time a command runs. +type Invocation struct { + PathParams map[string]string // resolved path parameters + QueryParams url.Values // resolved query parameters (stdlib type, handles repeated keys) + Body map[string]any // merged from flags + -d/--data; nil means no body sent + FilePath string // local file path for multipart upload +} + +// BuildInvocation resolves positional args and flags from a Cobra command +// into an Invocation. The merge order is: +// 1. -d/--data (base, if provided) +// 2. Positional body args overlay +// 3. Flag body values overlay (flag wins over -d/--data) +// +// -d/--data is the escape hatch for complex request bodies that can't be +// expressed as CLI flags (discriminated unions, nested objects, arrays of +// objects). It accepts inline JSON, a file path, or stdin. When used with +// individual flags, the flags overlay specific fields on top of the JSON base. +// This enables reusable JSON templates with per-invocation flag tweaks. +func (s *Spec) BuildInvocation(cmd *cobra.Command, args []string, data map[string]any) (*Invocation, error) { + inv := &Invocation{ + PathParams: make(map[string]string), + QueryParams: make(url.Values), + } + + // Step 1: -d/--data as base (if provided) + if data != nil { + inv.Body = data + } + + // Step 2: Positional args — routed by ArgSpec.Target + for i, arg := range s.Args { + if i >= len(args) { + break + } + switch arg.Target { + case "path": + inv.PathParams[arg.Param] = args[i] + case "body": + if inv.Body == nil { + inv.Body = make(map[string]any) + } + inv.Body[arg.Param] = args[i] + case "file": + inv.FilePath = args[i] + } + } + + // Step 3: Flags — only if explicitly set by the user + for _, flag := range s.Flags { + if !cmd.Flags().Changed(flag.Name) { + continue + } + + if err := validateFlag(cmd, flag); err != nil { + return nil, err + } + + switch flag.Source { + case "query": + inv.QueryParams.Add(flag.JSONName, getFlagAsString(cmd, flag)) + case "body": + if inv.Body == nil { + inv.Body = make(map[string]any) + } + inv.Body[flag.JSONName] = getFlagValue(cmd, flag) + } + } + + return inv, nil +} + +// validateFlag checks enum membership and min/max bounds. +func validateFlag(cmd *cobra.Command, flag FlagSpec) error { + if len(flag.Enum) > 0 { + val, _ := cmd.Flags().GetString(flag.Name) + if !slices.Contains(flag.Enum, val) { + return clierrors.NewUsage( + fmt.Sprintf("--%s must be one of %v, got %q", flag.Name, flag.Enum, val)) + } + } + + if flag.Type == "int" && (flag.Min != nil || flag.Max != nil) { + val, _ := cmd.Flags().GetInt(flag.Name) + if flag.Min != nil && val < *flag.Min { + return clierrors.NewUsage( + fmt.Sprintf("--%s must be at least %d, got %d", flag.Name, *flag.Min, val)) + } + if flag.Max != nil && val > *flag.Max { + return clierrors.NewUsage( + fmt.Sprintf("--%s must be at most %d, got %d", flag.Name, *flag.Max, val)) + } + } + + return nil +} + +// getFlagAsString reads a flag value as a string for query params. +func getFlagAsString(cmd *cobra.Command, flag FlagSpec) string { + switch flag.Type { + case "int": + v, _ := cmd.Flags().GetInt(flag.Name) + return strconv.Itoa(v) + case "bool": + v, _ := cmd.Flags().GetBool(flag.Name) + return strconv.FormatBool(v) + case "float64": + v, _ := cmd.Flags().GetFloat64(flag.Name) + return strconv.FormatFloat(v, 'f', -1, 64) + default: + v, _ := cmd.Flags().GetString(flag.Name) + return v + } +} + +// getFlagValue reads a flag value with its proper Go type for body fields. +func getFlagValue(cmd *cobra.Command, flag FlagSpec) any { + switch flag.Type { + case "int": + v, _ := cmd.Flags().GetInt(flag.Name) + return v + case "bool": + v, _ := cmd.Flags().GetBool(flag.Name) + return v + case "float64": + v, _ := cmd.Flags().GetFloat64(flag.Name) + return v + case "string-slice": + v, _ := cmd.Flags().GetStringSlice(flag.Name) + return v + default: + v, _ := cmd.Flags().GetString(flag.Name) + return v + } +} diff --git a/internal/command/spec_test.go b/internal/command/spec_test.go new file mode 100644 index 00000000..edee7501 --- /dev/null +++ b/internal/command/spec_test.go @@ -0,0 +1,334 @@ +package command + +import ( + "errors" + "testing" + + clierrors "github.com/heygen-com/heygen-cli/internal/errors" + "github.com/spf13/cobra" +) + +// helperCmd creates a Cobra command with flags registered from the spec, +// then simulates flag parsing with the given args. +func helperCmd(t *testing.T, spec *Spec, args []string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "test", RunE: func(cmd *cobra.Command, args []string) error { return nil }} + for _, f := range spec.Flags { + switch f.Type { + case "int": + cmd.Flags().Int(f.Name, 0, f.Help) + case "bool": + cmd.Flags().Bool(f.Name, false, f.Help) + case "float64": + cmd.Flags().Float64(f.Name, 0, f.Help) + case "string-slice": + cmd.Flags().StringSlice(f.Name, nil, f.Help) + default: + cmd.Flags().String(f.Name, "", f.Help) + } + } + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("failed to parse flags: %v", err) + } + return cmd +} + +func TestBuildInvocation_QueryParamFlag(t *testing.T) { + spec := &Spec{ + Flags: []FlagSpec{ + {Name: "limit", Type: "int", Source: "query", JSONName: "limit"}, + }, + } + cmd := helperCmd(t, spec, []string{"--limit", "10"}) + + inv, err := spec.BuildInvocation(cmd, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := inv.QueryParams.Get("limit"); got != "10" { + t.Errorf("QueryParams[limit] = %q, want %q", got, "10") + } +} + +func TestBuildInvocation_BodyFieldFlag(t *testing.T) { + spec := &Spec{ + Flags: []FlagSpec{ + {Name: "title", Type: "string", Source: "body", JSONName: "title"}, + }, + } + cmd := helperCmd(t, spec, []string{"--title", "My Video"}) + + inv, err := spec.BuildInvocation(cmd, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if inv.Body == nil { + t.Fatal("expected Body to be non-nil") + } + if inv.Body["title"] != "My Video" { + t.Errorf("Body[title] = %v, want %q", inv.Body["title"], "My Video") + } +} + +func TestBuildInvocation_PathParamArg(t *testing.T) { + spec := &Spec{ + Args: []ArgSpec{ + {Name: "video-id", Target: "path", Param: "video_id"}, + }, + } + cmd := helperCmd(t, spec, nil) + + inv, err := spec.BuildInvocation(cmd, []string{"abc123"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if inv.PathParams["video_id"] != "abc123" { + t.Errorf("PathParams[video_id] = %q, want %q", inv.PathParams["video_id"], "abc123") + } +} + +func TestBuildInvocation_BodyParamArg(t *testing.T) { + spec := &Spec{ + Args: []ArgSpec{ + {Name: "prompt", Target: "body", Param: "prompt"}, + }, + } + cmd := helperCmd(t, spec, nil) + + inv, err := spec.BuildInvocation(cmd, []string{"Hello world"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if inv.Body["prompt"] != "Hello world" { + t.Errorf("Body[prompt] = %v, want %q", inv.Body["prompt"], "Hello world") + } +} + +func TestBuildInvocation_FileArg(t *testing.T) { + spec := &Spec{ + Args: []ArgSpec{ + {Name: "file", Target: "file", Param: "file"}, + }, + } + cmd := helperCmd(t, spec, nil) + + inv, err := spec.BuildInvocation(cmd, []string{"/tmp/video.mp4"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if inv.FilePath != "/tmp/video.mp4" { + t.Errorf("FilePath = %q, want %q", inv.FilePath, "/tmp/video.mp4") + } +} + +func TestBuildInvocation_UnchangedFlagOmitted(t *testing.T) { + spec := &Spec{ + Flags: []FlagSpec{ + {Name: "limit", Type: "int", Source: "query", JSONName: "limit"}, + {Name: "token", Type: "string", Source: "query", JSONName: "token"}, + }, + } + // Only set --limit, leave --token unset + cmd := helperCmd(t, spec, []string{"--limit", "5"}) + + inv, err := spec.BuildInvocation(cmd, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := inv.QueryParams.Get("limit"); got != "5" { + t.Errorf("QueryParams[limit] = %q, want %q", got, "5") + } + if got := inv.QueryParams.Get("token"); got != "" { + t.Errorf("QueryParams[token] = %q, want empty (unset)", got) + } +} + +func TestBuildInvocation_EnumValidation(t *testing.T) { + spec := &Spec{ + Flags: []FlagSpec{ + {Name: "type", Type: "string", Source: "query", JSONName: "type", Enum: []string{"public", "private"}}, + }, + } + cmd := helperCmd(t, spec, []string{"--type", "invalid"}) + + _, err := spec.BuildInvocation(cmd, nil, nil) + if err == nil { + t.Fatal("expected error for invalid enum value, got nil") + } + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("expected *CLIError, got %T", err) + } + if cliErr.ExitCode != clierrors.ExitUsage { + t.Errorf("ExitCode = %d, want %d", cliErr.ExitCode, clierrors.ExitUsage) + } +} + +func TestBuildInvocation_EnumValid(t *testing.T) { + spec := &Spec{ + Flags: []FlagSpec{ + {Name: "type", Type: "string", Source: "query", JSONName: "type", Enum: []string{"public", "private"}}, + }, + } + cmd := helperCmd(t, spec, []string{"--type", "public"}) + + inv, err := spec.BuildInvocation(cmd, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := inv.QueryParams.Get("type"); got != "public" { + t.Errorf("QueryParams[type] = %q, want %q", got, "public") + } +} + +func TestBuildInvocation_MinMaxValidation(t *testing.T) { + min, max := 1, 100 + spec := &Spec{ + Flags: []FlagSpec{ + {Name: "limit", Type: "int", Source: "query", JSONName: "limit", Min: &min, Max: &max}, + }, + } + + tests := []struct { + name string + val string + wantErr bool + }{ + {"below min", "0", true}, + {"above max", "999", true}, + {"at min", "1", false}, + {"at max", "100", false}, + {"in range", "50", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := helperCmd(t, spec, []string{"--limit", tt.val}) + _, err := spec.BuildInvocation(cmd, nil, nil) + if tt.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestBuildInvocation_DataBase(t *testing.T) { + spec := &Spec{ + Flags: []FlagSpec{ + {Name: "title", Type: "string", Source: "body", JSONName: "title"}, + }, + } + cmd := helperCmd(t, spec, nil) // no flags set + + data := map[string]any{ + "title": "From JSON", + "video": map[string]any{"type": "url", "url": "https://example.com"}, + } + + inv, err := spec.BuildInvocation(cmd, nil, data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if inv.Body["title"] != "From JSON" { + t.Errorf("Body[title] = %v, want %q", inv.Body["title"], "From JSON") + } + // Complex field preserved + if inv.Body["video"] == nil { + t.Error("Body[video] should be preserved from -d/--data") + } +} + +func TestBuildInvocation_FlagOverridesData(t *testing.T) { + spec := &Spec{ + Flags: []FlagSpec{ + {Name: "title", Type: "string", Source: "body", JSONName: "title"}, + }, + } + cmd := helperCmd(t, spec, []string{"--title", "From Flag"}) + + data := map[string]any{ + "title": "From JSON", + "video": map[string]any{"type": "url"}, + } + + inv, err := spec.BuildInvocation(cmd, nil, data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Flag wins over -d/--data + if inv.Body["title"] != "From Flag" { + t.Errorf("Body[title] = %v, want %q (flag should override)", inv.Body["title"], "From Flag") + } + // Non-overlapping fields from -d/--data preserved + if inv.Body["video"] == nil { + t.Error("Body[video] should be preserved from -d/--data") + } +} + +func TestBuildInvocation_PositionalArgOverridesData(t *testing.T) { + spec := &Spec{ + Args: []ArgSpec{ + {Name: "prompt", Target: "body", Param: "prompt"}, + }, + } + cmd := helperCmd(t, spec, nil) + + data := map[string]any{ + "prompt": "From JSON", + "avatar_id": "josh", + } + + inv, err := spec.BuildInvocation(cmd, []string{"From Positional"}, data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Positional arg wins over -d/--data + if inv.Body["prompt"] != "From Positional" { + t.Errorf("Body[prompt] = %v, want %q", inv.Body["prompt"], "From Positional") + } + // Other fields from -d/--data preserved + if inv.Body["avatar_id"] != "josh" { + t.Errorf("Body[avatar_id] = %v, want %q", inv.Body["avatar_id"], "josh") + } +} + +func TestBuildInvocation_NoBodyWhenNoContent(t *testing.T) { + spec := &Spec{ + Flags: []FlagSpec{ + {Name: "limit", Type: "int", Source: "query", JSONName: "limit"}, + }, + } + cmd := helperCmd(t, spec, []string{"--limit", "10"}) + + inv, err := spec.BuildInvocation(cmd, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if inv.Body != nil { + t.Errorf("Body should be nil for query-only command, got %v", inv.Body) + } +} + +func TestBuildInvocation_StringSliceFlag(t *testing.T) { + spec := &Spec{ + Flags: []FlagSpec{ + {Name: "events", Type: "string-slice", Source: "body", JSONName: "events"}, + }, + } + cmd := helperCmd(t, spec, []string{"--events", "a.success,b.fail"}) + + inv, err := spec.BuildInvocation(cmd, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + events, ok := inv.Body["events"].([]string) + if !ok { + t.Fatalf("Body[events] type = %T, want []string", inv.Body["events"]) + } + if len(events) != 2 { + t.Errorf("events length = %d, want 2", len(events)) + } +}