diff --git a/docs/configuration.md b/docs/configuration.md index 306ce2a..f06416b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 @@ -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. diff --git a/docs/development.md b/docs/development.md index bf0c70f..17c6907 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/internal/cli/config.go b/internal/cli/config.go index 28cfe14..98c6026 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -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") @@ -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 { diff --git a/internal/cli/config_expansion_test.go b/internal/cli/config_expansion_test.go index 72acba6..b34b1c0 100644 --- a/internal/cli/config_expansion_test.go +++ b/internal/cli/config_expansion_test.go @@ -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) { @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6c0a4f6..685d8f4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. @@ -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") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8b4cd0c..df31795 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) @@ -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") diff --git a/internal/config/discovery_test.go b/internal/config/discovery_test.go new file mode 100644 index 0000000..1018b34 --- /dev/null +++ b/internal/config/discovery_test.go @@ -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) + } +} diff --git a/internal/config/paths.go b/internal/config/paths.go index 60df8a7..32f737a 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -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 == "" { @@ -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() @@ -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.