diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6618cd..8c36678 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: @@ -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 ` ...", + 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 `."}, +}, +``` + +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: diff --git a/cmd/heygen/builder.go b/cmd/heygen/builder.go index e27a060..c278326 100644 --- a/cmd/heygen/builder.go +++ b/cmd/heygen/builder.go @@ -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" @@ -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 { @@ -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. @@ -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 != "" { @@ -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)" } diff --git a/cmd/heygen/descriptions_test.go b/cmd/heygen/descriptions_test.go new file mode 100644 index 0000000..2cb4179 --- /dev/null +++ b/cmd/heygen/descriptions_test.go @@ -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 `") { + 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 "}, + } + + 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 ") { + 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) + } +} diff --git a/internal/clidesc/clidesc.go b/internal/clidesc/clidesc.go new file mode 100644 index 0000000..1ccc985 --- /dev/null +++ b/internal/clidesc/clidesc.go @@ -0,0 +1,274 @@ +// Package clidesc holds the CLI-surface description overlay: hand-written +// reframes of OpenAPI descriptions that read badly on the CLI surface, plus +// the resolvers the command builder uses to apply them. +// +// The CLI generates its command surface from HeyGen's OpenAPI spec. Some spec +// 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. +// +// This is a runtime overlay, NOT a codegen/spec change: command.Spec is +// generated and immutable (see AGENTS.md), so overrides are resolved at +// command-build time and never mutate the Spec. It mirrors the curated-lookup +// pattern used for --human columns (DefaultColumns in cmd/heygen/columns.go). +// It lives in internal/ (not gen/) so both the command builder and the +// advisory resync checker (make check-descriptions) share one source of truth. +package clidesc + +import ( + "encoding/json" + + "github.com/heygen-com/heygen-cli/internal/command" +) + +// Override is a CLI-surface description overlay for one generated command, +// keyed by the command's (Endpoint, Method) — the same pair that uniquely +// identifies a command.Spec. +// +// Every field is optional. An empty field falls through to the spec text +// unchanged. Most commands have NO override. +type Override struct { + // Summary overrides cobra.Command.Short (the one-line help). Empty → spec.Summary. + Summary string + // Description overrides cobra.Command.Long (the long help). Empty → spec.Description. + Description string + // Flags overrides per-flag usage text, keyed by the flag's kebab-case name + // (e.g. "brand-glossary-id"). A flag absent from this map keeps its + // generated FlagSpec.Help. + Flags map[string]string + // Fields overrides schema-property descriptions surfaced by + // --request-schema / --response-schema, keyed by the JSON property name + // (e.g. "video_url", "style_id"). Applied recursively to every property of + // that name in the schema JSON. A property absent from this map keeps its + // generated description. + Fields map[string]string +} + +// Key identifies a command by its HTTP identity. (Endpoint, Method) uniquely +// identifies a command.Spec, so it is a stable key that survives command +// renames in the generated surface. +type Key struct { + Endpoint string + Method string +} + +// Overrides is the sparse overlay table. Keyed by endpoint+method. Only +// entries that genuinely diverge from CLI framing appear here; everything else +// falls through to the generated spec text. +// +// Precedence: this overlay wins over the generated spec text. +var Overrides = map[Key]Override{ + // brand glossaries list — reframe the POST /v2/video_translate cross-ref + // (doubly wrong: CLI uses /v3/video-translations) and "this endpoint" → + // "this command". + {"/v3/brand-glossaries", "GET"}: { + Description: "List brand glossaries (custom term mappings, a.k.a. brand voices) in the authenticated user's workspace. A brand glossary enforces custom term translations during video translation — for example, translating \"Reformer\" as Pilates equipment instead of as a political activist. Brand glossaries are created and edited in the HeyGen web app under Brand Kit; this command is read-only for discovery.\n\nPass the returned `brand_glossary_id` (or the legacy alias `brand_voice_id`) as `--brand-glossary-id` to `heygen video-translate create`.", + Fields: map[string]string{ + // BrandGlossaryItem.brand_glossary_id + "brand_glossary_id": "Unique brand glossary ID. Pass as `--brand-glossary-id` (or the legacy `--brand-voice-id`) to `heygen video-translate create` to apply the glossary during translation.", + }, + }, + + // brand kits list — POST /v3/video-agents cross-ref → CLI command. + {"/v3/brand-kits", "GET"}: { + Description: "Returns brand kits available in the authenticated user's workspace. Each brand kit contains colors, fonts, and logos that can be applied to Video Agent sessions. Pass the returned `brand_kit_id` as `--brand-kit-id` to `heygen video-agent create` to generate on-brand videos.", + Fields: map[string]string{ + // BrandKitItem.brand_kit_id + "brand_kit_id": "Unique brand kit identifier. Pass as `--brand-kit-id` to `heygen video-agent create`.", + }, + }, + + // voice speech create — GET /v3/voices?engine=starfish → CLI command. + {"/v3/voices/speech", "POST"}: { + Description: "Synthesize speech audio from text using a specified voice. The voice must support the starfish engine — run `heygen voice list --engine starfish` to find compatible voices. Supports plain text and SSML. Speed range: 0.5–2.0x. Returns a URL to the generated audio file along with duration and optional word-level timestamps.", + }, + + // voice clone create — poll via GET /v3/voices/{id} → heygen voice get + // (no --wait on this command, so the reframe points only at voice get). + {"/v3/voices/clone", "POST"}: { + Description: "Creates a voice clone from an audio file. Returns a voice_clone_id; poll it with `heygen voice get ` until the status is 'complete'. The resulting voice can then be used with `heygen voice speech create` and `heygen video create`.", + }, + + // video get — surface the heygen video download command on the URL fields. + {"/v3/videos/{video_id}", "GET"}: { + Fields: map[string]string{ + "video_url": "URL to download the video file. Tip: `heygen video download ` saves it to disk for you.", + "captioned_video_url": "URL to download the video file with captions burned in. Tip: `heygen video download --asset captioned` saves it to disk.", + }, + }, + + // video-translate create — flag help reframes. + // + // Flag help intentionally avoids backticks: Cobra/pflag treats the first + // backtick-delimited token in a flag's usage string as the value-type + // placeholder shown after the flag name (e.g. `--style-id `), + // which would mangle the column. Backticks are fine in Summary/Description + // (Long help) and in schema field descriptions, just not in flag usage. + {"/v3/video-translations", "POST"}: { + Flags: map[string]string{ + "output-languages": "Target language names (e.g. 'Chinese (Cantonese, Traditional)', 'Spanish (Spain)', 'English'). Run 'heygen video-translate languages list' for valid values. Use one for single translation, multiple for batch.", + "brand-glossary-id": "Brand glossary ID for custom term translations (e.g. translate 'Reformer' as the Pilates equipment, not 'political activist'). Alias for the legacy --brand-voice-id flag. Discover IDs via 'heygen brand glossaries list'.", + "brand-voice-id": "Brand glossary ID for custom term translations. Legacy alias for --brand-glossary-id — both are accepted and resolve to the same workspace record. Discover IDs via 'heygen brand glossaries list'.", + }, + }, + + // video-translate proofreads create — brand-glossary-id flag help. + {"/v3/video-translations/proofreads", "POST"}: { + Flags: map[string]string{ + "brand-glossary-id": "Brand glossary ID for custom term translations (e.g. translate 'Reformer' as 'Pilates equipment', not 'political activist'). Alias for the legacy --brand-voice-id flag. Discover IDs via 'heygen brand glossaries list'.", + }, + }, + + // video-translate proofreads generate — video_translation_id poll reframe. + {"/v3/video-translations/proofreads/{proofread_id}/generate", "POST"}: { + Fields: map[string]string{ + "video_translation_id": "Video translation ID — poll status with `heygen video-translate get `, or pass `--wait` on create.", + }, + }, + + // lipsync create — lipsync_id poll reframe. + {"/v3/lipsyncs", "POST"}: { + Fields: map[string]string{ + "lipsync_id": "Lipsync ID — poll status with `heygen lipsync get `, or pass `--wait` on create.", + }, + }, + + // avatar looks get/list — AvatarLookItem.id / .supported_api_engines. + // Both commands return the same AvatarLookItem model, so both are keyed. + {"/v3/avatars/looks/{look_id}", "GET"}: { + Fields: avatarLookItemFields, + }, + {"/v3/avatars/looks", "GET"}: { + Fields: avatarLookItemFields, + }, + + // video-agent create — style-id flag + CreateVideoAgentResponse.video_id. + {"/v3/video-agents", "POST"}: { + Flags: map[string]string{ + "style-id": "Style ID from 'heygen video-agent styles list'. Applies a curated visual template to the generated video.", + }, + Fields: map[string]string{ + "video_id": "Video ID — poll status with `heygen video get `, or pass `--wait` on `heygen video-agent create`. Nullable in future multi-turn flows.", + }, + }, + + // video-agent styles list — StyleItem.style_id. + {"/v3/video-agents/styles", "GET"}: { + Fields: map[string]string{ + "style_id": "Unique style identifier. Pass as `--style-id` to `heygen video-agent create`.", + }, + }, + + // asset direct-uploads create — reframe the /complete and /v3/assets + // cross-refs to CLI commands (the raw S3 PUT stays, it is genuinely + // unwrapped by the CLI). + {"/v3/assets/direct-uploads", "POST"}: { + Description: "Begin a direct-to-S3 upload. Returns an `asset_id` and a presigned `upload_url`; PUT the file bytes to `upload_url` yourself (e.g. with curl), then run `heygen asset complete create ` to finalize. Unlike `heygen asset create` (which proxies the bytes), this never sends the file through HeyGen.", + Fields: map[string]string{ + // CreateAssetUploadResponse.asset_id + "asset_id": "Reusable asset identifier. Becomes usable after `heygen asset complete create `.", + }, + }, + + // asset create — UploadAssetV3Response.asset_id. + {"/v3/assets", "POST"}: { + Fields: map[string]string{ + "asset_id": "Unique asset identifier for use in other commands, e.g. as an `asset_id` reference in `heygen video-agent create` or `heygen video create`.", + }, + }, +} + +// avatarLookItemFields is the AvatarLookItem field overlay shared by +// `avatar looks get` and `avatar looks list` (both return AvatarLookItem). +var avatarLookItemFields = map[string]string{ + "id": "Unique look identifier. Pass this as `avatar_id` (in the `--data`/`-d` JSON) to `heygen video create`.", + "supported_api_engines": "Engine values this look supports for `heygen video create`.", +} + +// ForSpec returns the overlay for a spec, keyed by its HTTP identity, and +// whether one exists. Resolved at build time; the Spec is never mutated. +func ForSpec(spec *command.Spec) (Override, bool) { + o, ok := Overrides[Key{spec.Endpoint, spec.Method}] + return o, ok +} + +// Summary returns the CLI Short text: the overlay summary if set, else the +// generated spec summary. +func Summary(spec *command.Spec) string { + if o, ok := ForSpec(spec); ok && o.Summary != "" { + return o.Summary + } + return spec.Summary +} + +// Description returns the CLI Long text: the overlay description if set, else +// the generated spec description. +func Description(spec *command.Spec) string { + if o, ok := ForSpec(spec); ok && o.Description != "" { + return o.Description + } + return spec.Description +} + +// FlagHelp returns the usage text for a flag: the overlay value if present, +// else the generated FlagSpec.Help. +func FlagHelp(spec *command.Spec, flag command.FlagSpec) string { + if o, ok := ForSpec(spec); ok { + if help, found := o.Flags[flag.Name]; found { + return help + } + } + return flag.Help +} + +// Schema returns the schema JSON for --request-schema / --response-schema with +// any field-description overrides applied. The input schema string is never +// mutated in place; when overrides apply, a rewritten copy is returned. When no +// Fields override exists (the common case) or the schema cannot be parsed, the +// original string is returned unchanged. +func Schema(spec *command.Spec, schema string) string { + o, ok := ForSpec(spec) + if !ok || len(o.Fields) == 0 || schema == "" { + return schema + } + + var doc any + if err := json.Unmarshal([]byte(schema), &doc); err != nil { + // Faithful fallback: emit the original schema untouched. + return schema + } + applyFieldOverrides(doc, o.Fields) + + // Re-marshal with indentation to match the human-readable schema output. + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return schema + } + return string(out) +} + +// applyFieldOverrides walks a decoded JSON schema and, for every "properties" +// object, replaces the "description" of any property whose name appears in +// fields. Recurses into nested objects so fields under a wrapping "data" +// property are reached. +func applyFieldOverrides(node any, fields map[string]string) { + switch v := node.(type) { + case map[string]any: + if props, ok := v["properties"].(map[string]any); ok { + for name, prop := range props { + if propMap, ok := prop.(map[string]any); ok { + if override, found := fields[name]; found { + propMap["description"] = override + } + } + } + } + for _, child := range v { + applyFieldOverrides(child, fields) + } + case []any: + for _, child := range v { + applyFieldOverrides(child, fields) + } + } +} diff --git a/internal/clidesc/clidesc_test.go b/internal/clidesc/clidesc_test.go new file mode 100644 index 0000000..957aa1a --- /dev/null +++ b/internal/clidesc/clidesc_test.go @@ -0,0 +1,87 @@ +package clidesc + +import ( + "strings" + "testing" + + "github.com/heygen-com/heygen-cli/gen" + "github.com/heygen-com/heygen-cli/internal/command" +) + +// TestResolvers covers the override-wins and fall-through paths of the overlay +// resolvers directly. +func TestResolvers(t *testing.T) { + // Overridden endpoint: description wins, summary falls through (no Summary + // override defined for this entry). + clone := &command.Spec{ + Endpoint: "/v3/voices/clone", + Method: "POST", + Summary: "Create a voice clone", + Description: "generated description with GET /v3/voices/{voice_clone_id}", + } + if got := Summary(clone); got != "Create a voice clone" { + t.Errorf("summary should fall through, got %q", got) + } + if got := Description(clone); !strings.Contains(got, "heygen voice get") { + t.Errorf("description should be overridden, got %q", got) + } + + // Non-overridden endpoint: everything falls through. + other := &command.Spec{ + Endpoint: "/v3/videos", + Method: "GET", + Summary: "List videos", + Description: "generated list description", + } + if got := Summary(other); got != "List videos" { + t.Errorf("summary fall-through failed, got %q", got) + } + if got := Description(other); got != "generated list description" { + t.Errorf("description fall-through failed, got %q", got) + } + flag := command.FlagSpec{Name: "limit", Help: "generated limit help"} + if got := FlagHelp(other, flag); got != "generated limit help" { + t.Errorf("flag help fall-through failed, got %q", got) + } + + // Flag override-wins on an overridden endpoint. + vt := &command.Spec{Endpoint: "/v3/video-translations", Method: "POST"} + ol := command.FlagSpec{Name: "output-languages", Help: "generated GET ... help"} + if got := FlagHelp(vt, ol); !strings.Contains(got, "heygen video-translate languages list") { + t.Errorf("flag help override-wins failed, got %q", got) + } + // A flag with no override on an overridden endpoint falls through. + plain := command.FlagSpec{Name: "title", Help: "generated title help"} + if got := FlagHelp(vt, plain); got != "generated title help" { + t.Errorf("unlisted flag should fall through, got %q", got) + } + + // Schema fall-through: no Fields override → identical string returned. + const sc = `{"properties":{"x":{"description":"d"}}}` + if got := Schema(other, sc); got != sc { + t.Errorf("schema with no override should be returned unchanged, got %q", got) + } + // Malformed schema on an overridden endpoint → original returned. + const bad = `{not json` + if got := Schema(clone, bad); got != bad { + t.Errorf("malformed schema should be returned unchanged, got %q", got) + } +} + +// TestAllEndpointsExist guards against typos and stale keys: every override +// key must match a real generated command (endpoint + method) in gen.Groups. +// If an upstream resync renames an endpoint, this test fails loudly so the +// override is updated rather than silently dead. +func TestAllEndpointsExist(t *testing.T) { + live := make(map[Key]bool) + for _, specs := range gen.Groups { + for _, s := range specs { + live[Key{s.Endpoint, s.Method}] = true + } + } + for key := range Overrides { + if !live[key] { + t.Errorf("override key {%s %s} does not match any generated command", key.Method, key.Endpoint) + } + } +}