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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions internal/cli/config_expansion_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
10 changes: 10 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
93 changes: 93 additions & 0 deletions internal/config/expansion_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 7 additions & 0 deletions jevwise.toml
Original file line number Diff line number Diff line change
@@ -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"
Loading