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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ 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: `$XDG_CONFIG_HOME/ts-jev/config.toml`, or `$HOME/.config/ts-jev/config.toml`. A missing discovered file is allowed.
- 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.

## Skill setup
Expand Down
10 changes: 10 additions & 0 deletions docs/internal/config-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Config command plan

- 27: centralize discovery: explicit `--config` > current-directory `jevwise.toml` > existing XDG/HOME `ts-jev/config.toml`; selected files replace, not merge. Preserve flags/environment precedence and credential-free help. Bound reads and reject nonregular local files. Never search ancestor directories.
- 28: add `config init` and redacted `config view`; init defaults global, `--local` creates `./jevwise.toml`, explicit paths supported, conflicting selectors rejected. Publish a private template without overwriting any existing file/link; view shows known file settings/defaults, not environment overrides or arbitrary extra keys/comments.
- 29: add `config edit`, default installed `vim` on PATH for macOS/Linux; configurable `editor` executable name/path, no shell or embedded editor. Forward terminal streams, test with injected/fake processes, safe errors and cancellation. No Windows editor support promise; preserve cross-builds.
- Worktrees under `.agent/worktrees`, linear `virtual-main` branches; soft 300/hard 450 aggregate lines each. Local commits via approved git ch/OpenAI/luna; no push/merge/tag/release without approval.
- Local auto-load requires trusting the current directory, especially its base URL. Do not copy environment API keys into templates or show them in view; add local file to project ignore rules before storing credentials. Existing provider/SDK/config namespace and Windows dependency deferral remain unchanged.
- Root and independent QA: fresh normal/race tests, tidy/vet/build, offline Python fixtures, formatting/diff checks, six platform builds and synthetic credential-free CLI smoke. Never launch a real editor/agent or use normal user config during tests. Private spec remains unstaged.

- 27 local QA passed: root normal/race/tidy/vet/build, 11 offline Python fixtures, formatting/diff/docs checks and six platform builds; independent full tests and config race repeated 20 times. No network/model/editor calls.
31 changes: 12 additions & 19 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
package config

import (
"bytes"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"

Expand All @@ -21,32 +21,26 @@ type Config struct {
}

// Load applies changed flags > nonblank environment > TOML > defaults.
// An empty path discovers XDG_CONFIG_HOME/ts-jev/config.toml or
// HOME/.config/ts-jev/config.toml. Only a missing discovered file is optional.
// An empty path selects ./jevwise.toml before the XDG/HOME global file.
// Only a missing default global file is optional; selected files never merge.
// Neither global Viper state nor the supplied flags are mutated.
func Load(path string, flags *pflag.FlagSet) (Config, error) {
v := viper.New()
defaults := map[string]string{"api_key": "", "base_url": "https://api.typesafe.ai", "model": "jev-latest", "provider": "jev", "timeout": "10s"}
for key, value := range defaults {
v.SetDefault(key, value)
}
explicit := path != ""
if !explicit {
base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return Config{}, errors.New("cannot discover configuration: HOME is unset")
}
base = filepath.Join(home, ".config")
}
path = filepath.Join(base, "ts-jev", "config.toml")
path, required, err := ResolvePath(path)
if err != nil {
return Config{}, err
}
data, err := ReadFile(path)
if err != nil && (required || !errors.Is(err, os.ErrNotExist)) {
return Config{}, errors.New("cannot read configuration: file missing, unreadable or unsafe")
}
v.SetConfigFile(path)
v.SetConfigType("toml")
if err := v.ReadInConfig(); err != nil && (explicit || !errors.Is(err, os.ErrNotExist)) {
// Parser errors may contain file values, including credentials.
return Config{}, errors.New("cannot read configuration: file missing, unreadable or invalid TOML")
if err == nil && v.ReadConfig(bytes.NewReader(data)) != nil {
return Config{}, errors.New("cannot read configuration: invalid TOML")
}
upstream := map[string]string{"api_key": "TYPESAFE_API_KEY", "base_url": "TYPESAFE_BASE_URL", "model": "TYPESAFE_DEFAULT_MODEL"}
env := make(map[string]any)
Expand Down Expand Up @@ -78,7 +72,6 @@ func Load(path string, flags *pflag.FlagSet) (Config, error) {
values[key] = strings.TrimSpace(value)
}
c := Config{APIKey: values["api_key"], BaseURL: values["base_url"], Model: values["model"], Provider: values["provider"]}
var err error
c.Timeout, err = time.ParseDuration(values["timeout"])
if err != nil {
return Config{}, errors.New("timeout must be a positive duration")
Expand Down
1 change: 1 addition & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

func cleanEnv(t *testing.T) {
t.Helper()
t.Chdir(t.TempDir())
for _, name := range []string{"TS_JEV_API_KEY", "TS_JEV_BASE_URL", "TS_JEV_MODEL", "TS_JEV_PROVIDER", "TS_JEV_TIMEOUT", "TYPESAFE_API_KEY", "TYPESAFE_BASE_URL", "TYPESAFE_DEFAULT_MODEL"} {
t.Setenv(name, "")
}
Expand Down
78 changes: 78 additions & 0 deletions internal/config/paths.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package config

import (
"errors"
"io"
"os"
"path/filepath"
"strings"
)

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

// GlobalPath preserves the existing configuration namespace.
func GlobalPath() (string, error) {
base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", errors.New("cannot discover configuration: home is unset")
}
base = filepath.Join(home, ".config")
}
return filepath.Abs(filepath.Join(base, "ts-jev", "config.toml"))
}

// ResolvePath returns the selected file and whether its presence is required.
// Only the current directory is searched; a local file replaces the global one.
func ResolvePath(explicit string) (string, bool, error) {
if explicit != "" {
path, err := filepath.Abs(explicit)
return path, true, err
}
cwd, err := os.Getwd()
if err != nil {
return "", true, errors.New("cannot discover current-directory configuration")
}
local := filepath.Join(cwd, LocalName)
info, err := os.Lstat(local)
if err == nil {
if !info.Mode().IsRegular() {
return "", true, errors.New("local configuration must be a regular file, not a link")
}
return local, true, nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", true, errors.New("cannot inspect local configuration")
}
path, err := GlobalPath()
return path, false, err
}

// 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.
func ReadFile(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() > MaxFileBytes {
return nil, errors.New("configuration must be a regular file of at most 1 MiB")
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
opened, err := file.Stat()
if err != nil || !opened.Mode().IsRegular() || !os.SameFile(info, opened) {
return nil, errors.New("configuration changed while opening")
}
data, err := io.ReadAll(io.LimitReader(file, MaxFileBytes+1))
if err == nil && len(data) > MaxFileBytes {
err = errors.New("configuration exceeds 1 MiB")
}
return data, err
}
84 changes: 84 additions & 0 deletions internal/config/paths_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package config

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

"github.com/spf13/pflag"
)

func TestLocalDiscoveryPrecedenceAndNoMerge(t *testing.T) {
cleanEnv(t)
global, _ := GlobalPath()
writeConfig(t, global, "model='global'\napi_key='global-secret'")
writeConfig(t, LocalName, "model='local'")
cfg, err := Load("", nil)
if err != nil || cfg.Model != "local" || cfg.APIKey != "" {
t.Fatal("local file did not replace global", err)
}
t.Setenv("TS_JEV_MODEL", "environment")
cfg, _ = Load("", nil)
if cfg.Model != "environment" {
t.Fatal("environment lost precedence")
}
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
flags.String("model", "", "")
_ = flags.Parse([]string{"--model=flag"})
cfg, err = Load("", flags)
if err != nil || cfg.Model != "flag" {
t.Fatal("flag lost precedence", err)
}
t.Setenv("TS_JEV_MODEL", "")
cfg, err = Load(global, nil)
if err != nil || cfg.Model != "global" {
t.Fatal("explicit path lost precedence", err)
}
t.Setenv("HOME", "")
t.Setenv("USERPROFILE", "")
t.Setenv("XDG_CONFIG_HOME", "")
if _, err := Load("", nil); err != nil {
t.Fatal("local configuration requires home", err)
}
}

func TestLocalDiscoveryFailsClosedAndDoesNotSearchAncestors(t *testing.T) {
for _, mode := range []string{"invalid", "oversize", "directory", "link"} {
t.Run(mode, func(t *testing.T) {
cleanEnv(t)
global, _ := GlobalPath()
writeConfig(t, global, "model='global'")
switch mode {
case "invalid":
writeConfig(t, LocalName, "api_key='private-secret'\n[")
case "oversize":
writeConfig(t, LocalName, strings.Repeat("x", MaxFileBytes+1))
case "directory":
_ = os.Mkdir(LocalName, 0700)
case "link":
if runtime.GOOS == "windows" {
t.Skip("symlink creation may require privileges")
}
if err := os.Symlink(global, LocalName); err != nil {
t.Fatal(err)
}
}
if _, err := Load("", nil); err == nil || strings.Contains(err.Error(), "private-secret") {
t.Fatal("unsafe local configuration fell back or leaked", err)
}
})
}
cleanEnv(t)
writeConfig(t, LocalName, "model='ancestor'")
// A nested directory of the current project must not inherit its file.
cwd, _ := os.Getwd()
child := filepath.Join(cwd, "child")
_ = os.Mkdir(child, 0700)
t.Chdir(child)
cfg, err := Load("", nil)
if err != nil || cfg.Model != "jev-latest" {
t.Fatal("ancestor configuration loaded", err)
}
}
Loading