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
12 changes: 8 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,22 @@ Precedence: **changed flags > nonblank environment > TOML > defaults**.
| Operation timeout | `--timeout` | `TS_JEV_TIMEOUT` | `10s` |

- Explicit file: `--config /path/to/config.toml`. Missing or invalid explicit files fail.
- 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.
- On Windows, default config discovery uses `%USERPROFILE%\.config\ts-jev\config.toml` when `XDG_CONFIG_HOME` is unset.
- Otherwise: current-directory `.jevwise.toml`, then `$XDG_CONFIG_HOME/jevwise/jevwise.toml` or `$HOME/.config/jevwise/jevwise.toml`. Only a missing global file is allowed. The selected local file replaces rather than merges with global settings; no ancestor-directory search or visible-name fallback.
- On Windows, the default global path is `%USERPROFILE%\.config\jevwise\jevwise.toml` when `XDG_CONFIG_HOME` is unset.
- Quoted `--config "~/.config/jevwise/jevwise.toml"` paths expand the current user's home directory for init, view, edit and runtime loading. `~otheruser` and shell/environment evaluation of paths are not supported. An XDG path beginning with `~/` is supported too.
- 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.
- Custom environment names work through TOML references, for example `api_key = "${JEV_MCP_API_KEY}"`. Export the variable in the process environment before starting Jevwise. Exporting `JEV_MCP_API_KEY` alone is not an automatic API-key override; nonblank `TS_JEV_API_KEY`/`TYPESAFE_API_KEY` still take precedence over the file.
- Local discovery never loads `config.toml`, `configs.toml`, YAML files or ancestor configs. Only `.jevwise.toml` is a local candidate; visible `jevwise.toml` is not auto-loaded. Legacy `ts-jev/config.toml` is no longer discovered: migrate manually or select it with `--config`. An explicit `--config PATH` can still select another trusted filename.
- `JEV_MCP_API_KEY` is your chosen upstream-key variable when referenced by `api_key`; it is not the separate MCP agent bearer token (`JEV_MCP_TOKEN` or `--token-file`).
- 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

```sh
jevwise config init # Global XDG/HOME config, even if local exists
jevwise config init --local # ./jevwise.toml, automatically selected in this cwd
jevwise config init --local # ./.jevwise.toml, automatically selected in this cwd
jevwise config view # Selected file/defaults, API key redacted
jevwise config view --config /path/to/config.toml
jevwise config edit # Selected local/global file, vim by default
Expand All @@ -39,4 +43,4 @@ jevwise config edit --editor nvim # One-shot override, also for repairs
- Edit forwards terminal input/output and waits for the direct editor process. It uses the command context, not the decision timeout; missing editors/nonzero exits fail safely. Invalid TOML falls back to Vim for repair; wrong-typed editor settings or oversized files can be repaired using `--editor`. It refuses missing files, directories and final symlinks.
- Go-copy pipe waits are bounded after process exit/cancellation. Only the direct editor process is managed, not wrapper descendants; arbitrary blocking custom readers/writers cannot be forcibly interrupted.
- Trust local editor settings and wrappers before editing: they select a program to execute. Editor terminal output intentionally may show the file you are editing. macOS/Linux support assumes an installed editor; no Windows editor availability/behavior is promised.
- Add `jevwise.toml` to your project's ignore rules before storing credentials. Prefer environment API keys.
- Add `.jevwise.toml` to your project's ignore rules before storing credentials. Prefer environment API keys.
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ bin/jevwise decide --help

Help does not read credentials, configuration files, or contact the API.

The current checkout uses `jevwise`; releases through v1.2.0 used `jev`. There is no `jev` alias. Existing configuration and the `jev` provider remain unchanged. Verify ownership before removing an older binary; another project may own the `jev` command.
The current checkout uses `jevwise`; releases through v1.2.0 used `jev`. There is no `jev` alias. The `jev` provider is unchanged; see the [configuration guide](configuration.md) for the current file locations. Verify ownership before removing an older binary; another project may own the `jev` command.

## Version and platform builds

Expand Down
4 changes: 2 additions & 2 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func newConfig() *cobra.Command {
} else if path == "" {
path, err = config.GlobalPath()
} else {
path, err = filepath.Abs(path)
path, err = config.AbsolutePath(path)
}
if err != nil {
return errors.New("cannot determine configuration path")
Expand All @@ -66,7 +66,7 @@ func newConfig() *cobra.Command {
}
return nil
}}
init.Flags().BoolVar(&local, "local", false, "Create ./jevwise.toml instead of global configuration")
init.Flags().BoolVar(&local, "local", false, "Create ./.jevwise.toml instead of global configuration")
view := &cobra.Command{Use: "view", Short: "Show file settings with API key redacted (ignores environment)", Args: configNoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := cmd.Context().Err(); err != nil {
Expand Down
72 changes: 72 additions & 0 deletions internal/cli/config_expansion_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package cli

import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"

"github.com/bitbrew-dev/jevwise/internal/config"
"github.com/bitbrew-dev/jevwise/internal/service"
)

func TestDecideRejectsMissingOrBlankExpandedKey(t *testing.T) {
Expand All @@ -22,3 +28,69 @@ func TestDecideRejectsMissingOrBlankExpandedKey(t *testing.T) {
}
}
}

func TestHiddenConfigCustomKeyAndView(t *testing.T) {
configEnvironment(t)
t.Setenv("JEV_MCP_API_KEY", "synthetic-custom-key")
if err := os.WriteFile(config.LocalName, []byte("api_key='${JEV_MCP_API_KEY}'\nmodel='hidden-model'"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("configs.toml", []byte("[invalid TOML"), 0600); err != nil {
t.Fatal(err)
}
created := 0
factory := func(cfg config.Config) (service.DecisionService, func(), error) {
created++
if cfg.APIKey != "synthetic-custom-key" || cfg.Model != "hidden-model" {
t.Error("wrong config or custom key loaded")
}
return decideFunc(func(context.Context, service.Request) (service.Response, error) {
return service.Response{Choice: "fixture"}, nil
}), nil, nil
}
_, stderr, err := execute(NewRootWithFactory(factory), "decide", "--prompt=fixture", "--option=A", "--option=B")
if err != nil || stderr != "" || created != 1 {
t.Fatal("hidden config not used for decisions", err)
}
out, stderr, err := execute(NewRootWithFactory(factory), "config", "view")
if err != nil || stderr != "" || !strings.Contains(out, "hidden-model") || !strings.Contains(out, "[redacted]") || strings.Contains(out, "synthetic-custom-key") || created != 1 {
t.Fatal("view selected wrong config or exposed expanded key", err)
}
}

func TestConfigGlobalHomeAndTildeInitViewEdit(t *testing.T) {
configEnvironment(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", "")
path := filepath.Join(home, ".config", "jevwise", "jevwise.toml")
literal := "~/.config/jevwise/jevwise.toml"
_, stderr, err := execute(configRoot(t), "config", "init", "--config", literal)
if err != nil || stderr != "" {
t.Fatal("tilde init failed", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatal("init created literal tilde directory instead of global file")
}
if err := os.WriteFile(path, []byte("model='home-global'\napi_key='$JEV_MCP_API_KEY'"), 0600); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"config", "view"}, {"config", "view", "--config", literal}} {
out, stderr, err := execute(configRoot(t), args...)
if err != nil || stderr != "" || !strings.Contains(out, "home-global") || !strings.Contains(out, "[redacted]") {
t.Fatal("home/tilde global view failed", err)
}
}
called := false
cmd := editorRoot(t, func(_ context.Context, editor, selected string, _ io.Reader, _, _ io.Writer) error {
called = true
if editor != "vim" || selected != path {
t.Error("tilde edit selected wrong file/editor")
}
return nil
})
if _, stderr, err := execute(cmd, "config", "edit", "--config", literal); err != nil || stderr != "" || !called {
t.Fatal("tilde edit failed", err)
}
}
3 changes: 2 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type Config struct {
}

// Load applies changed flags > nonblank environment > TOML > defaults.
// An empty path selects ./jevwise.toml before the XDG/HOME global file.
// An empty path selects ./.jevwise.toml, then the Jevwise 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.
Expand All @@ -39,6 +39,7 @@ func Load(path string, flags *pflag.FlagSet) (Config, error) {
if err != nil && (required || !errors.Is(err, os.ErrNotExist)) {
return Config{}, errors.New("cannot read configuration: file missing, unreadable or unsafe")
}
// Parse only these selected bytes. Never use Viper search paths or ReadInConfig.
v.SetConfigType("toml")
if err == nil && v.ReadConfig(bytes.NewReader(data)) != nil {
return Config{}, errors.New("cannot read configuration: invalid TOML")
Expand Down
6 changes: 3 additions & 3 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ func TestDefaultsAndDiscovery(t *testing.T) {
if err != nil || got != want {
t.Fatalf("defaults: %+v %v", got, err)
}
xdg := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "ts-jev", "config.toml")
home := filepath.Join(os.Getenv("HOME"), ".config", "ts-jev", "config.toml")
xdg := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "jevwise", GlobalName)
home := filepath.Join(os.Getenv("HOME"), ".config", "jevwise", GlobalName)
writeConfig(t, xdg, `model = "xdg"`)
writeConfig(t, home, `model = "home"`)
got, err = Load("", nil)
Expand Down Expand Up @@ -132,7 +132,7 @@ func TestInvalidFilesAndValues(t *testing.T) {
}
})
}
path := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "ts-jev", "config.toml")
path := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "jevwise", GlobalName)
writeConfig(t, path, "[")
if _, err := Load("", nil); err == nil {
t.Fatal("invalid discovered file accepted")
Expand Down
92 changes: 92 additions & 0 deletions internal/config/discovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package config

import (
"os"
"path/filepath"
"runtime"
"testing"
)

func TestDiscoveryAllowlistsHiddenLocalAndNamedGlobal(t *testing.T) {
cleanEnv(t)
for _, name := range []string{"jevwise.toml", "config.toml", "configs.toml", ".config.toml", "config.yaml", "jevwise.yaml", ".jevwise.toml.bak"} {
writeConfig(t, name, "[invalid TOML")
}
writeConfig(t, filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "ts-jev", "config.toml"), "[old invalid config")
writeConfig(t, filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "jevwise", "config.toml"), "[unrelated config")
t.Setenv("JEV_MCP_API_KEY", "custom-variable-is-not-an-override")
cfg, err := Load("", nil)
if err != nil || cfg.Model != "jev-latest" || cfg.APIKey != "" {
t.Fatal("unrelated file or custom environment override loaded", err)
}
global, _ := GlobalPath()
writeConfig(t, global, "model='global'\napi_key='global-only-key'")
cfg, err = Load("", nil)
if err != nil || cfg.Model != "global" {
t.Fatal("named Jevwise global file not selected", err)
}
writeConfig(t, LocalName, "model='local'\napi_key='$JEV_MCP_API_KEY'")
cfg, err = Load("", nil)
path, required, pathErr := ResolvePath("")
if err != nil || pathErr != nil || !required || filepath.Base(path) != LocalName || cfg.Model != "local" || cfg.APIKey != "custom-variable-is-not-an-override" {
t.Fatal("local file or custom reference not selected", err)
}
writeConfig(t, LocalName, "model='local-only'")
cfg, err = Load("", nil)
if err != nil || cfg.APIKey != "" {
t.Fatal("local and global files merged", err)
}
writeConfig(t, LocalName, "[invalid TOML")
if _, err = Load("", nil); err == nil {
t.Fatal("invalid local config fell back to global")
}
}

func TestGlobalHomeAndLiteralTildePaths(t *testing.T) {
cleanEnv(t)
t.Setenv("XDG_CONFIG_HOME", "")
home := os.Getenv("HOME")
expected := filepath.Join(home, ".config", "jevwise", GlobalName)
writeConfig(t, expected, "model='home-global'\napi_key='${JEV_MCP_API_KEY}'")
t.Setenv("JEV_MCP_API_KEY", "synthetic-custom-key")
for _, path := range []string{"", "~/.config/jevwise/jevwise.toml", expected} {
cfg, err := Load(path, nil)
if err != nil || cfg.Model != "home-global" || cfg.APIKey != "synthetic-custom-key" {
t.Fatal("home/global/tilde config not loaded", err)
}
}
actual, err := GlobalPath()
if err != nil || actual != expected {
t.Fatal("wrong global namespace", err)
}
t.Setenv("XDG_CONFIG_HOME", "~/.config")
actual, err = GlobalPath()
if err != nil || actual != expected {
t.Fatal("tilde XDG path not expanded", err)
}
if _, _, err := ResolvePath("~otheruser/jevwise.toml"); err == nil {
t.Fatal("unsupported tilde form accepted")
}
t.Setenv("HOME", "")
t.Setenv("USERPROFILE", "")
if _, _, err := ResolvePath("~/jevwise.toml"); err == nil {
t.Fatal("missing home resolved literal tilde path")
}
}

func TestGlobalDirectorySymlinkSupportsDotfiles(t *testing.T) {
cleanEnv(t)
target := t.TempDir()
global, _ := GlobalPath()
if runtime.GOOS == "windows" {
t.Skip("directory symlink creation may require privileges")
}
if err := os.Symlink(target, filepath.Dir(global)); err != nil {
t.Fatal(err)
}
writeConfig(t, filepath.Join(target, GlobalName), "model='dotfiles'")
cfg, err := Load("", nil)
if err != nil || cfg.Model != "dotfiles" {
t.Fatal("trusted dotfiles directory symlink rejected", err)
}
}
30 changes: 25 additions & 5 deletions internal/config/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import (
"strings"
)

const LocalName = "jevwise.toml"
const LocalName = ".jevwise.toml"
const GlobalName = "jevwise.toml"
const MaxFileBytes = 1 << 20

// GlobalPath preserves the existing configuration namespace.
// GlobalPath selects only the Jevwise configuration namespace.
func GlobalPath() (string, error) {
base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
if base == "" {
Expand All @@ -21,14 +22,15 @@ func GlobalPath() (string, error) {
}
base = filepath.Join(home, ".config")
}
return filepath.Abs(filepath.Join(base, "ts-jev", "config.toml"))
return AbsolutePath(filepath.Join(base, "jevwise", GlobalName))
}

// ResolvePath returns the selected file and whether its presence is required.
// Only the current directory is searched; a local file replaces the global one.
// Only the exact hidden current-directory name is inspected.
// A selected local file replaces the application-specific global one.
func ResolvePath(explicit string) (string, bool, error) {
if explicit != "" {
path, err := filepath.Abs(explicit)
path, err := AbsolutePath(explicit)
return path, true, err
}
cwd, err := os.Getwd()
Expand All @@ -50,6 +52,24 @@ func ResolvePath(explicit string) (string, bool, error) {
return path, false, err
}

// AbsolutePath expands only current-user tilde paths, without shell/env evaluation.
func AbsolutePath(path string) (string, error) {
if path == "~" || strings.HasPrefix(path, "~/") || filepath.Separator == '\\' && strings.HasPrefix(path, "~\\") {
home, err := os.UserHomeDir()
if err != nil {
return "", errors.New("cannot expand configuration path: home is unset")
}
if path == "~" {
path = home
} else {
path = filepath.Join(home, path[2:])
}
} else if strings.HasPrefix(path, "~") {
return "", errors.New("only current-user ~/ configuration paths are supported")
}
return filepath.Abs(path)
}

// ReadFile bounds TOML input and refuses special files before opening them.
// Explicit/global read-only symlinks retain their existing behavior. Checks
// assume cooperative filesystem storage, not hostile concurrent path changes.
Expand Down
Loading