Skip to content

Commit 1de7729

Browse files
authored
config: add layered config commands (PRINFRA-141) (#22)
## Description ### What This Adds This PR adds the first persistent config system for the CLI. `heygen config set`, `heygen config get`, and `heygen config list` are backed by a local `config.toml` file in the CLI config directory. Config commands return structured JSON following the same stdout/stderr contract as the rest of the CLI. The config file is only created when the user explicitly runs `config set` — until then, all values come from defaults. ### Config Options | Key | Type | Default | Env Override | Description | |-----|------|---------|-------------|-------------| | `output` | `json` \| `human` | `json` | `HEYGEN_OUTPUT` | Default output format | | `analytics` | bool | `true` | `HEYGEN_NO_ANALYTICS` | PostHog analytics (opt-out) | The `HEYGEN_NO_ANALYTICS` env var uses a negative naming convention (same as `NO_COLOR`, `HOMEBREW_NO_ANALYTICS`, `GH_NO_UPDATE_NOTIFIER`). Unset means default behavior (on); set to any non-empty value means disabled. This avoids ambiguity about whether an unset var means "on" or "off." ### Resolution Model Config lookup uses a layered provider with clear precedence: 1. Environment variable 2. Config file (`~/.heygen/config.toml`) 3. Built-in default ### Type Handling String settings (`output`) are stored as TOML strings. Boolean settings (`analytics`) are stored as TOML booleans. This prevents type drift between the CLI and on-disk config. ### Error Handling A missing config file falls back to defaults silently. A corrupt or unreadable config file surfaces an error immediately — important for debugging persisted local settings. ### Example Outputs **List all config (fresh install, no config file):** ``` $ heygen config list [ {"key": "analytics", "value": "true", "source": "default"}, {"key": "output", "value": "json", "source": "default"} ] ``` **Set a value:** ``` $ heygen config set output human {"key": "output", "value": "human"} ``` **Get shows source (file vs env vs default):** ``` $ heygen config get output {"key": "output", "value": "human", "source": "file"} $ HEYGEN_OUTPUT=json heygen config get output {"key": "output", "value": "json", "source": "env"} ``` **Validation errors:** ``` $ heygen config set bogus value {"error": {"code": "usage_error", "message": "invalid config key \"bogus\""}} $ heygen config set output xml {"error": {"code": "usage_error", "message": "output must be one of: json, human"}} ``` **Linear issue:** PRINFRA-141 ## Testing ### Automated (25+ tests) - **EnvProvider** (5): BaseURL default, Output default + custom, Analytics default + disabled, GetEnv mapping + bogus key - **FileProvider** (11): Get exists/missing/no-file/corrupt, Set new-file/update-existing/overwrite/boolean-type/string-type, GetAll, GetAll corrupt - **LayeredProvider** (5): all defaults, env overrides default, file overrides default, env overrides file, analytics default/from-file/env-disable - **Config commands** (9): set success/invalid-key/invalid-value/api-base-rejected/skipAuth, get default/from-env/from-file/invalid-key, list all-defaults/mixed-sources - All existing M0 + auth tests pass unchanged ### Manual smoke tests - `heygen config list` → 2 keys with defaults - `heygen config set output human` → TOML file written, `config get` shows `source: "file"` - `HEYGEN_OUTPUT=json heygen config get output` → env overrides file, `source: "env"` - `heygen config set analytics false` → stored as TOML boolean `false` (not string) - `heygen config set bogus value` → exit 2 - `heygen config set api_base "https://..."` → exit 2 (not a user-facing key)
1 parent f7b4213 commit 1de7729

13 files changed

Lines changed: 943 additions & 8 deletions

cmd/heygen/config_cmd.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"slices"
7+
8+
"github.com/heygen-com/heygen-cli/internal/config"
9+
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
type configResponse struct {
14+
Key string `json:"key"`
15+
Value string `json:"value"`
16+
Source string `json:"source,omitempty"`
17+
}
18+
19+
func newConfigCmd(ctx *cmdContext) *cobra.Command {
20+
cmd := &cobra.Command{
21+
Use: "config",
22+
Short: "Manage CLI configuration",
23+
Annotations: map[string]string{"skipAuth": "true"},
24+
}
25+
cmd.AddCommand(newConfigSetCmd(ctx))
26+
cmd.AddCommand(newConfigGetCmd(ctx))
27+
cmd.AddCommand(newConfigListCmd(ctx))
28+
return cmd
29+
}
30+
31+
func configProviderWithSource(ctx *cmdContext) (config.ProviderWithSource, error) {
32+
provider, ok := ctx.configProvider.(config.ProviderWithSource)
33+
if !ok {
34+
return nil, clierrors.New("config provider does not expose value sources")
35+
}
36+
return provider, nil
37+
}
38+
39+
func writableConfigProvider(ctx *cmdContext) (config.WritableProvider, error) {
40+
provider, ok := ctx.configProvider.(config.WritableProvider)
41+
if !ok {
42+
return nil, clierrors.New("config provider does not support writes")
43+
}
44+
return provider, nil
45+
}
46+
47+
func validateConfigKey(key string) error {
48+
if !slices.Contains(config.ValidKeys, key) {
49+
return clierrors.NewUsage(fmt.Sprintf("invalid config key %q", key))
50+
}
51+
return nil
52+
}
53+
54+
func validateConfigValue(key, value string) error {
55+
switch key {
56+
case config.KeyOutput:
57+
if value != "json" && value != "human" {
58+
return clierrors.NewUsage("output must be one of: json, human")
59+
}
60+
case config.KeyAnalytics:
61+
if value != "true" && value != "false" {
62+
return clierrors.NewUsage(key + " must be one of: true, false")
63+
}
64+
}
65+
return nil
66+
}
67+
68+
func marshalData(v any) ([]byte, error) {
69+
data, err := json.Marshal(v)
70+
if err != nil {
71+
return nil, clierrors.New(fmt.Sprintf("failed to encode response: %v", err))
72+
}
73+
return data, nil
74+
}
75+
76+
func newConfigSetCmd(ctx *cmdContext) *cobra.Command {
77+
return &cobra.Command{
78+
Use: "set <key> <value>",
79+
Short: "Set a persistent config value",
80+
Args: cobra.ExactArgs(2),
81+
RunE: func(cmd *cobra.Command, args []string) error {
82+
key := args[0]
83+
value := args[1]
84+
85+
if err := validateConfigKey(key); err != nil {
86+
return err
87+
}
88+
if err := validateConfigValue(key, value); err != nil {
89+
return err
90+
}
91+
92+
provider, err := writableConfigProvider(ctx)
93+
if err != nil {
94+
return err
95+
}
96+
if err := provider.Set(key, value); err != nil {
97+
return clierrors.New(err.Error())
98+
}
99+
100+
data, err := marshalData(configResponse{Key: key, Value: value})
101+
if err != nil {
102+
return err
103+
}
104+
return ctx.formatter.Data(data)
105+
},
106+
}
107+
}
108+
109+
func newConfigGetCmd(ctx *cmdContext) *cobra.Command {
110+
return &cobra.Command{
111+
Use: "get <key>",
112+
Short: "Get a config value",
113+
Args: cobra.ExactArgs(1),
114+
RunE: func(cmd *cobra.Command, args []string) error {
115+
key := args[0]
116+
if err := validateConfigKey(key); err != nil {
117+
return err
118+
}
119+
120+
provider, err := configProviderWithSource(ctx)
121+
if err != nil {
122+
return err
123+
}
124+
source, err := provider.Resolve(key)
125+
if err != nil {
126+
return clierrors.New(err.Error())
127+
}
128+
129+
data, err := marshalData(configResponse{
130+
Key: key,
131+
Value: source.Value,
132+
Source: source.Origin,
133+
})
134+
if err != nil {
135+
return err
136+
}
137+
return ctx.formatter.Data(data)
138+
},
139+
}
140+
}
141+
142+
func newConfigListCmd(ctx *cmdContext) *cobra.Command {
143+
return &cobra.Command{
144+
Use: "list",
145+
Short: "List effective config values",
146+
Args: cobra.NoArgs,
147+
RunE: func(cmd *cobra.Command, args []string) error {
148+
provider, err := configProviderWithSource(ctx)
149+
if err != nil {
150+
return err
151+
}
152+
153+
items := make([]configResponse, 0, len(config.ValidKeys))
154+
for _, key := range config.ValidKeys {
155+
source, err := provider.Resolve(key)
156+
if err != nil {
157+
return clierrors.New(err.Error())
158+
}
159+
items = append(items, configResponse{
160+
Key: key,
161+
Value: source.Value,
162+
Source: source.Origin,
163+
})
164+
}
165+
166+
data, err := marshalData(items)
167+
if err != nil {
168+
return err
169+
}
170+
return ctx.formatter.Data(data)
171+
},
172+
}
173+
}

cmd/heygen/config_cmd_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
)
9+
10+
func TestConfigSet_Success(t *testing.T) {
11+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
12+
13+
res := runCommand(t, "http://example.invalid", "", "config", "set", "output", "human")
14+
if res.ExitCode != 0 {
15+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
16+
}
17+
18+
data, err := os.ReadFile(filepath.Join(os.Getenv("HEYGEN_CONFIG_DIR"), "config.toml"))
19+
if err != nil {
20+
t.Fatalf("ReadFile: %v", err)
21+
}
22+
if string(data) == "" {
23+
t.Fatal("expected config file contents")
24+
}
25+
}
26+
27+
func TestConfigSet_InvalidKey(t *testing.T) {
28+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
29+
res := runCommand(t, "http://example.invalid", "", "config", "set", "bogus", "value")
30+
if res.ExitCode != 2 {
31+
t.Fatalf("ExitCode = %d, want 2\nstderr: %s", res.ExitCode, res.Stderr)
32+
}
33+
}
34+
35+
func TestConfigSet_APIBaseNotExposed(t *testing.T) {
36+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
37+
res := runCommand(t, "http://example.invalid", "", "config", "set", "api_base", "https://api-dev.heygen.com")
38+
if res.ExitCode != 2 {
39+
t.Fatalf("ExitCode = %d, want 2 (api_base is internal)\nstderr: %s", res.ExitCode, res.Stderr)
40+
}
41+
}
42+
43+
func TestConfigSet_InvalidOutputValue(t *testing.T) {
44+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
45+
res := runCommand(t, "http://example.invalid", "", "config", "set", "output", "xml")
46+
if res.ExitCode != 2 {
47+
t.Fatalf("ExitCode = %d, want 2\nstderr: %s", res.ExitCode, res.Stderr)
48+
}
49+
}
50+
51+
func TestConfigSet_SkipsAuth(t *testing.T) {
52+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
53+
res := runCommand(t, "http://example.invalid", "", "config", "set", "output", "human")
54+
if res.ExitCode != 0 {
55+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
56+
}
57+
}
58+
59+
func TestConfigGet_Default(t *testing.T) {
60+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
61+
res := runCommand(t, "http://example.invalid", "", "config", "get", "output")
62+
if res.ExitCode != 0 {
63+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
64+
}
65+
66+
var parsed configResponse
67+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
68+
t.Fatalf("Unmarshal: %v", err)
69+
}
70+
if parsed.Value != "json" || parsed.Source != "default" {
71+
t.Fatalf("parsed = %#v", parsed)
72+
}
73+
}
74+
75+
func TestConfigGet_FromEnv(t *testing.T) {
76+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
77+
t.Setenv("HEYGEN_OUTPUT", "human")
78+
79+
res := runCommand(t, "http://example.invalid", "", "config", "get", "output")
80+
if res.ExitCode != 0 {
81+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
82+
}
83+
84+
var parsed configResponse
85+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
86+
t.Fatalf("Unmarshal: %v", err)
87+
}
88+
if parsed.Value != "human" || parsed.Source != "env" {
89+
t.Fatalf("parsed = %#v", parsed)
90+
}
91+
}
92+
93+
func TestConfigGet_FromFile(t *testing.T) {
94+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
95+
if err := os.WriteFile(filepath.Join(os.Getenv("HEYGEN_CONFIG_DIR"), "config.toml"), []byte("output = \"human\"\n"), 0o600); err != nil {
96+
t.Fatalf("WriteFile: %v", err)
97+
}
98+
99+
res := runCommand(t, "http://example.invalid", "", "config", "get", "output")
100+
if res.ExitCode != 0 {
101+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
102+
}
103+
104+
var parsed configResponse
105+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
106+
t.Fatalf("Unmarshal: %v", err)
107+
}
108+
if parsed.Value != "human" || parsed.Source != "file" {
109+
t.Fatalf("parsed = %#v", parsed)
110+
}
111+
}
112+
113+
func TestConfigGet_InvalidKey(t *testing.T) {
114+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
115+
res := runCommand(t, "http://example.invalid", "", "config", "get", "bogus")
116+
if res.ExitCode != 2 {
117+
t.Fatalf("ExitCode = %d, want 2\nstderr: %s", res.ExitCode, res.Stderr)
118+
}
119+
}
120+
121+
func TestConfigList_AllDefaults(t *testing.T) {
122+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
123+
res := runCommand(t, "http://example.invalid", "", "config", "list")
124+
if res.ExitCode != 0 {
125+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
126+
}
127+
128+
var parsed []configResponse
129+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
130+
t.Fatalf("Unmarshal: %v", err)
131+
}
132+
if len(parsed) != 2 {
133+
t.Fatalf("len(parsed) = %d, want 4", len(parsed))
134+
}
135+
}
136+
137+
func TestConfigList_MixedSources(t *testing.T) {
138+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
139+
t.Setenv("HEYGEN_OUTPUT", "json")
140+
if err := os.WriteFile(filepath.Join(os.Getenv("HEYGEN_CONFIG_DIR"), "config.toml"), []byte("analytics = false\n"), 0o600); err != nil {
141+
t.Fatalf("WriteFile: %v", err)
142+
}
143+
144+
res := runCommand(t, "http://example.invalid", "", "config", "list")
145+
if res.ExitCode != 0 {
146+
t.Fatalf("ExitCode = %d, want 0\nstderr: %s", res.ExitCode, res.Stderr)
147+
}
148+
149+
var parsed []configResponse
150+
if err := json.Unmarshal([]byte(res.Stdout), &parsed); err != nil {
151+
t.Fatalf("Unmarshal: %v", err)
152+
}
153+
if len(parsed) != 2 {
154+
t.Fatalf("len(parsed) = %d, want 4", len(parsed))
155+
}
156+
}

cmd/heygen/context.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ func skipAuth(cmd *cobra.Command) bool {
3030
// initContext sets up the config provider and, for commands that require
3131
// auth, resolves credentials and creates the HTTP client.
3232
func initContext(cmd *cobra.Command, version string, ctx *cmdContext) error {
33-
provider := &config.EnvProvider{}
33+
provider := &config.LayeredProvider{
34+
Env: &config.EnvProvider{},
35+
File: &config.FileProvider{},
36+
}
3437
ctx.configProvider = provider
3538

3639
if skipAuth(cmd) {

cmd/heygen/root.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,16 @@ func newRootCmd(version string, formatter output.Formatter) *cobra.Command {
1515
ctx := &cmdContext{formatter: formatter}
1616

1717
root := &cobra.Command{
18-
Use: "heygen",
19-
Short: "HeyGen CLI — create and manage videos, avatars, and more",
18+
Use: "heygen",
19+
Short: "HeyGen CLI — create and manage videos, avatars, and more",
20+
// NOTE: env var list is hardcoded. Keep in sync with envVarByKey in env_provider.go.
21+
Long: `HeyGen CLI — create and manage videos, avatars, and more.
22+
23+
Environment Variables:
24+
HEYGEN_API_KEY API key for authentication (overrides stored credentials)
25+
HEYGEN_OUTPUT Output format: json, human (default: json)
26+
HEYGEN_NO_ANALYTICS Disable analytics when set (default: enabled)
27+
HEYGEN_CONFIG_DIR Override config directory (default: ~/.heygen)`,
2028
Version: version,
2129
SilenceUsage: true, // we handle usage errors ourselves
2230
SilenceErrors: true, // we handle error output ourselves
@@ -31,6 +39,7 @@ func newRootCmd(version string, formatter output.Formatter) *cobra.Command {
3139
})
3240

3341
root.AddCommand(newAuthCmd(ctx))
42+
root.AddCommand(newConfigCmd(ctx))
3443
registerGroups(root, ctx, gen.Groups)
3544

3645
return root
@@ -58,6 +67,7 @@ func newRootCmdWithSpecs(version string, formatter output.Formatter, groups map[
5867
})
5968

6069
root.AddCommand(newAuthCmd(ctx))
70+
root.AddCommand(newConfigCmd(ctx))
6171
registerGroups(root, ctx, groups)
6272

6373
return root

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ require (
1010
gopkg.in/yaml.v3 v3.0.1
1111
)
1212

13-
require golang.org/x/term v0.30.0
13+
require (
14+
github.com/BurntSushi/toml v1.4.0
15+
golang.org/x/term v0.30.0
16+
)
1417

1518
require (
1619
github.com/go-openapi/jsonpointer v0.21.0 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
2+
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
13
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
24
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
35
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

0 commit comments

Comments
 (0)