diff --git a/config.example.toml b/config.example.toml index 3a45e5b..86b5efb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -9,3 +9,5 @@ editor = "vim" # Local MCP server: literal loopback IP (no scheme) and port. mcp_base_url = "127.0.0.1" mcp_port = 8080 +# Structured debug logs on stderr; --debug or --verbose overrides. +debug = false diff --git a/docs/configuration.md b/docs/configuration.md index 4bd158d..97ef5d4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,13 +15,14 @@ Precedence: **changed flags > nonblank environment > TOML > defaults**. These na | Operation timeout | `--timeout` | `TS_JEV_TIMEOUT` | `10s` | | MCP host | `--listen` (with port) | `TS_JEV_MCP_BASE_URL` | `127.0.0.1` | | MCP port | `--listen` (with host) | `TS_JEV_MCP_PORT` | `8080` | +| Debug logging | `--debug`, `--verbose` | `TS_JEV_DEBUG` | `false` | - `mcp_base_url` is a literal loopback IP without scheme or port (`127.0.0.1`, `::1`); `http://`, `localhost` and non-loopback hosts fail. `mcp_port` is an integer from 1 to 65535 (`mcp_port = 8080` or `"8080"`). `jevwise mcp` and `jevwise mcp install` read only these two keys for the address; a changed `--listen` overrides both, so `jevwise mcp --listen 127.0.0.1:9090` equals `jevwise mcp` with `mcp_port = 9090`. - Explicit file: `--config /path/to/config.toml`. Missing or invalid explicit files fail. - 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. +- 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, except that `version` and other commands read only the `debug` key and ignore an invalid or unreadable file. - 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 = "$JEV_API_KEY"` or `api_key = "${JEV_MCP_API_KEY}"`. Runtime loading expands references and trims the result; undefined variables become empty. - API environment names above are checked left to right, skipping blank values. `JEV_API_KEY` is for the upstream API; `JEVWISE_MCP_TOKEN` is separate local MCP authentication. Never reuse the same credential. diff --git a/docs/debug.md b/docs/debug.md index 7961ded..df90089 100644 --- a/docs/debug.md +++ b/docs/debug.md @@ -10,7 +10,7 @@ jevwise --debug mcp jevwise mcp --verbose # Same logging as --debug ``` -- Debug logging is opt-in via `--debug` or its `--verbose` alias, uses `github.com/phuslu/log`, and writes JSON to stderr. Normal stdout stays unchanged; logs can be redirected with `2>debug.log`. +- Debug logging is opt-in via `--debug`, its `--verbose` alias, `TS_JEV_DEBUG=true` or TOML `debug = true`, uses `github.com/phuslu/log`, and writes JSON to stderr. Normal stdout stays unchanged; logs can be redirected with `2>debug.log`. - Covers command/stage outcomes and timing, config loading, API attempts/retries/status codes, release verification/replacement, skills, MCP requests/lifecycle and authenticated management. - Never logs credentials, prompts/options/results, user-supplied URLs/paths, raw headers/bodies, or raw error causes. Release checks may log their fixed public GitHub endpoint and strictly numeric rate-limit metadata. Treat timing/status diagnostics as operationally sensitive when sharing logs. - Detached background mode traces the launcher only; child output remains isolated. There is no persistent debug log file. Use foreground MCP for request diagnostics, then restart without `--debug` when finished. @@ -18,7 +18,7 @@ jevwise mcp --verbose # Same logging as --debug ## MCP startup failures -- Either flag enables diagnostics; `--debug=false --verbose` still logs. They are flags only, not config settings. No flag means no debug trace. +- Either flag enables diagnostics; `--debug=false --verbose` still logs. A changed flag overrides `TS_JEV_DEBUG` and TOML `debug`, so `--debug=false` disables a `debug = true` setting. Without a flag, an invalid or unreadable setting means no debug trace; the detached background child ignores the setting. - Foreground startup logs classify missing/invalid upstream API keys, unsupported providers, client configuration errors and failed/empty factories, without showing values or raw causes. Known credential failures also print actionable errors without enabling debugging. - Set a Jev API key through `TS_JEV_API_KEY`, `TYPESAFE_API_KEY` or TOML `api_key`. The separate MCP agent bearer token is not the upstream key; see [configuration](configuration.md) and [MCP credentials](mcp.md#credentials-state-and-safety). - If neither flag is listed by `jevwise mcp --help`, check `jevwise version` and which binary is on PATH. Older published binaries may predate logging even when the current source supports it. For Homebrew-managed installs, use `brew upgrade jevwise` once a release containing these changes is available, not the standalone updater. diff --git a/internal/cli/debug.go b/internal/cli/debug.go index c2343b2..0c80941 100644 --- a/internal/cli/debug.go +++ b/internal/cli/debug.go @@ -3,6 +3,7 @@ package cli import ( "io" + "github.com/bitbrew-dev/jevwise/internal/config" "github.com/bitbrew-dev/jevwise/internal/debuglog" "github.com/spf13/cobra" ) @@ -30,9 +31,16 @@ func instrumentCommands(cmd *cobra.Command) { } // Either flag enables diagnostics; an explicit false never disables the other. +// Without a changed flag, the debug setting applies; unreadable settings mean off. +// Hidden commands such as the background child keep their output isolated. func debugEnabled(cmd *cobra.Command) bool { flags := cmd.Root().PersistentFlags() debug, _ := flags.GetBool("debug") verbose, _ := flags.GetBool("verbose") - return debug || verbose + if cmd.Hidden || flags.Changed("debug") || flags.Changed("verbose") { + return debug || verbose + } + path, _ := flags.GetString("config") + enabled, err := config.Debug(path) + return err == nil && enabled } diff --git a/internal/cli/debug_config_test.go b/internal/cli/debug_config_test.go new file mode 100644 index 0000000..06b6470 --- /dev/null +++ b/internal/cli/debug_config_test.go @@ -0,0 +1,48 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDebugFromConfigAndFlagOverride(t *testing.T) { + configEnvironment(t) + path := filepath.Join(t.TempDir(), "config.toml") + write := func(content string) { + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + write("debug = true\n") + for _, tt := range []struct { + args []string + enabled bool + }{ + {[]string{"version", "--config", path}, true}, + {[]string{"version", "--config", path, "--debug=false"}, false}, + {[]string{"version", "--config", path, "--verbose=false"}, false}, + {[]string{"version", "--config", "/missing"}, false}, + } { + out, stderr, err := execute(NewRoot(), tt.args...) + if err != nil || !strings.Contains(out, "commit:") || (stderr != "") != tt.enabled { + t.Fatal("debug setting precedence changed", tt.args, err) + } + } + write("debug = false\n") + t.Setenv("TS_JEV_DEBUG", "true") + if _, stderr, err := execute(NewRoot(), "version", "--config", path); err != nil || stderr == "" { + t.Fatal("environment did not enable debug", err) + } + child := newMCPChild(nil) + NewRoot().AddCommand(child) + if debugEnabled(child) { + t.Fatal("background child enabled debug from environment") + } + t.Setenv("TS_JEV_DEBUG", "") + write("debug = 'maybe'\n") + if out, stderr, err := execute(NewRoot(), "version", "--config", path); err != nil || stderr != "" || !strings.Contains(out, "commit:") { + t.Fatal("invalid debug setting broke or logged version", err) + } +} diff --git a/internal/cli/decide_test.go b/internal/cli/decide_test.go index 78ca39e..c080419 100644 --- a/internal/cli/decide_test.go +++ b/internal/cli/decide_test.go @@ -26,7 +26,7 @@ func (f decideFunc) Decide(ctx context.Context, req service.Request) (service.Re func decisionEnvironment(t *testing.T) { t.Helper() t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - for _, key := range []string{"JEV_API_KEY", "JEVWISE_API_KEY", "JEVWISE_MCP_TOKEN", "JEVWISE_MCP_AUTH_TOKEN", "JEV_MCP_TOKEN", "TS_JEV_API_KEY", "TS_JEV_MODEL", "TS_JEV_PROVIDER", "TS_JEV_BASE_URL", "TS_JEV_TIMEOUT", "TS_JEV_MCP_BASE_URL", "TS_JEV_MCP_PORT", "TYPESAFE_API_KEY", "TYPESAFE_DEFAULT_MODEL", "TYPESAFE_BASE_URL"} { + for _, key := range []string{"JEV_API_KEY", "JEVWISE_API_KEY", "JEVWISE_MCP_TOKEN", "JEVWISE_MCP_AUTH_TOKEN", "JEV_MCP_TOKEN", "TS_JEV_API_KEY", "TS_JEV_MODEL", "TS_JEV_PROVIDER", "TS_JEV_BASE_URL", "TS_JEV_TIMEOUT", "TS_JEV_MCP_BASE_URL", "TS_JEV_MCP_PORT", "TS_JEV_DEBUG", "TYPESAFE_API_KEY", "TYPESAFE_DEFAULT_MODEL", "TYPESAFE_BASE_URL"} { t.Setenv(key, "") } } diff --git a/internal/cli/mcp_process_test.go b/internal/cli/mcp_process_test.go index 03bc1dd..ba32d55 100644 --- a/internal/cli/mcp_process_test.go +++ b/internal/cli/mcp_process_test.go @@ -29,7 +29,15 @@ func TestMain(m *testing.M) { if len(os.Args) == 2 && os.Args[1] == daemon.ChildArgument && os.Getenv("JEVWISE_PROCESS_FIXTURE") == "1" { os.Exit(runProcessFixture()) } - os.Exit(m.Run()) + _ = os.Unsetenv("TS_JEV_DEBUG") + isolated, err := os.MkdirTemp("", "jevwise-cli-config-") + if err != nil { + os.Exit(1) + } + _ = os.Setenv("XDG_CONFIG_HOME", isolated) + code := m.Run() + _ = os.RemoveAll(isolated) + os.Exit(code) } func runProcessFixture() (code int) { diff --git a/internal/config/debug.go b/internal/config/debug.go new file mode 100644 index 0000000..53a2499 --- /dev/null +++ b/internal/config/debug.go @@ -0,0 +1,57 @@ +package config + +import ( + "bytes" + "errors" + "os" + "strconv" + "strings" + + "github.com/spf13/viper" +) + +var errDebug = errors.New("debug must be true or false") + +// Debug resolves the debug setting (nonblank TS_JEV_DEBUG > TOML > false). It reads +// no other settings; only a missing file falls back to false. +func Debug(path string) (bool, error) { + resolved, _, err := ResolvePath(path) + if err != nil { + return false, err + } + data, err := ReadFile(resolved) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return false, errors.New("cannot read configuration: file unreadable or unsafe") + } + v := viper.New() + v.SetConfigType("toml") + if err == nil && v.ReadConfig(bytes.NewReader(data)) != nil { + return false, errors.New("cannot read configuration: invalid TOML") + } + enabled, err := debugValue(v) + if err != nil { + return false, err + } + if value := strings.TrimSpace(os.Getenv("TS_JEV_DEBUG")); value != "" { + if enabled, err = strconv.ParseBool(value); err != nil { + return false, errDebug + } + } + return enabled, nil +} + +func debugValue(v *viper.Viper) (bool, error) { + switch raw := v.Get("debug").(type) { + case nil: + return false, nil + case bool: + return raw, nil + case string: + enabled, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return false, errDebug + } + return enabled, nil + } + return false, errDebug +} diff --git a/internal/config/debug_test.go b/internal/config/debug_test.go new file mode 100644 index 0000000..38668bb --- /dev/null +++ b/internal/config/debug_test.go @@ -0,0 +1,49 @@ +package config + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestDebugPrecedenceAndValidation(t *testing.T) { + cleanEnv(t) + t.Setenv("TS_JEV_DEBUG", "") + dir := t.TempDir() + if enabled, err := Debug(filepath.Join(dir, "missing.toml")); err != nil || enabled { + t.Fatal("missing file did not default to false", err) + } + path := filepath.Join(dir, "config.toml") + for content, want := range map[string]bool{ + "debug = true\n": true, + "debug = false\n": false, + "debug = 'true'\n": true, + "timeout = 'invalid'\ndebug = true\n": true, + "model = 'unrelated'\n": false, + } { + if enabled, err := Debug(writeConfig(t, path, content)); err != nil || enabled != want { + t.Fatal("wrong debug value", content, err) + } + } + t.Setenv("TS_JEV_DEBUG", "false") + if enabled, err := Debug(writeConfig(t, path, "debug = true\n")); err != nil || enabled { + t.Fatal("environment did not override TOML", err) + } + t.Setenv("TS_JEV_DEBUG", "maybe") + if _, err := Debug(path); err == nil { + t.Fatal("invalid environment debug accepted") + } + t.Setenv("TS_JEV_DEBUG", "") + for _, content := range []string{"debug = 'maybe'\n", "debug = 1\n", "debug = [\n"} { + if _, err := Debug(writeConfig(t, path, content)); err == nil { + t.Fatal("invalid debug accepted", content) + } + if _, err := View(path); err == nil { + t.Fatal("view accepted invalid debug", content) + } + } + text, err := View(writeConfig(t, path, string(template))) + if err != nil || !strings.Contains(text, "debug = false\n") { + t.Fatal("view or template lacks debug", err, text) + } +} diff --git a/internal/config/files.go b/internal/config/files.go index c7367c5..5fd7888 100644 --- a/internal/config/files.go +++ b/internal/config/files.go @@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "github.com/spf13/viper" @@ -101,6 +102,11 @@ func fileValues(path string) (map[string]string, error) { for key, value := range mcp { values[key] = value } + debug, err := debugValue(v) + if err != nil { + return nil, err + } + values["debug"] = strconv.FormatBool(debug) return values, nil } @@ -117,9 +123,9 @@ func View(path string) (string, error) { } values["api_key"] = "[redacted]" var out strings.Builder - for _, key := range []string{"api_key", "base_url", "model", "provider", "timeout", "editor", "mcp_base_url", "mcp_port"} { + for _, key := range []string{"api_key", "base_url", "model", "provider", "timeout", "editor", "mcp_base_url", "mcp_port", "debug"} { value, _ := json.Marshal(values[key]) - if key == "mcp_port" { + if key == "mcp_port" || key == "debug" { value = []byte(values[key]) } out.WriteString(key + " = " + string(value) + "\n") diff --git a/internal/config/template.toml b/internal/config/template.toml index 06cc876..2327386 100644 --- a/internal/config/template.toml +++ b/internal/config/template.toml @@ -8,3 +8,5 @@ editor = "vim" # Local MCP server: literal loopback IP (no scheme) and port. mcp_base_url = "127.0.0.1" mcp_port = 8080 +# Structured debug logs on stderr; --debug or --verbose overrides. +debug = false diff --git a/jevwise.toml b/jevwise.toml index 4c94f2d..ed2d791 100644 --- a/jevwise.toml +++ b/jevwise.toml @@ -8,3 +8,5 @@ editor = "vim" # Local MCP server: literal loopback IP (no scheme) and port. mcp_base_url = "127.0.0.1" mcp_port = 8080 +# Structured debug logs on stderr; --debug or --verbose overrides. +debug = false