Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/heygen/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}
Expand Down
72 changes: 72 additions & 0 deletions codegen/examples.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions codegen/examples/asset.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"POST /v3/assets":
- "heygen asset create --file ./video.mp4"
12 changes: 12 additions & 0 deletions codegen/examples/avatar.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"GET /v3/avatars":
- "heygen avatar list --limit 10"
"GET /v3/avatars/{group_id}":
- "heygen avatar get <group-id>"
"POST /v3/avatars":
- "heygen avatar create"
"POST /v3/avatars/{group_id}/consent":
- "heygen avatar consent create <group-id>"
"GET /v3/avatars/looks":
- "heygen avatar looks list --limit 10"
"GET /v3/avatars/looks/{look_id}":
- "heygen avatar looks get <look-id>"
2 changes: 2 additions & 0 deletions codegen/examples/user.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"GET /v3/user/me":
- "heygen user me get"
14 changes: 14 additions & 0 deletions codegen/examples/video-agent.yaml
Original file line number Diff line number Diff line change
@@ -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 <session-id>"
"POST /v3/video-agents/sessions/{session_id}/messages":
- "heygen video-agent sessions messages create <session-id> --message 'Add intro'"
"GET /v3/video-agents/sessions/{session_id}/resources":
- "heygen video-agent sessions resources list <session-id>"
"POST /v3/video-agents/sessions/{session_id}/stop":
- "heygen video-agent sessions stop <session-id>"
24 changes: 24 additions & 0 deletions codegen/examples/video-translate.yaml
Original file line number Diff line number Diff line change
@@ -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 <video-translation-id>"
"POST /v3/video-translations":
- "cat request.json | heygen video-translate create -d -"
"PATCH /v3/video-translations/{video_translation_id}":
- "heygen video-translate update <video-translation-id> --title 'New title'"
"DELETE /v3/video-translations/{video_translation_id}":
- "heygen video-translate delete <video-translation-id>"
"GET /v3/video-translations/{video_translation_id}/caption":
- "heygen video-translate caption get <video-translation-id> --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 <proofread-id>"
"POST /v3/video-translations/proofreads/{proofread_id}/generate":
- "heygen video-translate proofreads generate <proofread-id>"
"GET /v3/video-translations/proofreads/{proofread_id}/srt":
- "heygen video-translate proofreads srt get <proofread-id>"
"PUT /v3/video-translations/proofreads/{proofread_id}/srt":
- "heygen video-translate proofreads srt update <proofread-id>"
9 changes: 9 additions & 0 deletions codegen/examples/video.yaml
Original file line number Diff line number Diff line change
@@ -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 <video-id>"
"DELETE /v3/videos/{video_id}":
- "heygen video delete <video-id>"
4 changes: 4 additions & 0 deletions codegen/examples/voice.yaml
Original file line number Diff line number Diff line change
@@ -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"
14 changes: 14 additions & 0 deletions codegen/examples/webhook.yaml
Original file line number Diff line number Diff line change
@@ -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 <endpoint-id> --url https://new.example.com/hook"
"DELETE /v3/webhooks/endpoints/{endpoint_id}":
- "heygen webhook endpoints delete <endpoint-id>"
"POST /v3/webhooks/endpoints/{endpoint_id}/rotate-secret":
- "heygen webhook endpoints rotate-secret <endpoint-id>"
"GET /v3/webhooks/event-types":
- "heygen webhook event-types list"
"GET /v3/webhooks/events":
- "heygen webhook events list --event-type avatar_video.success"
12 changes: 12 additions & 0 deletions codegen/testdata/test_examples.yaml
Original file line number Diff line number Diff line change
@@ -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 <widget-id>"
"DELETE /v3/widgets/{widget_id}":
- "heygen widget delete <widget-id>"
"POST /v3/widgets/{widget_id}/activate":
- "heygen widget activate <widget-id>"
"POST /v3/uploads":
- "heygen upload upload --file ./file.txt"
8 changes: 7 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
16 changes: 16 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
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=
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=
71 changes: 35 additions & 36 deletions internal/command/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <video-id> → PathParams["video_id"] = "abc123"
//
// - "path": URL template substitution. heygen video get <video-id> → PathParams["video_id"] = "abc123"
// - "body": JSON body field. heygen voice speech <text> → Body["text"] = "Hello"
// - "file": Multipart file upload path. heygen asset upload <file> → 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"
Expand All @@ -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")
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}
}

Expand Down
Loading
Loading