From d9adbc84ce21ca074b282131afbfad435fbd6219 Mon Sep 17 00:00:00 2001 From: Somansh Reddy Satish Date: Tue, 31 Mar 2026 17:20:38 +0000 Subject: [PATCH] cmd: add generic command builder for generated Specs buildGenCommand converts a command.Spec into a Cobra command: registers typed flags, adds --json-body for JSON body endpoints, validates enums and min/max, builds Invocation from parsed flags/args. Also adds newRootCmdWithSpecs for testing spec-based command registration. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/heygen/builder.go | 137 +++++++++++++++++++ cmd/heygen/builder_test.go | 274 +++++++++++++++++++++++++++++++++++++ cmd/heygen/root.go | 50 ++++++- 3 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 cmd/heygen/builder.go create mode 100644 cmd/heygen/builder_test.go diff --git a/cmd/heygen/builder.go b/cmd/heygen/builder.go new file mode 100644 index 00000000..d1a47f71 --- /dev/null +++ b/cmd/heygen/builder.go @@ -0,0 +1,137 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/heygen-com/heygen-cli/internal/command" + clierrors "github.com/heygen-com/heygen-cli/internal/errors" + "github.com/spf13/cobra" +) + +// buildCobraCommand creates a Cobra command from a command.Spec. +// It registers typed flags, sets up positional arg validation, and wires +// RunE to build an Invocation and execute it through the HTTP client. +func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command { + var rawData string + + cmd := &cobra.Command{ + Use: buildUseLine(spec), + Short: spec.Summary, + Long: spec.Description, + Example: strings.Join(spec.Examples, "\n"), + Args: cobra.ExactArgs(len(spec.Args)), + RunE: func(cmd *cobra.Command, args []string) error { + // Parse -d/--data if provided + var parsedData map[string]any + if rawData != "" { + parsed, err := readData(rawData) + if err != nil { + return err + } + parsedData = parsed + } + + inv, err := spec.BuildInvocation(cmd, args, parsedData) + if err != nil { + return err + } + + result, err := ctx.client.Execute(spec, inv) + if err != nil { + return err + } + + return ctx.formatter.Data(result) + }, + } + + // Register flags from spec + for _, flag := range spec.Flags { + registerFlag(cmd, flag) + } + + // Add -d/--data for commands with JSON request bodies + if spec.BodyEncoding == "json" { + cmd.Flags().StringVarP(&rawData, "data", "d", "", + "JSON request body (inline JSON, file path, or - for stdin)") + } + + return cmd +} + +// buildUseLine constructs the Cobra Use string from the spec name and args. +// Example: "get " or "list" +func buildUseLine(spec *command.Spec) string { + parts := []string{spec.Name} + for _, arg := range spec.Args { + parts = append(parts, "<"+arg.Name+">") + } + return strings.Join(parts, " ") +} + +// registerFlag adds a typed flag to the Cobra command based on the FlagSpec. +func registerFlag(cmd *cobra.Command, flag command.FlagSpec) { + helpText := flag.Help + if len(flag.Enum) > 0 { + helpText += fmt.Sprintf(" (allowed: %s)", strings.Join(flag.Enum, ", ")) + } + + switch flag.Type { + case "int": + defaultVal := 0 + if flag.Default != "" { + defaultVal, _ = strconv.Atoi(flag.Default) + } + cmd.Flags().Int(flag.Name, defaultVal, helpText) + case "bool": + defaultVal := flag.Default == "true" + cmd.Flags().Bool(flag.Name, defaultVal, helpText) + case "float64": + defaultVal := 0.0 + if flag.Default != "" { + defaultVal, _ = strconv.ParseFloat(flag.Default, 64) + } + cmd.Flags().Float64(flag.Name, defaultVal, helpText) + case "string-slice": + cmd.Flags().StringSlice(flag.Name, nil, helpText) + default: // string + cmd.Flags().String(flag.Name, flag.Default, helpText) + } + + if flag.Required { + _ = cmd.MarkFlagRequired(flag.Name) + } +} + +// readData reads JSON from inline string, file path, or stdin ("-"). +func readData(source string) (map[string]any, error) { + var raw []byte + var err error + + if source == "-" { + raw, err = io.ReadAll(os.Stdin) + if err != nil { + return nil, clierrors.New(fmt.Sprintf("failed to read stdin: %v", err)) + } + } else if strings.HasPrefix(source, "{") || strings.HasPrefix(source, "[") { + // Inline JSON + raw = []byte(source) + } else { + // File path + raw, err = os.ReadFile(source) + if err != nil { + return nil, clierrors.New(fmt.Sprintf("failed to read file %q: %v", source, err)) + } + } + + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + return nil, clierrors.NewUsage(fmt.Sprintf("invalid JSON in --data: %v", err)) + } + return body, nil +} diff --git a/cmd/heygen/builder_test.go b/cmd/heygen/builder_test.go new file mode 100644 index 00000000..dd0e3b75 --- /dev/null +++ b/cmd/heygen/builder_test.go @@ -0,0 +1,274 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "testing" + + "github.com/heygen-com/heygen-cli/internal/command" + clierrors "github.com/heygen-com/heygen-cli/internal/errors" + "github.com/heygen-com/heygen-cli/internal/output" +) + +// videoListSpec mirrors the hand-written video list command as a Spec, +// proving the generic builder produces identical behavior. +var videoListSpec = &command.Spec{ + Group: "video", + Name: "list", + Summary: "List videos", + Endpoint: "/v3/videos", + Method: "GET", + // TokenField/DataField for pagination (used by paginator, not tested here) + TokenField: "next_token", + DataField: "data", + Flags: []command.FlagSpec{ + {Name: "limit", Type: "int", Source: "query", JSONName: "limit"}, + {Name: "token", Type: "string", Source: "query", JSONName: "token"}, + {Name: "folder-id", Type: "string", Source: "query", JSONName: "folder_id"}, + }, + Examples: []string{"heygen video list --limit 10"}, +} + +func TestGenBuilder_VideoList_Success(t *testing.T) { + srv := setupTestServer(t, map[string]testHandler{ + "GET /v3/videos": { + StatusCode: 200, + Body: `{"data":[{"id":"v1","title":"Demo","status":"completed"}],"has_more":false,"next_token":null}`, + }, + }) + defer srv.Close() + + // Build a Cobra tree using the generic builder instead of hand-written newVideoListCmd + ctx := &cmdContext{formatter: nil} // will be set by runGenCommand + res := runGenCommand(t, srv.URL, "test-key", ctx, videoListSpec, "list") + + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr) + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil { + t.Fatalf("stdout is not valid JSON: %v\nstdout: %s", err, res.Stdout) + } + data, ok := parsed["data"].([]any) + if !ok { + t.Fatalf("data field missing or not array: %v", parsed) + } + if len(data) != 1 { + t.Errorf("data length = %d, want 1", len(data)) + } +} + +func TestGenBuilder_VideoList_Flags(t *testing.T) { + srv := setupTestServer(t, map[string]testHandler{ + "GET /v3/videos": { + StatusCode: 200, + Body: `{"data":[],"has_more":false}`, + ValidateRequest: func(t *testing.T, r *http.Request) { + t.Helper() + q := r.URL.Query() + if got := q.Get("limit"); got != "25" { + t.Errorf("limit = %q, want %q", got, "25") + } + if got := q.Get("folder_id"); got != "folder_abc" { + t.Errorf("folder_id = %q, want %q", got, "folder_abc") + } + if got := q.Get("token"); got != "cursor_xyz" { + t.Errorf("token = %q, want %q", got, "cursor_xyz") + } + }, + }, + }) + defer srv.Close() + + ctx := &cmdContext{} + res := runGenCommand(t, srv.URL, "test-key", ctx, videoListSpec, + "list", "--limit", "25", "--folder-id", "folder_abc", "--token", "cursor_xyz") + + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr) + } +} + +func TestGenBuilder_PostWithBodyFlags(t *testing.T) { + var gotBody map[string]any + + srv := setupTestServer(t, map[string]testHandler{ + "POST /v3/webhooks/endpoints": { + StatusCode: 200, + Body: `{"data":{"id":"ep_123"}}`, + ValidateRequest: func(t *testing.T, r *http.Request) { + t.Helper() + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + }, + }, + }) + defer srv.Close() + + spec := &command.Spec{ + Group: "webhook", + Name: "create", + Summary: "Create a webhook endpoint", + Endpoint: "/v3/webhooks/endpoints", + Method: "POST", + BodyEncoding: "json", + Flags: []command.FlagSpec{ + {Name: "url", Type: "string", Source: "body", JSONName: "url", Required: true}, + {Name: "entity-id", Type: "string", Source: "body", JSONName: "entity_id"}, + }, + Examples: []string{"heygen webhook create --url https://example.com/hook"}, + } + + ctx := &cmdContext{} + res := runGenCommand(t, srv.URL, "test-key", ctx, spec, + "create", "--url", "https://example.com/hook", "--entity-id", "proj_456") + + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr) + } + if gotBody["url"] != "https://example.com/hook" { + t.Errorf("body.url = %v, want %q", gotBody["url"], "https://example.com/hook") + } + if gotBody["entity_id"] != "proj_456" { + t.Errorf("body.entity_id = %v, want %q", gotBody["entity_id"], "proj_456") + } +} + +func TestGenBuilder_PathParam(t *testing.T) { + srv := setupTestServer(t, map[string]testHandler{ + "GET /v3/videos/abc123": { + StatusCode: 200, + Body: `{"data":{"id":"abc123","status":"completed"}}`, + }, + }) + defer srv.Close() + + spec := &command.Spec{ + Group: "video", + Name: "get", + Summary: "Get video details", + Endpoint: "/v3/videos/{video_id}", + Method: "GET", + Args: []command.ArgSpec{ + {Name: "video-id", Target: "path", Param: "video_id"}, + }, + Examples: []string{"heygen video get abc123"}, + } + + ctx := &cmdContext{} + res := runGenCommand(t, srv.URL, "test-key", ctx, spec, "get", "abc123") + + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr) + } +} + +func TestGenBuilder_BodylessPost_NoBodySent(t *testing.T) { + srv := setupTestServer(t, map[string]testHandler{ + "POST /v3/avatars": { + StatusCode: 200, + Body: `{"data":{"id":"avatar_new"}}`, + ValidateRequest: func(t *testing.T, r *http.Request) { + t.Helper() + body, _ := io.ReadAll(r.Body) + if len(body) > 0 { + t.Errorf("expected no body for bodyless POST, got %q", string(body)) + } + }, + }, + }) + defer srv.Close() + + spec := &command.Spec{ + Group: "avatar", + Name: "create", + Summary: "Create an avatar", + Endpoint: "/v3/avatars", + Method: "POST", + // No BodyEncoding, no Flags with Source:"body" — truly bodyless + Examples: []string{"heygen avatar create"}, + } + + ctx := &cmdContext{} + res := runGenCommand(t, srv.URL, "test-key", ctx, spec, "create") + + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr) + } +} + +func TestGenBuilder_EnumValidation(t *testing.T) { + srv := setupTestServer(t, map[string]testHandler{}) + defer srv.Close() + + spec := &command.Spec{ + Group: "voice", + Name: "list", + Summary: "List voices", + Endpoint: "/v3/voices", + Method: "GET", + Flags: []command.FlagSpec{ + {Name: "type", Type: "string", Source: "query", JSONName: "type", Enum: []string{"public", "private"}}, + }, + Examples: []string{"heygen voice list --type public"}, + } + + ctx := &cmdContext{} + res := runGenCommand(t, srv.URL, "test-key", ctx, spec, "list", "--type", "invalid") + + if res.ExitCode != 2 { + t.Errorf("ExitCode = %d, want 2 (usage error)\nstderr: %s", res.ExitCode, res.Stderr) + } +} + +// runGenCommand builds a Cobra tree using the generic builder and executes it, +// similar to runCommand but using buildCobraCommand instead of the hand-written commands. +// +// It creates a fresh root command (with PersistentPreRunE that sets up the client) +// and adds the spec-built command under a group. The cmdContext is shared between +// PersistentPreRunE (which sets ctx.client) and the spec command (which uses it). +func runGenCommand(t *testing.T, serverURL, apiKey string, _ *cmdContext, spec *command.Spec, args ...string) cmdResult { + t.Helper() + + var stdout, stderr bytes.Buffer + formatter := output.NewJSONFormatter(&stdout, &stderr) + + t.Setenv("HEYGEN_API_KEY", apiKey) + t.Setenv("HEYGEN_API_BASE", serverURL) + + // Build a root command that registers the spec via the generic builder. + // newRootCmdWithSpecs shares a cmdContext between PersistentPreRunE (which + // creates the HTTP client) and buildCobraCommand (which uses it in RunE). + root := newRootCmdWithSpecs("test", formatter, map[string][]*command.Spec{ + spec.Group: {spec}, + }) + + fullArgs := append([]string{spec.Group}, args...) + root.SetArgs(fullArgs) + + err := root.Execute() + + var exitCode int + if err != nil { + var cliErr *clierrors.CLIError + if errors.As(err, &cliErr) { + formatter.Error(cliErr) + exitCode = cliErr.ExitCode + } else { + wrapped := classifyError(err) + formatter.Error(wrapped) + exitCode = wrapped.ExitCode + } + } + + return cmdResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + } +} + diff --git a/cmd/heygen/root.go b/cmd/heygen/root.go index b4c52db0..9afbef10 100644 --- a/cmd/heygen/root.go +++ b/cmd/heygen/root.go @@ -3,6 +3,7 @@ package main import ( "github.com/heygen-com/heygen-cli/internal/auth" "github.com/heygen-com/heygen-cli/internal/client" + "github.com/heygen-com/heygen-cli/internal/command" "github.com/heygen-com/heygen-cli/internal/config" clierrors "github.com/heygen-com/heygen-cli/internal/errors" "github.com/heygen-com/heygen-cli/internal/output" @@ -22,7 +23,7 @@ func newRootCmd(version string, formatter output.Formatter) *cobra.Command { root := &cobra.Command{ Use: "heygen", - Short: "HeyGen CLI — manage videos, avatars, and more", + Short: "HeyGen CLI — create and manage videos, avatars, and more", Version: version, SilenceUsage: true, // we handle usage errors ourselves SilenceErrors: true, // we handle error output ourselves @@ -58,3 +59,50 @@ func newRootCmd(version string, formatter output.Formatter) *cobra.Command { return root } + +// newRootCmdWithSpecs creates a root command that registers generated commands +// from Specs instead of hand-written command constructors. Used by tests to +// verify the generic builder produces correct behavior. +func newRootCmdWithSpecs(version string, formatter output.Formatter, groups map[string][]*command.Spec) *cobra.Command { + ctx := &cmdContext{formatter: formatter} + + root := &cobra.Command{ + Use: "heygen", + Short: "HeyGen CLI — create and manage videos, avatars, and more", + Version: version, + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + provider := &config.EnvProvider{} + ctx.configProvider = provider + + resolver := &auth.EnvCredentialResolver{} + apiKey, err := resolver.Resolve() + if err != nil { + return err + } + + ctx.client = client.New(apiKey, + client.WithBaseURL(provider.BaseURL()), + client.WithUserAgent("heygen-cli/"+version), + ) + + return nil + }, + } + + root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + return clierrors.NewUsage(err.Error()) + }) + + // Register spec-based commands grouped by name + for groupName, specs := range groups { + groupCmd := &cobra.Command{Use: groupName, Short: "Create and manage " + groupName + "s"} + for _, spec := range specs { + groupCmd.AddCommand(buildCobraCommand(spec, ctx)) + } + root.AddCommand(groupCmd) + } + + return root +}