diff --git a/cmd/heygen/builder_test.go b/cmd/heygen/builder_test.go index dd0e3b75..5dad6f31 100644 --- a/cmd/heygen/builder_test.go +++ b/cmd/heygen/builder_test.go @@ -154,7 +154,7 @@ func TestGenBuilder_PathParam(t *testing.T) { Endpoint: "/v3/videos/{video_id}", Method: "GET", Args: []command.ArgSpec{ - {Name: "video-id", Target: "path", Param: "video_id"}, + {Name: "video-id", Param: "video_id"}, }, Examples: []string{"heygen video get abc123"}, } diff --git a/codegen/examples.go b/codegen/examples.go new file mode 100644 index 00000000..1d3fb83a --- /dev/null +++ b/codegen/examples.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Examples maps "METHOD /path" → list of curated CLI usage examples. +// Hand-written to show real usage patterns. Mandatory for every generated +// command — make generate fails if any are missing. +type Examples map[string][]string + +// LoadExamples reads examples from a YAML file or a directory of YAML files. +// When given a directory, all *.yaml files are loaded and merged. Duplicate +// endpoint keys across files produce an error. +func LoadExamples(path string) (Examples, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("reading examples: %w", err) + } + + if !info.IsDir() { + return loadExamplesFile(path) + } + + // Directory: load and merge all .yaml files + entries, err := os.ReadDir(path) + if err != nil { + return nil, fmt.Errorf("reading examples directory: %w", err) + } + + merged := make(Examples) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { + continue + } + file := filepath.Join(path, entry.Name()) + single, err := loadExamplesFile(file) + if err != nil { + return nil, err + } + for key, examples := range single { + if _, exists := merged[key]; exists { + return nil, fmt.Errorf("duplicate endpoint %q found in %s (already defined in another file)", key, entry.Name()) + } + merged[key] = examples + } + } + + return merged, nil +} + +func loadExamplesFile(path string) (Examples, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + var examples Examples + if err := yaml.Unmarshal(data, &examples); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + + if examples == nil { + examples = make(Examples) + } + + return examples, nil +} diff --git a/codegen/examples/asset.yaml b/codegen/examples/asset.yaml new file mode 100644 index 00000000..842361ca --- /dev/null +++ b/codegen/examples/asset.yaml @@ -0,0 +1,2 @@ +"POST /v3/assets": + - "heygen asset create --file ./video.mp4" diff --git a/codegen/examples/avatar.yaml b/codegen/examples/avatar.yaml new file mode 100644 index 00000000..1021bc89 --- /dev/null +++ b/codegen/examples/avatar.yaml @@ -0,0 +1,12 @@ +"GET /v3/avatars": + - "heygen avatar list --limit 10" +"GET /v3/avatars/{group_id}": + - "heygen avatar get " +"POST /v3/avatars": + - "heygen avatar create" +"POST /v3/avatars/{group_id}/consent": + - "heygen avatar consent create " +"GET /v3/avatars/looks": + - "heygen avatar looks list --limit 10" +"GET /v3/avatars/looks/{look_id}": + - "heygen avatar looks get " diff --git a/codegen/examples/user.yaml b/codegen/examples/user.yaml new file mode 100644 index 00000000..535e8a3f --- /dev/null +++ b/codegen/examples/user.yaml @@ -0,0 +1,2 @@ +"GET /v3/user/me": + - "heygen user me get" diff --git a/codegen/examples/video-agent.yaml b/codegen/examples/video-agent.yaml new file mode 100644 index 00000000..e84aa1b5 --- /dev/null +++ b/codegen/examples/video-agent.yaml @@ -0,0 +1,14 @@ +"POST /v3/video-agents": + - "heygen video-agent create --prompt 'Make a product demo' --style-id modern" +"GET /v3/video-agents/styles": + - "heygen video-agent styles list" +"POST /v3/video-agents/sessions": + - "heygen video-agent sessions create --prompt 'Interview video'" +"GET /v3/video-agents/sessions/{session_id}": + - "heygen video-agent sessions get " +"POST /v3/video-agents/sessions/{session_id}/messages": + - "heygen video-agent sessions messages create --message 'Add intro'" +"GET /v3/video-agents/sessions/{session_id}/resources": + - "heygen video-agent sessions resources list " +"POST /v3/video-agents/sessions/{session_id}/stop": + - "heygen video-agent sessions stop " diff --git a/codegen/examples/video-translate.yaml b/codegen/examples/video-translate.yaml new file mode 100644 index 00000000..c77da6ed --- /dev/null +++ b/codegen/examples/video-translate.yaml @@ -0,0 +1,24 @@ +"GET /v3/video-translations": + - "heygen video-translate list --limit 10" +"GET /v3/video-translations/{video_translation_id}": + - "heygen video-translate get " +"POST /v3/video-translations": + - "cat request.json | heygen video-translate create -d -" +"PATCH /v3/video-translations/{video_translation_id}": + - "heygen video-translate update --title 'New title'" +"DELETE /v3/video-translations/{video_translation_id}": + - "heygen video-translate delete " +"GET /v3/video-translations/{video_translation_id}/caption": + - "heygen video-translate caption get --format srt" +"GET /v3/video-translations/languages": + - "heygen video-translate languages list" +"POST /v3/video-translations/proofreads": + - "cat request.json | heygen video-translate proofreads create -d -" +"GET /v3/video-translations/proofreads/{proofread_id}": + - "heygen video-translate proofreads get " +"POST /v3/video-translations/proofreads/{proofread_id}/generate": + - "heygen video-translate proofreads generate " +"GET /v3/video-translations/proofreads/{proofread_id}/srt": + - "heygen video-translate proofreads srt get " +"PUT /v3/video-translations/proofreads/{proofread_id}/srt": + - "heygen video-translate proofreads srt update " diff --git a/codegen/examples/video.yaml b/codegen/examples/video.yaml new file mode 100644 index 00000000..5d42f5da --- /dev/null +++ b/codegen/examples/video.yaml @@ -0,0 +1,9 @@ +"GET /v3/videos": + - "heygen video list --limit 10" + - "heygen video list --folder-id abc123" +"POST /v3/videos": + - "heygen video create --avatar-id josh_lite --script 'Hello world' --voice-id en_male" +"GET /v3/videos/{video_id}": + - "heygen video get " +"DELETE /v3/videos/{video_id}": + - "heygen video delete " diff --git a/codegen/examples/voice.yaml b/codegen/examples/voice.yaml new file mode 100644 index 00000000..9467dd83 --- /dev/null +++ b/codegen/examples/voice.yaml @@ -0,0 +1,4 @@ +"GET /v3/voices": + - "heygen voice list --type public" +"POST /v3/voices/speech": + - "heygen voice speech create --text 'Hello world' --voice-id en_male" diff --git a/codegen/examples/webhook.yaml b/codegen/examples/webhook.yaml new file mode 100644 index 00000000..4f608ef0 --- /dev/null +++ b/codegen/examples/webhook.yaml @@ -0,0 +1,14 @@ +"GET /v3/webhooks/endpoints": + - "heygen webhook endpoints list" +"POST /v3/webhooks/endpoints": + - "heygen webhook endpoints create --url https://example.com/hook" +"PATCH /v3/webhooks/endpoints/{endpoint_id}": + - "heygen webhook endpoints update --url https://new.example.com/hook" +"DELETE /v3/webhooks/endpoints/{endpoint_id}": + - "heygen webhook endpoints delete " +"POST /v3/webhooks/endpoints/{endpoint_id}/rotate-secret": + - "heygen webhook endpoints rotate-secret " +"GET /v3/webhooks/event-types": + - "heygen webhook event-types list" +"GET /v3/webhooks/events": + - "heygen webhook events list --event-type avatar_video.success" diff --git a/codegen/testdata/test_examples.yaml b/codegen/testdata/test_examples.yaml new file mode 100644 index 00000000..ad2112ec --- /dev/null +++ b/codegen/testdata/test_examples.yaml @@ -0,0 +1,12 @@ +"GET /v3/widgets": + - "heygen widget list --limit 10" +"POST /v3/widgets": + - "heygen widget create --name 'My Widget'" +"GET /v3/widgets/{widget_id}": + - "heygen widget get " +"DELETE /v3/widgets/{widget_id}": + - "heygen widget delete " +"POST /v3/widgets/{widget_id}/activate": + - "heygen widget activate " +"POST /v3/uploads": + - "heygen upload upload --file ./file.txt" diff --git a/go.mod b/go.mod index 1f4867c3..482ec891 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,15 @@ module github.com/heygen-com/heygen-cli go 1.23.8 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index a6ee3e0f..aac8f6cd 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,18 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -8,3 +20,7 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/command/spec.go b/internal/command/spec.go index ebd2f3fe..efef33bc 100644 --- a/internal/command/spec.go +++ b/internal/command/spec.go @@ -10,6 +10,23 @@ import ( "github.com/spf13/cobra" ) +// Groups maps group name → list of command specs in that group. +// This is the output of the codegen grouper and the input to the +// renderer and the runtime command builder. +// +// groups["video"] = []*Spec{videoList, videoGet, videoCreate, videoDelete} +type Groups map[string][]*Spec + +// SortedNames returns group names in alphabetical order for deterministic output. +func (g Groups) SortedNames() []string { + names := make([]string, 0, len(g)) + for name := range g { + names = append(names, name) + } + slices.Sort(names) + return names +} + // 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 @@ -46,36 +63,25 @@ type Spec struct { Columns []Column // TUI table column definitions (future) } -// ArgSpec defines a positional argument and where its value is routed. +// ArgSpec defines a positional argument derived from a URL path parameter. +// Every positional arg maps to a path template variable for URL substitution. // -// Unlike FlagSpec (which always maps to --name value), positional args -// have no flag prefix — their meaning comes from position. Target determines -// the destination: +// Example: heygen video get → PathParams["video_id"] = "abc123" // -// - "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" +// Body fields and file paths are always flags (--flag), never positional. +// This is an agent-first design — named flags are self-documenting. 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 + Name string // display name, kebab-case ("video-id") + Param string // path template variable ("video_id") + 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" +// where the resolved value is routed: // -// FlagSpec{Name: "title", Type: "string", Source: "body", JSONName: "title"} -// → user passes --title "Hello" → inv.Body["title"] = "Hello" +// - "query": → inv.QueryParams (e.g., --limit 10) +// - "body": → inv.Body (e.g., --title "Hello") +// - "file": → inv.FilePath (e.g., --file ./video.mp4, for multipart upload) type FlagSpec struct { Name string // kebab-case ("folder-id") Type string // "string", "int", "bool", "float64", "string-slice" @@ -85,7 +91,7 @@ type FlagSpec struct { 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" + Source string // "query", "body", or "file" JSONName string // original API parameter/field name ("folder_id") } @@ -138,22 +144,12 @@ func (s *Spec) BuildInvocation(cmd *cobra.Command, args []string, data map[strin inv.Body = data } - // Step 2: Positional args — routed by ArgSpec.Target + // Step 2: Positional args → path params 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] - } + inv.PathParams[arg.Param] = args[i] } // Step 3: Flags — only if explicitly set by the user @@ -174,6 +170,9 @@ func (s *Spec) BuildInvocation(cmd *cobra.Command, args []string, data map[strin inv.Body = make(map[string]any) } inv.Body[flag.JSONName] = getFlagValue(cmd, flag) + case "file": + v, _ := cmd.Flags().GetString(flag.Name) + inv.FilePath = v } } diff --git a/internal/command/spec_test.go b/internal/command/spec_test.go index edee7501..f0d57230 100644 --- a/internal/command/spec_test.go +++ b/internal/command/spec_test.go @@ -74,7 +74,7 @@ func TestBuildInvocation_BodyFieldFlag(t *testing.T) { func TestBuildInvocation_PathParamArg(t *testing.T) { spec := &Spec{ Args: []ArgSpec{ - {Name: "video-id", Target: "path", Param: "video_id"}, + {Name: "video-id", Param: "video_id"}, }, } cmd := helperCmd(t, spec, nil) @@ -88,39 +88,7 @@ func TestBuildInvocation_PathParamArg(t *testing.T) { } } -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{ @@ -268,32 +236,6 @@ func TestBuildInvocation_FlagOverridesData(t *testing.T) { } } -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{