diff --git a/CHANGELOG.md b/CHANGELOG.md index b776282..f3a240b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ # Changelog -## feat/mcp-server +## feat/config-package-go126 + +Persistent configuration via `~/.httpmon/config.json` replaces pure CLI-flag +defaults (#22). A new `internal/config` package handles Load/Save with automatic +default backfill for forward-compatible configs, CLI flag overrides via +`flag.Visit`, and transparent MCP token migration from the legacy `mcp-token` +file. A TUI settings screen (P key) exposes all seven fields — ProxyPort, +MCPEnabled, MCPAddr, BufferSize, ThrottlePreset, ListMode, TreeGroupBy — with +bool/enum toggle and text editing, auto-saving on close. Ten unit tests cover +open/close, navigation, field toggling, text editing with cancel, disk +persistence, view rendering, and menu integration. Go upgraded from 1.25.3 to +1.26.0 alongside golangci-lint v2.9.0, resolving the race detector toolchain +mismatch. + +## [feat/mcp-server](https://github.com/kostyay/httpmon/pull/20) - 2026-02-16 An MCP (Model Context Protocol) server lets LLM agents debug HTTP traffic programmatically alongside the TUI. Fourteen tools span read-only inspection diff --git a/README.md b/README.md index ef4d5d8..97b00aa 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ httpmon --throttle 3g # Simulate 3G network (750 kbps) httpmon --throttle 4g # Simulate 4G network (4 Mbps) httpmon --latency 100ms # Add 100ms latency to responses httpmon --maplocal rules.json # Serve local files for matching URLs +httpmon --mcp # Start with MCP server enabled +httpmon --mcp-token # Print MCP bearer token httpmon --install-ca # Install CA cert into system trust store (needs sudo) httpmon --version # Print version ``` @@ -228,6 +230,49 @@ Changes auto-save back to the rules file. Flows served from local files show a `[L]` indicator in the flow list. +## MCP Server + +httpmon includes an MCP (Model Context Protocol) server so LLM agents can programmatically inspect and debug HTTP traffic. + +### Start the MCP server + +```bash +httpmon --mcp # Start on default addr (127.0.0.1:9551) +httpmon --mcp --mcp-addr :9600 # Custom address +``` + +### Get the bearer token + +```bash +httpmon --mcp-token +``` + +### Configure Claude Code + +```bash +claude mcp add --transport http httpmon http://127.0.0.1:9551/mcp \ + --header "Authorization: Bearer $(httpmon --mcp-token)" +``` + +### Available tools + +| Tool | Description | +|------|-------------| +| `list_requests` | List captured HTTP flows with optional filter | +| `get_request` | Get full request/response details | +| `search_requests` | Search flows by substring | +| `get_request_count` | Count flows matching a filter | +| `export_har` | Export flows as HAR 1.2 JSON | +| `set_throttle` | Set bandwidth throttling (3g/4g/wifi presets) | +| `get_throttle` | Get current throttle settings | +| `replay_request` | Replay a captured request or compose a new one | +| `mock_response` | Mock a response for matching URLs | +| `list_scripts` | List scripting hooks | +| `create_script` | Create a new script | +| `get_script` | Get script source | +| `toggle_script` | Enable/disable a script | +| `delete_script` | Remove a script | + ## Keyboard Shortcuts Press `?` anywhere for the full help overlay, or `Space` for a context-aware action menu. diff --git a/cmd/httpmon/main.go b/cmd/httpmon/main.go index 5ef52b1..2ee285d 100644 --- a/cmd/httpmon/main.go +++ b/cmd/httpmon/main.go @@ -14,6 +14,7 @@ import ( "github.com/kostyay/httpmon/internal/breakpoint" "github.com/kostyay/httpmon/internal/certutil" + "github.com/kostyay/httpmon/internal/config" "github.com/kostyay/httpmon/internal/hostfilter" "github.com/kostyay/httpmon/internal/mcpserver" "github.com/kostyay/httpmon/internal/procinfo" @@ -27,17 +28,18 @@ import ( var version = "dev" func main() { - port := flag.Int("port", 8080, "proxy listen port") + flag.Int("port", 8080, "proxy listen port") dataDir := flag.String("data-dir", defaultDataDir(), "data directory for CA certs") - bufSize := flag.Int("buffer-size", 10000, "max flows in memory") + flag.Int("buffer-size", 10000, "max flows in memory") blockHosts := flag.String("block", "", "comma-separated host patterns to block (wildcards: *.ads.com)") allowHosts := flag.String("allow", "", "comma-separated host patterns to allow (only these intercepted)") showVersion := flag.Bool("version", false, "print version and exit") installCA := flag.Bool("install-ca", false, "install CA cert into system trust store and exit") - throttleFlag := flag.String("throttle", "", "throttle preset: 3g, 4g, wifi") + flag.String("throttle", "", "throttle preset: 3g, 4g, wifi") latencyFlag := flag.Duration("latency", 0, "added latency per response (e.g. 100ms)") - mcpFlag := flag.Bool("mcp", false, "start MCP server on default addr (127.0.0.1:9551)") - mcpAddrFlag := flag.String("mcp-addr", "", "MCP server listen address (implies --mcp)") + flag.Bool("mcp", false, "start MCP server on default addr (127.0.0.1:9551)") + flag.String("mcp-addr", "", "MCP server listen address (implies --mcp)") + mcpTokenFlag := flag.Bool("mcp-token", false, "print MCP bearer token and exit") flag.Parse() if *showVersion { @@ -45,18 +47,38 @@ func main() { return } - if *port < 1 || *port > 65535 { - fatal("invalid port: %d", *port) + cfg, cfgErr := config.Load(*dataDir) + if cfgErr != nil { + fatal("config: %v", cfgErr) } - if *bufSize < 1 { + config.ApplyFlags(cfg, flag.Visit) + + // --mcp-addr implies --mcp. + flag.Visit(func(f *flag.Flag) { + if f.Name == "mcp-addr" { + cfg.MCPEnabled = true + } + }) + + if *mcpTokenFlag { + if err := config.LoadOrCreateToken(cfg, *dataDir); err != nil { + fatal("mcp token: %v", err) + } + fmt.Println(cfg.MCPToken) + return + } + + if cfg.ProxyPort < 1 || cfg.ProxyPort > 65535 { + fatal("invalid port: %d", cfg.ProxyPort) + } + if cfg.BufferSize < 1 { fatal("buffer-size must be > 0") } - s := store.New(*bufSize) + s := store.New(cfg.BufferSize) p := proxy.New(s, *dataDir) p.Resolver = procinfo.New(s) - // Init scripting engine and breakpoint controller. scriptsDir := filepath.Join(*dataDir, "scripts") engine := scripting.New() engine.LoadFromDir(scriptsDir) @@ -70,11 +92,10 @@ func main() { p.HostFilter = hostfilter.New(block, allow) } - // Throttle configuration. - if *throttleFlag != "" { - bps := throttle.PresetBandwidth(*throttleFlag) + if cfg.ThrottlePreset != "" { + bps := throttle.PresetBandwidth(cfg.ThrottlePreset) if bps == 0 { - fatal("unknown throttle preset: %q (use 3g, 4g, or wifi)", *throttleFlag) + fatal("unknown throttle preset: %q (use 3g, 4g, or wifi)", cfg.ThrottlePreset) } p.ThrottleBPS = bps } @@ -82,7 +103,7 @@ func main() { p.ThrottleLatency = *latencyFlag } - addr := fmt.Sprintf(":%d", *port) + addr := fmt.Sprintf(":%d", cfg.ProxyPort) if err := p.Init(addr); err != nil { fatal("proxy init: %v", err) } @@ -106,46 +127,38 @@ func main() { caTrusted := certutil.IsInstalled(p.CACertPath()) mgr := scripting.NewManager(engine, scriptsDir) - // MCP server (optional). - if *mcpAddrFlag != "" { - *mcpFlag = true - } var mcpSrv *mcpserver.Server - if *mcpFlag { - mcpAddr := *mcpAddrFlag - if mcpAddr == "" { - mcpAddr = mcpserver.DefaultAddr - } - token, tokenErr := mcpserver.LoadOrCreateToken(*dataDir) - if tokenErr != nil { - fatal("mcp token: %v", tokenErr) + if cfg.MCPEnabled { + if err := config.LoadOrCreateToken(cfg, *dataDir); err != nil { + fatal("mcp token: %v", err) } mcpSrv = mcpserver.New(mcpserver.Config{ Store: s, Proxy: p, Scripts: mgr, Throttle: p, - Addr: mcpAddr, - Token: token, + Addr: cfg.MCPAddr, + Token: cfg.MCPToken, }) if err := mcpSrv.Start(ctx); err != nil { fatal("mcp server: %v", err) } - fmt.Fprintf(os.Stderr, "MCP server listening on %s (token: %s)\n", mcpSrv.Addr(), token) + fmt.Fprintf(os.Stderr, "MCP server listening on %s (token: %s)\n", mcpSrv.Addr(), cfg.MCPToken) } - cfg := tui.AppConfig{ + tuiCfg := tui.AppConfig{ Store: s, Proxy: p, CATrusted: caTrusted, Scripts: mgr, Throttle: p, Breakpoints: bpCtrl, + DataDir: *dataDir, } if mcpSrv != nil { - cfg.MCP = mcpSrv + tuiCfg.MCP = mcpSrv } - app := tui.NewApp(cfg) + app := tui.NewApp(tuiCfg) prog := tea.NewProgram(app) if _, err := prog.Run(); err != nil { fatal("TUI error: %v", err) diff --git a/go.mod b/go.mod index e0a80cb..51c10fb 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/kostyay/httpmon -go 1.25.3 - -toolchain go1.25.7 +go 1.26.0 require ( charm.land/bubbles/v2 v2.0.0-rc.1 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..67ca328 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,143 @@ +package config + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + configFile = "config.json" + oldTokenFile = "mcp-token" +) + +// Config holds persistent httpmon settings stored in ~/.httpmon/config.json. +type Config struct { + ProxyPort int `json:"proxy_port"` + MCPEnabled bool `json:"mcp_enabled"` + MCPAddr string `json:"mcp_addr"` + MCPToken string `json:"mcp_token"` + BufferSize int `json:"buffer_size"` + ThrottlePreset string `json:"throttle_preset"` + ListMode string `json:"list_mode"` + TreeGroupBy string `json:"tree_group_by"` +} + +// DefaultConfig returns a Config with sensible defaults. +func DefaultConfig() Config { + return Config{ + ProxyPort: 8080, + MCPAddr: "127.0.0.1:9551", + BufferSize: 10000, + } +} + +// Load reads config.json from dataDir. Returns defaults if the file doesn't exist. +func Load(dataDir string) (*Config, error) { + path := filepath.Join(dataDir, configFile) + data, err := os.ReadFile(path) // #nosec G304 -- dataDir is user-provided config dir + if err != nil { + if errors.Is(err, os.ErrNotExist) { + cfg := DefaultConfig() + return &cfg, nil + } + return nil, fmt.Errorf("read config: %w", err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + applyDefaults(&cfg) + return &cfg, nil +} + +// Save writes the config as indented JSON to dataDir/config.json. +func (c *Config) Save(dataDir string) error { + if err := os.MkdirAll(dataDir, 0o750); err != nil { + return fmt.Errorf("create data dir: %w", err) + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + path := filepath.Join(dataDir, configFile) + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} + +// LoadOrCreateToken ensures cfg.MCPToken is set. It migrates from the legacy +// mcp-token file if present, otherwise generates a new 32-byte hex token. +// The config is saved after the token is set. +func LoadOrCreateToken(cfg *Config, dataDir string) error { + if cfg.MCPToken != "" { + return nil + } + + // Try migrating from legacy file. + oldPath := filepath.Join(dataDir, oldTokenFile) + if data, err := os.ReadFile(oldPath); err == nil { // #nosec G304 + tok := strings.TrimSpace(string(data)) + if tok != "" { + cfg.MCPToken = tok + _ = os.Remove(oldPath) + return cfg.Save(dataDir) + } + } + + // Generate new token. + var buf [32]byte + if _, err := rand.Read(buf[:]); err != nil { + return fmt.Errorf("generate token: %w", err) + } + cfg.MCPToken = hex.EncodeToString(buf[:]) + return cfg.Save(dataDir) +} + +// ApplyFlags overrides config fields with explicitly-set CLI flags. +// Only flags the user actually passed on the command line are applied. +func ApplyFlags(cfg *Config, visit func(fn func(*flag.Flag))) { + visit(func(f *flag.Flag) { + switch f.Name { + case "port": + cfg.ProxyPort = mustInt(f.Value.String()) + case "buffer-size": + cfg.BufferSize = mustInt(f.Value.String()) + case "mcp": + cfg.MCPEnabled = f.Value.String() == "true" + case "mcp-addr": + cfg.MCPAddr = f.Value.String() + case "throttle": + cfg.ThrottlePreset = f.Value.String() + } + }) +} + +// applyDefaults fills zero-value fields with defaults so that older config +// files missing new fields still behave correctly. +func applyDefaults(cfg *Config) { + d := DefaultConfig() + if cfg.ProxyPort == 0 { + cfg.ProxyPort = d.ProxyPort + } + if cfg.MCPAddr == "" { + cfg.MCPAddr = d.MCPAddr + } + if cfg.BufferSize == 0 { + cfg.BufferSize = d.BufferSize + } +} + +func mustInt(s string) int { + v, _ := strconv.Atoi(s) + return v +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..2e59629 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,222 @@ +package config + +import ( + "encoding/json" + "flag" + "os" + "path/filepath" + "testing" +) + +func TestLoad_MissingFile(t *testing.T) { + dir := t.TempDir() + cfg, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + d := DefaultConfig() + if cfg.ProxyPort != d.ProxyPort { + t.Errorf("ProxyPort = %d, want %d", cfg.ProxyPort, d.ProxyPort) + } + if cfg.MCPAddr != d.MCPAddr { + t.Errorf("MCPAddr = %q, want %q", cfg.MCPAddr, d.MCPAddr) + } + if cfg.BufferSize != d.BufferSize { + t.Errorf("BufferSize = %d, want %d", cfg.BufferSize, d.BufferSize) + } +} + +func TestSaveLoadRoundtrip(t *testing.T) { + dir := t.TempDir() + orig := &Config{ + ProxyPort: 9090, + MCPEnabled: true, + MCPAddr: "0.0.0.0:1234", + MCPToken: "tok123", + BufferSize: 500, + ThrottlePreset: "3g", + ListMode: "tree", + TreeGroupBy: "host", + } + if err := orig.Save(dir); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if *got != *orig { + t.Errorf("roundtrip mismatch:\n got %+v\nwant %+v", got, orig) + } +} + +func TestLoad_AppliesDefaults(t *testing.T) { + dir := t.TempDir() + // Write a config with only one field set; rest should get defaults. + data, _ := json.Marshal(Config{MCPEnabled: true}) + if err := os.WriteFile(filepath.Join(dir, configFile), data, 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ProxyPort != 8080 { + t.Errorf("ProxyPort = %d, want 8080", cfg.ProxyPort) + } + if cfg.BufferSize != 10000 { + t.Errorf("BufferSize = %d, want 10000", cfg.BufferSize) + } + if !cfg.MCPEnabled { + t.Error("MCPEnabled should be true") + } +} + +func TestTokenMigration(t *testing.T) { + dir := t.TempDir() + oldTok := "legacy-token-value" + if err := os.WriteFile(filepath.Join(dir, oldTokenFile), []byte(oldTok+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + cfg := &Config{} + if err := LoadOrCreateToken(cfg, dir); err != nil { + t.Fatalf("LoadOrCreateToken: %v", err) + } + if cfg.MCPToken != oldTok { + t.Errorf("MCPToken = %q, want %q", cfg.MCPToken, oldTok) + } + // Old file should be removed. + if _, err := os.Stat(filepath.Join(dir, oldTokenFile)); !os.IsNotExist(err) { + t.Error("old mcp-token file should be deleted") + } + // Config should be persisted. + loaded, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.MCPToken != oldTok { + t.Errorf("persisted MCPToken = %q, want %q", loaded.MCPToken, oldTok) + } +} + +func TestLoadOrCreateToken_GeneratesNew(t *testing.T) { + dir := t.TempDir() + cfg := &Config{} + if err := LoadOrCreateToken(cfg, dir); err != nil { + t.Fatalf("LoadOrCreateToken: %v", err) + } + if len(cfg.MCPToken) != 64 { // 32 bytes = 64 hex chars + t.Errorf("token length = %d, want 64", len(cfg.MCPToken)) + } +} + +func TestLoadOrCreateToken_AlreadySet(t *testing.T) { + cfg := &Config{MCPToken: "existing"} + if err := LoadOrCreateToken(cfg, t.TempDir()); err != nil { + t.Fatalf("LoadOrCreateToken: %v", err) + } + if cfg.MCPToken != "existing" { + t.Error("should not overwrite existing token") + } +} + +func TestApplyFlags(t *testing.T) { + cfg := DefaultConfig() + + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.Int("port", 8080, "") + fs.Int("buffer-size", 10000, "") + fs.Bool("mcp", false, "") + fs.String("mcp-addr", "", "") + fs.String("throttle", "", "") + + // Only set port and throttle. + if err := fs.Parse([]string{"-port", "3128", "-throttle", "4g"}); err != nil { + t.Fatal(err) + } + + ApplyFlags(&cfg, fs.Visit) + + if cfg.ProxyPort != 3128 { + t.Errorf("ProxyPort = %d, want 3128", cfg.ProxyPort) + } + if cfg.ThrottlePreset != "4g" { + t.Errorf("ThrottlePreset = %q, want 4g", cfg.ThrottlePreset) + } + // Unset flags should keep defaults. + if cfg.BufferSize != 10000 { + t.Errorf("BufferSize = %d, want 10000 (unchanged)", cfg.BufferSize) + } + if cfg.MCPEnabled { + t.Error("MCPEnabled should remain false") + } +} + +func TestApplyFlags_AllFlags(t *testing.T) { + cfg := DefaultConfig() + + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.Int("port", 8080, "") + fs.Int("buffer-size", 10000, "") + fs.Bool("mcp", false, "") + fs.String("mcp-addr", "", "") + fs.String("throttle", "", "") + + if err := fs.Parse([]string{ + "-port", "9090", + "-buffer-size", "500", + "-mcp", + "-mcp-addr", "0.0.0.0:5555", + "-throttle", "3g", + }); err != nil { + t.Fatal(err) + } + + ApplyFlags(&cfg, fs.Visit) + + if cfg.ProxyPort != 9090 { + t.Errorf("ProxyPort = %d, want 9090", cfg.ProxyPort) + } + if cfg.BufferSize != 500 { + t.Errorf("BufferSize = %d, want 500", cfg.BufferSize) + } + if !cfg.MCPEnabled { + t.Error("MCPEnabled should be true") + } + if cfg.MCPAddr != "0.0.0.0:5555" { + t.Errorf("MCPAddr = %q, want 0.0.0.0:5555", cfg.MCPAddr) + } + if cfg.ThrottlePreset != "3g" { + t.Errorf("ThrottlePreset = %q, want 3g", cfg.ThrottlePreset) + } +} + +func TestLoad_InvalidJSON(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, configFile), []byte("{bad json"), 0o600); err != nil { + t.Fatal(err) + } + _, err := Load(dir) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestLoad_UnreadableFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, configFile) + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o600) }) + + _, err := Load(dir) + if err == nil { + t.Fatal("expected error for unreadable file") + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index d12b500..4b4b64e 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -13,6 +13,7 @@ import ( "charm.land/lipgloss/v2" "github.com/kostyay/httpmon/internal/breakpoint" + "github.com/kostyay/httpmon/internal/config" "github.com/kostyay/httpmon/internal/filter" "github.com/kostyay/httpmon/internal/scripting" "github.com/kostyay/httpmon/internal/store" @@ -105,6 +106,14 @@ type App struct { // MCP server mcp MCPServer + // settings modal + dataDir string + showSettings bool + settingsCursor int + settingsEditing bool + settingsInput textinput.Model + settingsConfig *config.Config + // breakpoint editor breakpoints breakpoint.Controller showBreakpointQueue bool @@ -145,6 +154,7 @@ type AppConfig struct { Throttle ThrottleController Breakpoints breakpoint.Controller MCP MCPServer + DataDir string } // NewApp creates a TUI application from the given config. @@ -164,6 +174,7 @@ func NewApp(cfg AppConfig) *App { scripts: cfg.Scripts, throttle: cfg.Throttle, mcp: cfg.MCP, + dataDir: cfg.DataDir, filterInput: ti, searchInput: si, groupExpanded: make(map[string]bool), @@ -225,6 +236,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showBreakpointQueue { return a.updateBreakpoint(msg) } + if a.showSettings { + return a.updateSettings(msg) + } if a.showThrottle { return a.updateThrottle(msg) } @@ -272,6 +286,8 @@ func (a *App) viewContent() string { return a.viewDiff() case a.showHelp: return a.viewHelp() + case a.showSettings: + return a.viewSettings() case a.showThrottle: return a.viewThrottle() case a.showScripts: @@ -377,6 +393,9 @@ func (a *App) updateList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { a.initThrottle() } return a, nil + case "P": + a.initSettings() + return a, nil case "B": if a.breakpoints != nil { a.initBreakpointQueue() @@ -581,6 +600,9 @@ func (a *App) updateDetail(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { a.initThrottle() } return a, nil + case "P": + a.initSettings() + return a, nil case "esc", "q": a.showDetail = false a.detailImagePreview = false diff --git a/internal/tui/compose.go b/internal/tui/compose.go index 11fe224..d8a7575 100644 --- a/internal/tui/compose.go +++ b/internal/tui/compose.go @@ -133,7 +133,7 @@ func (a *App) sendCompose() (tea.Model, tea.Cmd) { TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 -- routes through our own MITM proxy CA }, } - resp, doErr := client.Do(req) + resp, doErr := client.Do(req) // #nosec G704 -- user-composed request sent through local proxy if doErr != nil { return tea.Printf("Send failed: %v", doErr) } diff --git a/internal/tui/editor.go b/internal/tui/editor.go index 64dcc32..8b5d7ed 100644 --- a/internal/tui/editor.go +++ b/internal/tui/editor.go @@ -73,7 +73,7 @@ func openInEditor(body []byte, contentType string) tea.Cmd { } editor := resolveEditor() - c := exec.Command(editor, f.Name()) // #nosec G204 -- editor from $VISUAL/$EDITOR env + c := exec.Command(editor, f.Name()) // #nosec G204 G702 -- editor from $VISUAL/$EDITOR env return tea.ExecProcess(c, func(err error) tea.Msg { return editorFinishedMsg{err: err} }) diff --git a/internal/tui/help.go b/internal/tui/help.go index 74cef25..77cfc65 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -59,6 +59,7 @@ func helpGroups() []helpGroup { title: "Global", bindings: []helpBinding{ {"?", "Toggle this help"}, + {"P", "Settings"}, {"S", "Scripts manager"}, {"T", "Throttle settings"}, {"q / Ctrl+C", "Quit"}, diff --git a/internal/tui/menu.go b/internal/tui/menu.go index 50da887..1a74e0a 100644 --- a/internal/tui/menu.go +++ b/internal/tui/menu.go @@ -61,6 +61,7 @@ func (a *App) listMenuItems() []menuItem { if a.breakpoints != nil { items = append(items, menuItem{label: "Breakpoints", key: "B"}) } + items = append(items, menuItem{label: "Settings", key: "P"}) return items } @@ -81,6 +82,7 @@ func (a *App) detailMenuItems() []menuItem { if a.throttle != nil { items = append(items, menuItem{label: "Throttle", key: "T"}) } + items = append(items, menuItem{label: "Settings", key: "P"}) return items } diff --git a/internal/tui/repeat.go b/internal/tui/repeat.go index 43cdfa0..197274c 100644 --- a/internal/tui/repeat.go +++ b/internal/tui/repeat.go @@ -60,7 +60,7 @@ func (a *App) repeatRequest() tea.Cmd { }, } - resp, err := client.Do(req) + resp, err := client.Do(req) // #nosec G704 -- user-initiated replay of captured request if err != nil { return tea.Printf("Repeat failed: %v", err) } diff --git a/internal/tui/settings.go b/internal/tui/settings.go new file mode 100644 index 0000000..345f18e --- /dev/null +++ b/internal/tui/settings.go @@ -0,0 +1,212 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/kostyay/httpmon/internal/config" +) + +type settingsField struct { + label string + kind string // "int", "bool", "enum", "string" + options []string + restart bool + get func(*config.Config) string + set func(*config.Config, string) +} + +var settingsFields = []settingsField{ + { + label: "Proxy Port", kind: "int", restart: true, + get: func(c *config.Config) string { return strconv.Itoa(c.ProxyPort) }, + set: func(c *config.Config, v string) { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 65535 { + c.ProxyPort = n + } + }, + }, + { + label: "MCP Enabled", kind: "bool", restart: true, + get: func(c *config.Config) string { return strconv.FormatBool(c.MCPEnabled) }, + set: func(c *config.Config, v string) { c.MCPEnabled = v == "true" }, + }, + { + label: "MCP Address", kind: "string", restart: true, + get: func(c *config.Config) string { return c.MCPAddr }, + set: func(c *config.Config, v string) { c.MCPAddr = v }, + }, + { + label: "Buffer Size", kind: "int", restart: true, + get: func(c *config.Config) string { return strconv.Itoa(c.BufferSize) }, + set: func(c *config.Config, v string) { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.BufferSize = n + } + }, + }, + { + label: "Throttle Preset", kind: "enum", options: []string{"", "3g", "4g", "wifi"}, + get: func(c *config.Config) string { return c.ThrottlePreset }, + set: func(c *config.Config, v string) { c.ThrottlePreset = v }, + }, + { + label: "List Mode", kind: "enum", options: []string{"", "flat", "tree"}, + get: func(c *config.Config) string { return c.ListMode }, + set: func(c *config.Config, v string) { c.ListMode = v }, + }, + { + label: "Tree Group By", kind: "enum", options: []string{"", "host", "process"}, + get: func(c *config.Config) string { return c.TreeGroupBy }, + set: func(c *config.Config, v string) { c.TreeGroupBy = v }, + }, +} + +func (a *App) initSettings() { + a.showSettings = true + a.settingsCursor = 0 + a.settingsEditing = false + + // Reload config from disk. + cfg, err := config.Load(a.dataDir) + if err != nil { + return + } + a.settingsConfig = cfg +} + +func (a *App) updateSettings(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if a.settingsEditing { + return a.updateSettingsInput(msg) + } + + switch msg.String() { + case "esc", "P": + // Save and close. + if a.settingsConfig != nil { + _ = a.settingsConfig.Save(a.dataDir) + } + a.showSettings = false + return a, nil + case "j", "down": + if a.settingsCursor < len(settingsFields)-1 { + a.settingsCursor++ + } + case "k", "up": + if a.settingsCursor > 0 { + a.settingsCursor-- + } + case "enter": + if a.settingsConfig == nil { + return a, nil + } + f := settingsFields[a.settingsCursor] + switch f.kind { + case "bool": + f.set(a.settingsConfig, strconv.FormatBool(f.get(a.settingsConfig) != "true")) + case "enum": + cur := f.get(a.settingsConfig) + idx := 0 + for i, opt := range f.options { + if opt == cur { + idx = i + break + } + } + next := (idx + 1) % len(f.options) + f.set(a.settingsConfig, f.options[next]) + case "int", "string": + a.settingsEditing = true + a.settingsInput = textinput.New() + a.settingsInput.SetValue(f.get(a.settingsConfig)) + a.settingsInput.CharLimit = 64 + cmd := a.settingsInput.Focus() + return a, cmd + } + } + return a, nil +} + +func (a *App) updateSettingsInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + if a.settingsConfig != nil { + f := settingsFields[a.settingsCursor] + f.set(a.settingsConfig, a.settingsInput.Value()) + } + a.settingsEditing = false + return a, nil + case "esc": + a.settingsEditing = false + return a, nil + } + + var cmd tea.Cmd + a.settingsInput, cmd = a.settingsInput.Update(msg) + return a, cmd +} + +// settingsFieldDisplay returns the rendered display string for a single settings field. +func (a *App) settingsFieldDisplay(f settingsField, val string, idx int) string { + if a.settingsEditing && idx == a.settingsCursor { + return a.settingsInput.View() + } + switch f.kind { + case "bool": + if val == "true" { + return styleEnabled.Render("on") + } + return styleMuted.Render("off") + default: + if val == "" { + return styleMuted.Render("(default)") + } + return val + } +} + +func (a *App) viewSettings() string { + var b strings.Builder + + b.WriteString(styleMenuTitle.Render(" Settings ")) + b.WriteString("\n\n") + + for i, f := range settingsFields { + val := "" + if a.settingsConfig != nil { + val = f.get(a.settingsConfig) + } + + display := a.settingsFieldDisplay(f, val, i) + + hint := "" + if f.restart { + hint = styleMuted.Render(" (restart)") + } + + line := fmt.Sprintf(" %-18s %s%s", f.label, display, hint) + if i == a.settingsCursor && !a.settingsEditing { + line = styleMenuSelected.Render(line) + } + + b.WriteString(line) + b.WriteString("\n") + } + + b.WriteString("\n") + help := " j/k navigate Enter edit/toggle Esc save & close " + b.WriteString(styleMuted.Render(help)) + + popup := styleMenuBorder.Render(b.String()) + + return lipgloss.Place( + a.width, a.height, + lipgloss.Center, lipgloss.Center, + popup, + ) +} diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go new file mode 100644 index 0000000..271d494 --- /dev/null +++ b/internal/tui/settings_test.go @@ -0,0 +1,264 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/kostyay/httpmon/internal/config" +) + +func newAppWithSettings(dir string) *App { + m := seedMock(3) + app := NewApp(AppConfig{ + Store: m, + Proxy: &mockProxyInfo{addr: ":9999"}, + CATrusted: true, + DataDir: dir, + }) + app.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + app.Update(TickMsg(time.Now())) + return app +} + +// --- Open / Close / Navigate --- + +func TestSettingsOpenClose(t *testing.T) { + app := newAppWithSettings(t.TempDir()) + + sendKey(app, "P") + if !app.showSettings { + t.Fatal("P should open settings") + } + + app.updateSettings(tea.KeyPressMsg{Code: tea.KeyEscape}) + if app.showSettings { + t.Error("Esc should close settings") + } +} + +func TestSettingsPKeyCloses(t *testing.T) { + app := newAppWithSettings(t.TempDir()) + + sendKey(app, "P") + if !app.showSettings { + t.Fatal("P should open settings") + } + + app.updateSettings(tea.KeyPressMsg{Code: 'P', Text: "P"}) + if app.showSettings { + t.Error("second P should close settings") + } +} + +func TestSettingsNavigate(t *testing.T) { + app := newAppWithSettings(t.TempDir()) + sendKey(app, "P") + + app.updateSettings(tea.KeyPressMsg{Code: 'j', Text: "j"}) + if app.settingsCursor != 1 { + t.Errorf("after j: cursor = %d, want 1", app.settingsCursor) + } + + app.updateSettings(tea.KeyPressMsg{Code: 'k', Text: "k"}) + if app.settingsCursor != 0 { + t.Errorf("after k: cursor = %d, want 0", app.settingsCursor) + } + + // Clamp at 0. + app.updateSettings(tea.KeyPressMsg{Code: 'k', Text: "k"}) + if app.settingsCursor != 0 { + t.Errorf("clamp at 0: cursor = %d", app.settingsCursor) + } + + // Clamp at max. + for range len(settingsFields) + 2 { + app.updateSettings(tea.KeyPressMsg{Code: 'j', Text: "j"}) + } + if app.settingsCursor != len(settingsFields)-1 { + t.Errorf("clamp at max: cursor = %d, want %d", app.settingsCursor, len(settingsFields)-1) + } +} + +// --- Field interactions --- + +func TestSettingsBoolToggle(t *testing.T) { + app := newAppWithSettings(t.TempDir()) + sendKey(app, "P") + app.settingsConfig = &config.Config{ProxyPort: 8080, MCPAddr: "127.0.0.1:9551", BufferSize: 10000} + + // MCPEnabled is field index 1. + app.settingsCursor = 1 + if app.settingsConfig.MCPEnabled { + t.Fatal("MCPEnabled should start false") + } + + app.updateSettings(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !app.settingsConfig.MCPEnabled { + t.Error("Enter should toggle MCPEnabled to true") + } + + app.updateSettings(tea.KeyPressMsg{Code: tea.KeyEnter}) + if app.settingsConfig.MCPEnabled { + t.Error("second Enter should toggle MCPEnabled back to false") + } +} + +func TestSettingsEnumCycle(t *testing.T) { + app := newAppWithSettings(t.TempDir()) + sendKey(app, "P") + app.settingsConfig = &config.Config{ProxyPort: 8080, MCPAddr: "127.0.0.1:9551", BufferSize: 10000} + + // ThrottlePreset is field index 4; options: "", "3g", "4g", "wifi". + app.settingsCursor = 4 + + want := []string{"3g", "4g", "wifi", ""} + for _, exp := range want { + app.updateSettings(tea.KeyPressMsg{Code: tea.KeyEnter}) + if app.settingsConfig.ThrottlePreset != exp { + t.Errorf("ThrottlePreset = %q, want %q", app.settingsConfig.ThrottlePreset, exp) + } + } +} + +func TestSettingsIntEdit(t *testing.T) { + app := newAppWithSettings(t.TempDir()) + sendKey(app, "P") + app.settingsConfig = &config.Config{ProxyPort: 8080, MCPAddr: "127.0.0.1:9551", BufferSize: 10000} + + // ProxyPort is field index 0. + app.settingsCursor = 0 + app.updateSettings(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !app.settingsEditing { + t.Fatal("Enter on int field should open text input") + } + + // Clear existing value and type new one. + app.settingsInput.SetValue("9090") + app.updateSettingsInput(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if app.settingsEditing { + t.Error("Enter should close text input") + } + if app.settingsConfig.ProxyPort != 9090 { + t.Errorf("ProxyPort = %d, want 9090", app.settingsConfig.ProxyPort) + } +} + +func TestSettingsEditCancel(t *testing.T) { + app := newAppWithSettings(t.TempDir()) + sendKey(app, "P") + app.settingsConfig = &config.Config{ProxyPort: 8080, MCPAddr: "127.0.0.1:9551", BufferSize: 10000} + + app.settingsCursor = 0 + app.updateSettings(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !app.settingsEditing { + t.Fatal("should be editing") + } + + // Modify the input but cancel. + app.settingsInput.SetValue("1234") + app.updateSettingsInput(tea.KeyPressMsg{Code: tea.KeyEscape}) + + if app.settingsEditing { + t.Error("Esc should close text input") + } + if app.settingsConfig.ProxyPort != 8080 { + t.Errorf("ProxyPort = %d, want 8080 (original value)", app.settingsConfig.ProxyPort) + } +} + +// --- Persistence + View + Menu --- + +func TestSettingsPersistence(t *testing.T) { + dir := t.TempDir() + app := newAppWithSettings(dir) + + sendKey(app, "P") + + // Toggle MCPEnabled (index 1). + app.settingsCursor = 1 + app.updateSettings(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !app.settingsConfig.MCPEnabled { + t.Fatal("MCPEnabled should be true after toggle") + } + + // Close settings (saves to disk). + app.updateSettings(tea.KeyPressMsg{Code: tea.KeyEscape}) + if app.showSettings { + t.Fatal("settings should be closed") + } + + // Reload from disk and verify. + cfg, err := config.Load(dir) + if err != nil { + t.Fatalf("config.Load: %v", err) + } + if !cfg.MCPEnabled { + t.Error("MCPEnabled should be persisted as true") + } +} + +func TestSettingsViewContent(t *testing.T) { + app := newAppWithSettings(t.TempDir()) + sendKey(app, "P") + + view := ansi.Strip(app.viewSettings()) + if view == "" { + t.Fatal("viewSettings should return non-empty") + } + + // All 7 field labels. + for _, label := range []string{ + "Proxy Port", "MCP Enabled", "MCP Address", "Buffer Size", + "Throttle Preset", "List Mode", "Tree Group By", + } { + if !strings.Contains(view, label) { + t.Errorf("view should contain label %q", label) + } + } + + // Restart hints. + if !strings.Contains(view, "(restart)") { + t.Error("view should contain (restart) hint") + } + + // Help text. + if !strings.Contains(view, "Esc save & close") { + t.Error("view should contain help text") + } +} + +func TestSettingsMenuEntry(t *testing.T) { + app := newAppWithSettings(t.TempDir()) + + // List menu. + listItems := app.listMenuItems() + found := false + for _, item := range listItems { + if item.label == "Settings" { + found = true + break + } + } + if !found { + t.Error("listMenuItems should contain Settings") + } + + // Detail menu. + app.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) // enter detail + detailItems := app.detailMenuItems() + found = false + for _, item := range detailItems { + if item.label == "Settings" { + found = true + break + } + } + if !found { + t.Error("detailMenuItems should contain Settings") + } +}