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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ bin/jevwise update --check --timeout 20s
bin/jevwise update --timeout 60s # Install on supported standalone Linux/macOS builds
```

- `--debug update --check` shows the fixed `bitbrew-dev/jevwise` endpoint, HTTP status, rate-limit remaining/reset/retry seconds and failure category. Anonymous checks can be rate-limited: wait for the limit to reset. A 403 without rate-limit evidence is reported as an HTTP failure, not assumed to be rate limiting.
- `--check` checks the latest stable GitHub release without downloading a binary or changing files. No background checks, API credentials, or decision configuration are used.
- `--timeout` is a positive flag-only deadline for the whole update, default `10s`; decision timeout environment/config values do not affect updates. Increase it for slow downloads.
- Reports newer/current releases or no published release. Development/unknown builds report the latest release without inventing a version comparison.
Expand Down Expand Up @@ -104,6 +105,19 @@ The release-assets workflow attaches raw CLI binaries and `SHA256SUMS` to an exi
- Filesystem checks assume ordinary cooperative local storage, not hostile concurrent writers, mount changes or power-loss durability. Context cancellation is checked at safe boundaries; filesystem calls are not interruptible.
- Use your package manager for managed installs. `os.Executable` may resolve a symlink launch to its physical target, so the updater cannot reliably identify every symlink-invoked or package-managed installation.

## Debug logging

```sh
jevwise --debug update --check
jevwise --debug mcp
```

- Debug logging is opt-in via `--debug`, 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.

## CLI configuration

Precedence: **changed flags > nonblank environment > TOML > defaults**.
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/bitbrew-dev/jevwise/internal/config"
"github.com/bitbrew-dev/jevwise/internal/debuglog"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -52,6 +53,7 @@ func newConfig() *cobra.Command {
if err != nil {
return errors.New("cannot determine configuration path")
}
debuglog.Event(cmd.Context(), "config.init.publish")
if err := config.Init(cmd.Context(), path); err != nil {
if errors.Is(err, os.ErrExist) {
return errors.New("configuration already exists; no file replaced")
Expand All @@ -78,6 +80,7 @@ func newConfig() *cobra.Command {
if err != nil {
return err
}
debuglog.Event(cmd.Context(), "config.view.read")
text, err := config.View(path)
if err != nil {
return err
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/config_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ import (
"time"

"github.com/bitbrew-dev/jevwise/internal/config"
"github.com/bitbrew-dev/jevwise/internal/debuglog"
"github.com/spf13/cobra"
)

type configEditor func(context.Context, string, string, io.Reader, io.Writer, io.Writer) error

func runConfigEditor(ctx context.Context, editor, path string, in io.Reader, out, stderr io.Writer) error {
func runConfigEditor(ctx context.Context, editor, path string, in io.Reader, out, stderr io.Writer) (resultErr error) {
finish := debuglog.Trace(ctx, "config.editor")
defer func() { finish(resultErr) }()
process := exec.CommandContext(ctx, editor, path) // No shell; absolute file is one argument.
process.WaitDelay = 250 * time.Millisecond // Bound inherited Go-copy pipe waits.
process.Stdin, process.Stdout, process.Stderr = in, out, stderr
Expand All @@ -41,6 +44,7 @@ func newConfigEdit(run configEditor) *cobra.Command {
if err != nil {
return err
}
debuglog.Event(cmd.Context(), "config.edit.inspect")
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
return errors.New("config edit requires an existing regular file, not a link; initialize it first")
Expand Down
30 changes: 30 additions & 0 deletions internal/cli/debug.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package cli

import (
"io"

"github.com/bitbrew-dev/jevwise/internal/debuglog"
"github.com/spf13/cobra"
)

// Instrument declared commands only. Help/completion generated by Cobra stay quiet.
func instrumentCommands(cmd *cobra.Command) {
if run := cmd.RunE; run != nil {
operation := cmd.CommandPath()
cmd.RunE = func(cmd *cobra.Command, args []string) (err error) {
previous := cmd.Context()
var writer io.Writer
if enabled, _ := cmd.Flags().GetBool("debug"); enabled {
writer = cmd.ErrOrStderr()
}
cmd.SetContext(debuglog.WithWriter(previous, writer))
defer cmd.SetContext(previous)
finish := debuglog.Trace(cmd.Context(), operation)
defer func() { finish(err) }()
return run(cmd, args)
}
}
for _, child := range cmd.Commands() {
instrumentCommands(child)
}
}
52 changes: 52 additions & 0 deletions internal/cli/debug_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cli

import (
"context"
"errors"
"strings"
"testing"

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

func TestDebugStreamsPrivacyAndContextIsolation(t *testing.T) {
configEnvironment(t)
original := context.Background()
factory := func(config.Config) (service.DecisionService, func(), error) {
return decideFunc(func(context.Context, service.Request) (service.Response, error) {
return service.Response{Choice: "private-option"}, nil
}), nil, nil
}
cmd := NewRootWithFactory(factory)
cmd.SetContext(original)
args := []string{"decide", "--prompt=private-prompt", "--option=private-option", "--option=other", "--api-key=private-key"}
quiet, stderr, err := execute(cmd, args...)
if err != nil || stderr != "" {
t.Fatal("default logs changed command behavior", err)
}
verbose, stderr, err := execute(cmd, "decide", "--debug")
if err != nil || quiet != verbose || !strings.Contains(stderr, `"operation":"config.load"`) || !strings.Contains(stderr, "decision.dispatch") {
t.Fatal("debug output missing or stdout changed", err)
}
child, _, _ := cmd.Find([]string{"decide"})
if strings.Contains(stderr, "private") || child.Context() != original || cmd.Context() != original {
t.Fatal("diagnostics leaked input or context")
}
_, stderr, err = execute(cmd, "decide", "--debug=false")
if err != nil || stderr != "" {
t.Fatal("debug setting leaked across invocations", err)
}
for _, args := range [][]string{{"--help", "--debug"}, {"decide", "--help", "--debug"}, {"version"}} {
if _, stderr, err := execute(NewRoot(), args...); err != nil || stderr != "" {
t.Fatal("help/version unexpectedly logged", err)
}
}
broken := NewRootWithFactory(func(config.Config) (service.DecisionService, func(), error) {
return nil, nil, errors.New("private-service-error")
})
_, stderr, err = execute(broken, append(args, "--debug")...)
if err == nil || strings.Contains(stderr, "private") || !strings.Contains(stderr, `"success":false`) {
t.Fatal("failure logs leaked or lost the outcome")
}
}
4 changes: 4 additions & 0 deletions internal/cli/decide.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"

"github.com/bitbrew-dev/jevwise/internal/config"
"github.com/bitbrew-dev/jevwise/internal/debuglog"
"github.com/bitbrew-dev/jevwise/internal/service"
"github.com/bitbrew-dev/jevwise/pkg/typesafe"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -98,6 +99,7 @@ func newDecide(factory ServiceFactory) *cobra.Command {
if err := ctx.Err(); err != nil {
return err
}
debuglog.Event(ctx, "decision.service.initialize")
svc, cleanup, err := factory(cfg)
if cleanup != nil {
defer cleanup()
Expand All @@ -111,13 +113,15 @@ func newDecide(factory ServiceFactory) *cobra.Command {
if err := ctx.Err(); err != nil {
return err
}
debuglog.Event(ctx, "decision.dispatch")
response, err := svc.Decide(ctx, request)
if err != nil {
return &decisionError{"decision request failed", err}
}
if err := ctx.Err(); err != nil {
return err
}
debuglog.Event(ctx, "decision.output")
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(response); err != nil {
return &decisionError{"cannot write decision response", err}
}
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/bitbrew-dev/jevwise/internal/daemon"
"github.com/bitbrew-dev/jevwise/internal/debuglog"
"github.com/bitbrew-dev/jevwise/internal/mcpserver"
"github.com/bitbrew-dev/jevwise/internal/service"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -66,6 +67,7 @@ func newMCPWithBackground(factory ServiceFactory, run mcpRunner, start mcpStarte
if background {
return runMCPBackground(cmd, cfg, canonical, token, runtimeDir, start)
}
debuglog.Event(ctx, "mcp.service.initialize")
svc, cleanup, err := factory(cfg)
if cleanup != nil {
defer cleanup()
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/mcp_background.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/bitbrew-dev/jevwise/internal/buildinfo"
"github.com/bitbrew-dev/jevwise/internal/config"
"github.com/bitbrew-dev/jevwise/internal/daemon"
"github.com/bitbrew-dev/jevwise/internal/debuglog"
"github.com/bitbrew-dev/jevwise/internal/mcpserver"
"github.com/bitbrew-dev/jevwise/internal/service"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -40,6 +41,7 @@ func runMCPBackground(cmd *cobra.Command, cfg config.Config, address, agentToken
if err := daemon.ValidateBootstrap(boot); err != nil {
return err
}
debuglog.Event(cmd.Context(), "mcp.background.inspect")
state, token, err := readBackgroundInstance(path)
if err == nil {
controller, err := daemon.NewController(state.Address, state.Instance, token)
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/mcp_management.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strconv"

"github.com/bitbrew-dev/jevwise/internal/daemon"
"github.com/bitbrew-dev/jevwise/internal/debuglog"
"github.com/spf13/cobra"
)

Expand All @@ -30,6 +31,7 @@ func newMCPManagement(action string, runtimeDir *string) *cobra.Command {
if err != nil || *runtimeDir == "" {
return errors.New("invalid MCP runtime directory")
}
debuglog.Event(cmd.Context(), "mcp.management.inspect")
store, err := daemon.OpenExistingStore(filepath.Clean(path))
if errors.Is(err, os.ErrNotExist) {
return writeMCPManagement(cmd, "MCP background instance is not running", false)
Expand Down
18 changes: 16 additions & 2 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/bitbrew-dev/jevwise/internal/buildinfo"
"github.com/bitbrew-dev/jevwise/internal/config"
"github.com/bitbrew-dev/jevwise/internal/debuglog"
"github.com/bitbrew-dev/jevwise/internal/mcpserver"
"github.com/bitbrew-dev/jevwise/internal/update"
"github.com/phuslu/log"
Expand Down Expand Up @@ -48,6 +49,7 @@ func NewRootWithFactory(factory ServiceFactory) *cobra.Command {
// Leave Command.Version empty: Cobra prints raw version-template writer errors.
cmd.Flags().BoolVar(&showVersion, "version", false, "Print version and build metadata")
flags := cmd.PersistentFlags()
flags.Bool("debug", false, "Write safe structured debug logs to stderr")
flags.String("config", "", "Path to TOML configuration")
flags.String("provider", "jev", "Decision provider: jev, codex or claude")
flags.String("api-key", "", "Jev API key (prefer environment or config)")
Expand All @@ -62,20 +64,32 @@ func NewRootWithFactory(factory ServiceFactory) *cobra.Command {
cmd.AddCommand(newMCP(factory, mcpserver.Run))
cmd.AddCommand(newMCPChild(factory))
cmd.AddCommand(newSkill(newSkillFetch(nil), installSkill))
instrumentCommands(cmd)
return cmd
}

// LoadConfig resolves a command's inherited flags without changing global state.
func LoadConfig(cmd *cobra.Command) (config.Config, error) {
func LoadConfig(cmd *cobra.Command) (_ config.Config, resultErr error) {
finish := debuglog.Trace(cmd.Context(), "config.load")
defer func() { finish(resultErr) }()
path, err := cmd.Flags().GetString("config")
if err != nil {
return config.Config{}, errors.New("configuration flag is unavailable")
}
if path == "" {
debuglog.Event(cmd.Context(), "config.discovery")
} else {
debuglog.Event(cmd.Context(), "config.explicit")
}
return config.Load(path, cmd.Flags())
}

// Logger writes only to the command's error stream. Log status and safe metadata,
// never credentials, prompt bodies, option content, or raw request/response bodies.
func Logger(cmd *cobra.Command) log.Logger {
return log.Logger{Level: log.InfoLevel, Writer: log.IOWriter{Writer: cmd.ErrOrStderr()}}
level := log.InfoLevel
if enabled, _ := cmd.Root().PersistentFlags().GetBool("debug"); enabled {
level = log.DebugLevel
}
return log.Logger{Level: level, Writer: log.IOWriter{Writer: cmd.ErrOrStderr()}}
}
2 changes: 2 additions & 0 deletions internal/cli/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"
"unicode/utf8"

"github.com/bitbrew-dev/jevwise/internal/debuglog"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -132,6 +133,7 @@ func newSkill(fetch skillFetch, install skillInstall) *cobra.Command {
if err := ctx.Err(); err != nil {
return err
}
debuglog.Event(ctx, "skill.fetch")
data, err := fetch(ctx)
if err != nil {
return &decisionError{"cannot fetch Jev skill", err}
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/skill_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"io"
"os"
"runtime"

"github.com/bitbrew-dev/jevwise/internal/debuglog"
)

var errSkillExists = errors.New("skill already exists; use --force to replace it")
Expand All @@ -30,6 +32,8 @@ func installSkill(ctx context.Context, data []byte, target string, force bool) e
// replacement; Unix rename replaces the directory entry, never its old inode.
// Directory and final-link checks are static checks, not hostile-writer CAS.
func installSkillAt(ctx context.Context, project string, data []byte, target string, force bool, hooks skillInstallHooks) (err error) {
finish := debuglog.Trace(ctx, "skill.install")
defer func() { finish(err) }()
if ctx == nil {
return errors.New("skill context is required")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func newUpdate(ops updateOps, current string) *cobra.Command {
if errors.Is(err, update.ErrNoRelease) {
output = "No stable Jevwise release is published yet.\n"
} else if err != nil {
return &decisionError{"cannot check Jevwise releases", err}
return &decisionError{update.LookupMessage(err), err}
} else {
if _, err := update.Compare(release.Tag, release.Tag); err != nil {
return &decisionError{"invalid latest release version", err}
Expand Down
32 changes: 32 additions & 0 deletions internal/cli/update_diagnostics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package cli

import (
"io"
"net/http"
"strings"
"testing"

"github.com/bitbrew-dev/jevwise/internal/update"
)

func TestUpdateReportsRateLimitWithAndWithoutDebug(t *testing.T) {
client := update.NewClient(&http.Client{Transport: skillTransport(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 403, Header: http.Header{"X-Ratelimit-Remaining": {"0"}}, Body: io.NopCloser(strings.NewReader("private-body"))}, nil
})})
for _, debug := range []bool{false, true} {
root := updateRoot(client.Latest, "dev")
cmd, _, _ := root.Find([]string{"update"})
instrumentCommands(cmd) // The injected test command was added after NewRoot.
args := []string{"update", "--check"}
if debug {
args = append(args, "--debug")
}
out, stderr, err := execute(root, args...)
if err == nil || out != "" || !strings.Contains(err.Error(), "rate limited") || strings.Contains(stderr+err.Error(), "private") {
t.Fatal("rate-limit failure unhelpful or unsafe")
}
if debug != strings.Contains(stderr, "update.lookup.failed.rate_limited") {
t.Fatal("debug flag was not honored")
}
}
}
6 changes: 5 additions & 1 deletion internal/daemon/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"net"
"net/http"
"time"

"github.com/bitbrew-dev/jevwise/internal/debuglog"
)

const controlResponseLimit = 4 << 10
Expand Down Expand Up @@ -59,7 +61,9 @@ func (c *Controller) Stop(ctx context.Context) (ManagementStatus, error) {
return c.request(ctx, http.MethodPost, "/_jev/stop")
}

func (c *Controller) request(ctx context.Context, method, path string) (ManagementStatus, error) {
func (c *Controller) request(ctx context.Context, method, path string) (_ ManagementStatus, resultErr error) {
finish := debuglog.Trace(ctx, "mcp.management.request")
defer func() { finish(resultErr) }()
fail := func(cause error) (ManagementStatus, error) {
return ManagementStatus{}, &controlError{cause}
}
Expand Down
Loading
Loading