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 config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/debug.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ 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.
- Logging is best-effort. Write failures do not change operation results. No global logger or SDK configuration is changed.

## 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.
10 changes: 9 additions & 1 deletion internal/cli/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
48 changes: 48 additions & 0 deletions internal/cli/debug_config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion internal/cli/decide_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
}
}
Expand Down
10 changes: 9 additions & 1 deletion internal/cli/mcp_process_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
57 changes: 57 additions & 0 deletions internal/config/debug.go
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions internal/config/debug_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
10 changes: 8 additions & 2 deletions internal/config/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/spf13/viper"
Expand Down Expand Up @@ -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
}

Expand All @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions internal/config/template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions jevwise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading