Skip to content
Open
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
28 changes: 28 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ This reads the spec, generates command definitions in `gen/`, and formats the ou

Every command must have usage examples. If a new endpoint lacks examples, codegen fails. Add examples to `codegen/examples/{group}.yaml`.

### CLI-specific OpenAPI extensions

The codegen honors a small set of `x-cli-*` vendor extensions that the API team sets in the source spec (in EF, via Pydantic `json_schema_extra`). They let a field behave differently on the CLI surface than on the raw HTTP API:

- `x-cli-visible` (operation or schema property, bool) — when `false`, the command or flag is omitted from the CLI.
- `x-cli-action` (operation, bool) — when `true`, the path's terminal literal is treated as the verb (no `create`/`get` appended).
- `x-cli-default` (schema property) — overrides the HTTP `default` for the CLI flag default, and marks the value to be sent even when the user omits the flag.

Surface-specific *descriptions* are intentionally not a spec extension: they are owned CLI-side via the description overlay (see "CLI-surface description overrides" below), so the spec stays the single API contract.

## Adding Hand-Written Enhancements

Generated commands are pure data. To add behavior on top:
Expand Down Expand Up @@ -85,6 +95,24 @@ Generated commands are pure data. To add behavior on top:
},
```

**CLI-surface description overrides** (`--help` / `--request-schema` / `--response-schema` text) — register in `internal/clidesc`:

```go
{"/v3/voices/clone", "POST"}: {
Description: "Creates a voice clone ... poll it with `heygen voice get <voice-clone-id>` ...",
Flags: map[string]string{"style-id": "Style ID from 'heygen video-agent styles list'. ..."},
Fields: map[string]string{"asset_id": "Reusable asset identifier. Becomes usable after `heygen asset complete create <asset-id>`."},
},
```

Some OpenAPI descriptions are written in raw HTTP terms (`poll via GET /v3/videos/{id}`, `Pass to POST /v3/video-agents`) that mislead a CLI user, who drives the API with commands and flags. This overlay reframes those few cases in CLI terms.

- **It is a runtime overlay, not a spec/codegen change.** `command.Spec` is generated and immutable (see [AGENTS.md](./AGENTS.md)). The override is resolved at command-build time in `buildCobraCommand` (via `clidesc.Summary`/`Description`/`FlagHelp`/`Schema`) and never mutates the Spec, exactly like `DefaultColumns`. It lives in `internal/clidesc` (not `gen/`) so the builder and any tooling can share one source of truth.
- **Keyed by `(Endpoint, Method)`** — the same pair that uniquely identifies a `Spec`, so a key survives a command rename in the generated surface.
- **Sparse and fall-through.** Most commands have no entry. `Summary` overrides `Short`, `Description` overrides `Long`, `Flags[name]` overrides a flag's usage, and `Fields[json_name]` overrides a schema property's description. Any field left empty/absent falls through to the generated spec text unchanged.
- **Precedence: overlay wins.** The override replaces the generated spec text. (A spec-extension approach, `x-cli-description`, was considered and dropped in favor of this CLI-curated overlay: surface framing belongs to the surface, and the source of truth in the spec stays the API contract.)
- **Flag help avoids backticks.** Cobra/pflag treats the first backtick-delimited token in a flag's usage as the value placeholder; use plain quotes in `Flags` entries. Backticks are fine in `Summary`/`Description` and in `Fields` (schema JSON).

**Custom commands** (not in the API spec) — add a new file in `cmd/heygen/` and register in `root.go`. See `video_download.go` for an example.

**Deprecated aliases** (backward compatibility after a spec-driven rename) — register in `cmd/heygen/aliases.go`. Command names are derived from the upstream OpenAPI spec, so an upstream tag or path change can rename a command that already shipped in a stable release. An `Alias` re-registers the canonical `Spec` at its old path, hidden from help and marked deprecated, so the old invocation still resolves to the same handler while a stderr notice points to the new name:
Expand Down
23 changes: 17 additions & 6 deletions cmd/heygen/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strings"
"time"

"github.com/heygen-com/heygen-cli/internal/clidesc"
"github.com/heygen-com/heygen-cli/internal/client"
"github.com/heygen-com/heygen-cli/internal/command"
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
Expand All @@ -27,14 +28,17 @@ import (
func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
var rawData string

description := spec.Description
// CLI-surface description overlay (internal/clidesc). Resolved here at
// build time; spec is never mutated. Falls through to spec text when no
// override exists.
description := clidesc.Description(spec)
if spec.BodyEncoding == "json" && !hasBodyFlags(spec) {
description += "\n\nUse --request-schema to see all available request fields."
}

cmd := &cobra.Command{
Use: buildUseLine(spec),
Short: spec.Summary,
Short: clidesc.Summary(spec),
Long: description,
Example: strings.Join(spec.Examples, "\n"),
Args: func(cmd *cobra.Command, args []string) error {
Expand All @@ -55,6 +59,9 @@ func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
defer restoreRequiredFlagAnnotations(cmd)

if show, schema := requestedSchema(cmd, spec); show {
// Apply CLI-surface field-description overrides (no-op when
// none exist). Resolved at render time; spec is not mutated.
schema = clidesc.Schema(spec, schema)
var buf bytes.Buffer
if err := json.Compact(&buf, []byte(schema)); err != nil {
// Fallback: print as-is if compact fails.
Expand Down Expand Up @@ -199,9 +206,10 @@ func buildCobraCommand(spec *command.Spec, ctx *cmdContext) *cobra.Command {
},
}

// Register flags from spec
// Register flags from spec. Flag usage text is resolved through the
// description overlay (falls through to FlagSpec.Help when no override).
for _, flag := range spec.Flags {
registerFlag(cmd, flag)
registerFlag(cmd, flag, clidesc.FlagHelp(spec, flag))
}

if spec.RequestSchema != "" {
Expand Down Expand Up @@ -363,8 +371,11 @@ func restoreRequiredFlagAnnotations(cmd *cobra.Command) {
}

// registerFlag adds a typed flag to the Cobra command based on the FlagSpec.
func registerFlag(cmd *cobra.Command, flag command.FlagSpec) {
helpText := flag.Help
// help is the resolved usage text (a CLI-surface override if one applies,
// otherwise FlagSpec.Help); the "(required)"/"(allowed: ...)" suffixes are
// appended on top of it.
func registerFlag(cmd *cobra.Command, flag command.FlagSpec, help string) {
helpText := help
if flag.Required {
helpText += " (required)"
}
Expand Down
161 changes: 161 additions & 0 deletions cmd/heygen/descriptions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package main

import (
"encoding/json"
"strings"
"testing"

"github.com/heygen-com/heygen-cli/internal/command"
)

// These tests exercise the builder integration: that the clidesc overlay is
// applied to the rendered Cobra command surface (--help, --response-schema)
// and that non-overridden text falls through unchanged. The overlay resolvers
// themselves are unit-tested in internal/clidesc.

// TestDescriptionOverride_OperationLong verifies that a command whose endpoint
// has a description override renders the override as Long help (`--help`), not
// the generated spec.Description.
func TestDescriptionOverride_OperationLong(t *testing.T) {
// /v3/voices/clone POST is overridden. The generated description cites
// "GET /v3/voices/{voice_clone_id}"; the override replaces it with the CLI
// `heygen voice get` framing.
spec := &command.Spec{
Group: "voice",
Name: "clone create",
Summary: "Create a voice clone",
Description: "Creates a voice clone from an audio file. Returns a voice_clone_id that can be polled via GET /v3/voices/{voice_clone_id} until the status is 'complete'.",
Endpoint: "/v3/voices/clone",
Method: "POST",
Examples: []string{"heygen voice clone create --request-schema"},
}

res := runGenCommand(t, "http://example.test", "test-key", spec, "clone", "create", "--help")
if res.ExitCode != 0 {
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
}
if !strings.Contains(res.Stdout, "poll it with `heygen voice get <voice-clone-id>`") {
t.Fatalf("Long help did not render override:\n%s", res.Stdout)
}
if strings.Contains(res.Stdout, "GET /v3/voices/{voice_clone_id}") {
t.Fatalf("Long help still shows generated HTTP-framed text:\n%s", res.Stdout)
}
}

// TestDescriptionOverride_FlagHelp verifies that an overridden flag renders the
// override usage text, while a non-overridden flag on the same command falls
// through to its generated FlagSpec.Help.
func TestDescriptionOverride_FlagHelp(t *testing.T) {
spec := &command.Spec{
Group: "video-translate",
Name: "create",
Summary: "Create video translation",
Endpoint: "/v3/video-translations",
Method: "POST",
BodyEncoding: "json",
Flags: []command.FlagSpec{
// Overridden flag — generated help cites GET /v3/video-translations/languages.
{Name: "output-languages", Type: "string-slice", Source: "body", JSONName: "output_languages", Help: "Use GET /v3/video-translations/languages for valid values."},
// Non-overridden flag — should keep its generated help verbatim.
{Name: "title", Type: "string", Source: "body", JSONName: "title", Help: "Optional title for the translation."},
},
Examples: []string{"heygen video-translate create --output-languages es"},
}

res := runGenCommand(t, "http://example.test", "test-key", spec, "create", "--help")
if res.ExitCode != 0 {
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
}
if !strings.Contains(res.Stdout, "Run 'heygen video-translate languages list' for valid values") {
t.Fatalf("overridden flag help not rendered:\n%s", res.Stdout)
}
if strings.Contains(res.Stdout, "GET /v3/video-translations/languages") {
t.Fatalf("overridden flag still shows generated HTTP text:\n%s", res.Stdout)
}
if !strings.Contains(res.Stdout, "Optional title for the translation.") {
t.Fatalf("non-overridden flag did not fall through to generated help:\n%s", res.Stdout)
}
}

// TestDescriptionOverride_ResponseSchemaField verifies that a response-schema
// field with an override renders the override description in
// `--response-schema` output, while a sibling field with no override is
// untouched.
func TestDescriptionOverride_ResponseSchemaField(t *testing.T) {
// /v3/videos/{video_id} GET overrides video_url and captioned_video_url
// but not subtitle_url.
spec := &command.Spec{
Group: "video",
Name: "get",
Summary: "Get video",
Endpoint: "/v3/videos/{video_id}",
Method: "GET",
Args: []command.ArgSpec{{Name: "video-id", Param: "video_id"}},
ResponseSchema: `{
"properties": {
"data": {
"properties": {
"video_url": {"description": "Presigned URL to download the video file", "type": "string"},
"subtitle_url": {"description": "Presigned URL to download the SRT subtitle file", "type": "string"}
}
}
}
}`,
Examples: []string{"heygen video get <video-id>"},
}

res := runGenCommand(t, "http://example.test", "", spec, "get", "vid_123", "--response-schema")
if res.ExitCode != 0 {
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
}

var doc struct {
Properties struct {
Data struct {
Properties map[string]struct {
Description string `json:"description"`
} `json:"properties"`
} `json:"data"`
} `json:"properties"`
}
if err := json.Unmarshal([]byte(res.Stdout), &doc); err != nil {
t.Fatalf("response-schema output not valid JSON: %v\n%s", err, res.Stdout)
}
props := doc.Properties.Data.Properties
if got := props["video_url"].Description; !strings.Contains(got, "heygen video download <video-id>") {
t.Fatalf("video_url description not overridden: %q", got)
}
if got := props["subtitle_url"].Description; got != "Presigned URL to download the SRT subtitle file" {
t.Fatalf("subtitle_url should fall through unchanged, got: %q", got)
}
}

// TestDescriptionOverride_Fallthrough verifies that a command on an endpoint
// with NO override keeps its generated Short, Long, and flag help verbatim.
func TestDescriptionOverride_Fallthrough(t *testing.T) {
// /v3/videos GET (video list) has no override.
spec := &command.Spec{
Group: "video",
Name: "list",
Summary: "List videos",
Description: "Returns a paginated list of all videos in the account.",
Endpoint: "/v3/videos",
Method: "GET",
Paginated: true,
Flags: []command.FlagSpec{
{Name: "limit", Type: "int", Source: "query", JSONName: "limit", Help: "Maximum number of videos to return."},
},
Examples: []string{"heygen video list --limit 10"},
}

res := runGenCommand(t, "http://example.test", "test-key", spec, "list", "--help")
if res.ExitCode != 0 {
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
}
if !strings.Contains(res.Stdout, "Returns a paginated list of all videos in the account.") {
t.Fatalf("non-overridden Long help should be the generated text:\n%s", res.Stdout)
}
if !strings.Contains(res.Stdout, "Maximum number of videos to return.") {
t.Fatalf("non-overridden flag help should be the generated text:\n%s", res.Stdout)
}
}
Loading
Loading