From 1f19cab798ed11d7caee4a3ea5164500cb4e5755 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Fri, 9 Oct 2026 17:31:06 +0800 Subject: [PATCH] feat(config): expand environment variables in TOML API keys Allow TOML `api_key` values to reference `$NAME` and `${NAME}` variables while keeping environment and flag values literal. Trim expanded keys, treat undefined variables as empty, and preserve key-free file viewing without requiring service initialization credentials. Document the expansion behavior and add configuration tests and an example TOML file. --- README.md | 2 + internal/cli/config_expansion_test.go | 24 +++++++ internal/config/config.go | 10 +++ internal/config/expansion_test.go | 93 +++++++++++++++++++++++++++ jevwise.toml | 7 ++ 5 files changed, 136 insertions(+) create mode 100644 internal/cli/config_expansion_test.go create mode 100644 internal/config/expansion_test.go create mode 100644 jevwise.toml diff --git a/README.md b/README.md index e50cce8..367b7d8 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ Precedence: **changed flags > nonblank environment > TOML > defaults**. - Otherwise: current-directory `jevwise.toml`, then `$XDG_CONFIG_HOME/ts-jev/config.toml` or `$HOME/.config/ts-jev/config.toml`. Only a missing global file is allowed. The selected file replaces rather than merges with global settings; no ancestor-directory search. - Trust the current directory before running decision/MCP commands: local settings can change the upstream destination. Environment/changed flags still win. Invalid, oversized (over 1 MiB), linked or nonregular local files fail without global fallback; help/version do not load them. - Start with [config.example.toml](config.example.toml). Prefer environment credentials rather than command-line secrets. +- TOML `api_key` supports `$NAME`/`${NAME}`, for example `api_key = "${MY_JEV_KEY}"`. Runtime loading expands references and trims the result; undefined variables become empty. +- Environment and `--api-key` values stay literal. File viewing never expands references or requires a key; Jev service initialization requires a nonblank key. ### Initialize, inspect and edit diff --git a/internal/cli/config_expansion_test.go b/internal/cli/config_expansion_test.go new file mode 100644 index 0000000..72acba6 --- /dev/null +++ b/internal/cli/config_expansion_test.go @@ -0,0 +1,24 @@ +package cli + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestDecideRejectsMissingOrBlankExpandedKey(t *testing.T) { + configEnvironment(t) + const name = "JEVWISE_TEST_DECISION_KEY" + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte("api_key = '${"+name+"}'"), 0600); err != nil { + t.Fatal(err) + } + for _, value := range []string{"", " \t\n "} { + t.Setenv(name, value) + out, stderr, err := execute(NewRoot(), "decide", "--config", path, "--prompt", "synthetic", "--option", "A", "--option", "B") + if cause := errors.Unwrap(err); cause == nil || cause.Error() != "API key is required" || out != "" || stderr != "" { + t.Fatal("missing expanded key not rejected at service initialization", err) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 91ab7d6..6c0a4f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,7 @@ type Config struct { // Load applies changed flags > nonblank environment > TOML > defaults. // An empty path selects ./jevwise.toml before the XDG/HOME global file. // Only a missing default global file is optional; selected files never merge. +// TOML API keys expand $NAME/${NAME}; environment and flag keys stay literal. // Neither global Viper state nor the supplied flags are mutated. func Load(path string, flags *pflag.FlagSet) (Config, error) { v := viper.New() @@ -63,12 +64,21 @@ func Load(path string, flags *pflag.FlagSet) (Config, error) { if err := v.MergeConfigMap(env); err != nil { return Config{}, errors.New("cannot apply environment configuration") } + apiKeyFromFile := env["api_key"] == nil + if flags != nil { + if flag := flags.Lookup("api-key"); flag != nil && flag.Changed { + apiKeyFromFile = false + } + } values := make(map[string]string) for key := range defaults { value, ok := v.Get(key).(string) if !ok { return Config{}, fmt.Errorf("%s must be a string", key) } + if key == "api_key" && apiKeyFromFile { + value = os.ExpandEnv(value) + } values[key] = strings.TrimSpace(value) } return fromValues(values) diff --git a/internal/config/expansion_test.go b/internal/config/expansion_test.go new file mode 100644 index 0000000..13bef18 --- /dev/null +++ b/internal/config/expansion_test.go @@ -0,0 +1,93 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/pflag" +) + +func TestLoadExpandsOnlyFileAPIKeyAndTrimsResult(t *testing.T) { + cleanEnv(t) + const name = "JEVWISE_TEST_EXPANSION_KEY" + path := filepath.Join(t.TempDir(), "config.toml") + for _, test := range []struct { + name, file, variable, want string + }{ + {"dollar", "$" + name, "synthetic-key", "synthetic-key"}, + {"braces", "${" + name + "}", " \tsynthetic-key\n ", "synthetic-key"}, + {"embedded", "prefix-${" + name + "}-suffix", "part", "prefix-part-suffix"}, + {"missing", "$" + name, "", ""}, + {"whitespace", "$" + name, " \t\n ", ""}, + {"literal", "file-key", "ignored", "file-key"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv(name, test.variable) + if test.name == "missing" { + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + } + writeConfig(t, path, "api_key = '"+test.file+"'") + cfg, err := Load(path, nil) + if err != nil || cfg.APIKey != test.want { + t.Fatal("wrong file key resolution", err) + } + }) + } + writeConfig(t, path, "api_key = '$"+name+"'") + t.Setenv(name, "file-expanded-key") + t.Setenv("TS_JEV_API_KEY", " $"+name+" ") + flags := pflag.NewFlagSet("test", pflag.ContinueOnError) + flags.String("api-key", "unused", "") + cfg, err := Load(path, flags) + if err != nil || cfg.APIKey != "$"+name { + t.Fatal("environment key was expanded or precedence changed", err) + } + if err := flags.Set("api-key", " ${"+name+"} "); err != nil { + t.Fatal(err) + } + cfg, err = Load(path, flags) + if err != nil || cfg.APIKey != "${"+name+"}" { + t.Fatal("flag key was expanded or precedence changed", err) + } + if err := flags.Set("api-key", ""); err != nil { + t.Fatal(err) + } + cfg, err = Load(path, flags) + if err != nil || cfg.APIKey != "" { + t.Fatal("explicit empty flag fell back", err) + } + t.Setenv("TS_JEV_API_KEY", "") + t.Setenv("TYPESAFE_API_KEY", "$"+name) + cfg, err = Load(path, nil) + if err != nil || cfg.APIKey != "$"+name { + t.Fatal("upstream environment key was expanded", err) + } +} + +func TestViewTemplateAndReferenceIndependentOfEnvironment(t *testing.T) { + cleanEnv(t) + path := filepath.Join(t.TempDir(), "config.toml") + if err := Init(context.Background(), path); err != nil { + t.Fatal(err) + } + if _, err := View(path); err != nil { + t.Fatal("fresh template cannot be viewed", err) + } + const name = "JEVWISE_TEST_VIEW_KEY" + writeConfig(t, path, "api_key = '${"+name+"}'") + t.Setenv(name, "") + before, err := View(path) + if err != nil { + t.Fatal("unresolved key prevented file view", err) + } + t.Setenv(name, "synthetic-secret") + after, err := View(path) + if err != nil || after != before || strings.Contains(after, "synthetic-secret") || !strings.Contains(after, "[redacted]") { + t.Fatal("view depends on environment or exposes key", err) + } +} diff --git a/jevwise.toml b/jevwise.toml new file mode 100644 index 0000000..176efad --- /dev/null +++ b/jevwise.toml @@ -0,0 +1,7 @@ +# Optional CLI configuration. Prefer environment credentials. +api_key = "$JEV_TOKEN" +base_url = "https://api.typesafe.ai" +model = "jev-latest" +provider = "jev" +timeout = "10s" +editor = "vim"