diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-28 17:30:44 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-28 17:30:44 +0300 |
| commit | 0761409497041c752086b9aded08cf9e32e30fd2 (patch) | |
| tree | e62721bc119d4ae435d2609292faea06a68244a4 /internal | |
| parent | 0ac2d186e84f77d73d924e2c0ce975a17c3a8078 (diff) | |
Add --config flag support across CLI, LSP, and tmux tools
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/appconfig/config.go | 21 | ||||
| -rw-r--r-- | internal/hexaiaction/run.go | 22 | ||||
| -rw-r--r-- | internal/hexaicli/run.go | 26 | ||||
| -rw-r--r-- | internal/hexailsp/run.go | 15 | ||||
| -rw-r--r-- | internal/hexailsp/run_more_test.go | 4 | ||||
| -rw-r--r-- | internal/hexailsp/run_test.go | 10 | ||||
| -rw-r--r-- | internal/lsp/chat_commands.go | 5 | ||||
| -rw-r--r-- | internal/lsp/server.go | 17 |
8 files changed, 90 insertions, 30 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 27c7e02..1b134ee 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -180,7 +180,8 @@ func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{} // LoadOptions tune how configuration is loaded at runtime. type LoadOptions struct { // IgnoreEnv skips applying environment overrides when true. - IgnoreEnv bool + IgnoreEnv bool + ConfigPath string } // LoadWithOptions reads configuration and applies the requested loading options. @@ -190,16 +191,20 @@ func LoadWithOptions(logger *log.Logger, opts LoadOptions) App { return cfg // Return defaults if no logger is provided (e.g. in tests) } - configPath, err := getConfigPath() - if err != nil { - logger.Printf("%v", err) - // Even if config path cannot be resolved, keep defaults and optionally apply env overrides below. - } else { + configPath := strings.TrimSpace(opts.ConfigPath) + if configPath != "" { if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil { cfg.mergeWith(fileCfg) + } else if err != nil { + logger.Printf("cannot open config file %s: %v", configPath, err) + } + } else { + path, err := getConfigPath() + if err != nil { + logger.Printf("%v", err) + } else if fileCfg, err := loadFromFile(path, logger); err == nil && fileCfg != nil { + cfg.mergeWith(fileCfg) } - // When the config file is missing or invalid, we keep defaults and still - // apply any environment overrides below (unless disabled). } if !opts.IgnoreEnv { diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go index a5f47cf..2a1f940 100644 --- a/internal/hexaiaction/run.go +++ b/internal/hexaiaction/run.go @@ -23,13 +23,15 @@ var ( newClientFromApp = llmutils.NewClientFromApp ) +type configPathKey struct{} + // selectedCustom carries the chosen custom action (if any) from the TUI submenu // to the executor. Cleared after use. var selectedCustom *appconfig.CustomAction func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error { logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix) - cfg := appconfig.Load(logger) + cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPathFromContext(ctx)}) if cfg.StatsWindowMinutes > 0 { stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) } @@ -77,6 +79,24 @@ func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error { return nil } +// WithConfigPath attaches a config path override to the context for Run/RunCommand. +func WithConfigPath(ctx context.Context, path string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, configPathKey{}, strings.TrimSpace(path)) +} + +func configPathFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if v, ok := ctx.Value(configPathKey{}).(string); ok { + return strings.TrimSpace(v) + } + return "" +} + func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { switch kind { case ActionSkip: diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go index b7745c8..e2aa9a2 100644 --- a/internal/hexaicli/run.go +++ b/internal/hexaicli/run.go @@ -52,7 +52,10 @@ type columnWriter struct { index int } -type selectionContextKey struct{} +type ( + selectionContextKey struct{} + configPathContextKey struct{} +) func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) { entries := cfg.CLIConfigs @@ -160,7 +163,8 @@ func defaultModelForProvider(cfg appconfig.App, provider string) string { func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { // Load configuration with a logger so file-based config is respected. logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) - cfg := appconfig.Load(logger) + configPath := configPathFromContext(ctx) + cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPath}) if cfg.StatsWindowMinutes > 0 { stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) } @@ -494,6 +498,24 @@ func WithCLISelection(ctx context.Context, indices []int) context.Context { return context.WithValue(ctx, selectionContextKey{}, cpy) } +// WithCLIConfigPath returns a context that carries the config file path override. +func WithCLIConfigPath(ctx context.Context, path string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, configPathContextKey{}, strings.TrimSpace(path)) +} + +func configPathFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if v, ok := ctx.Value(configPathContextKey{}).(string); ok { + return strings.TrimSpace(v) + } + return "" +} + func selectionFromContext(ctx context.Context) []int { if ctx == nil { return nil diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go index ffb9f86..750e544 100644 --- a/internal/hexailsp/run.go +++ b/internal/hexailsp/run.go @@ -25,7 +25,12 @@ type ServerFactory func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.S // Run configures logging, loads config, builds the LLM client and runs the LSP server. // It is thin and delegates to RunWithFactory for testability. + func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { + return RunWithConfig(logPath, "", stdin, stdout, stderr) +} + +func RunWithConfig(logPath string, configPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { logger := log.New(stderr, "hexai-lsp ", log.LstdFlags|log.Lmsgprefix) if strings.TrimSpace(logPath) != "" { f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) @@ -36,19 +41,20 @@ func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) er logger.SetOutput(f) } logging.Bind(logger) - cfg := appconfig.Load(logger) + loadOpts := appconfig.LoadOptions{ConfigPath: configPath} + cfg := appconfig.LoadWithOptions(logger, loadOpts) if err := cfg.Validate(); err != nil { logger.Fatalf("invalid config: %v", err) } if cfg.StatsWindowMinutes > 0 { stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) } - return RunWithFactory(logPath, stdin, stdout, logger, cfg, nil, nil) + return RunWithFactory(logPath, configPath, stdin, stdout, logger, cfg, nil, nil) } // RunWithFactory is the testable entrypoint. When client is nil, it is built from cfg+env. // When factory is nil, lsp.NewServer is used. -func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error { +func RunWithFactory(logPath string, configPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error { normalizeLoggingConfig(&cfg) if err := cfg.Validate(); err != nil { logger.Fatalf("invalid config: %v", err) @@ -58,7 +64,9 @@ func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *l store := runtimeconfig.New(cfg) logContext := strings.TrimSpace(logPath) != "" + loadOpts := appconfig.LoadOptions{ConfigPath: strings.TrimSpace(configPath)} opts := makeServerOptions(cfg, logContext, client) + opts.ConfigLoadOptions = loadOpts opts.ConfigStore = store server := factory(stdin, stdout, logger, opts) if configurable, ok := server.(interface{ ApplyOptions(lsp.ServerOptions) }); ok { @@ -72,6 +80,7 @@ func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *l client = newClient } opts := makeServerOptions(updated, logContext, client) + opts.ConfigLoadOptions = loadOpts opts.ConfigStore = store configurable.ApplyOptions(opts) }) diff --git a/internal/hexailsp/run_more_test.go b/internal/hexailsp/run_more_test.go index faaae41..338dd48 100644 --- a/internal/hexailsp/run_more_test.go +++ b/internal/hexailsp/run_more_test.go @@ -44,7 +44,7 @@ func TestRunWithFactory_BuildsOptionsAndClient(t *testing.T) { cfg.MaxTokens = 123 cfg.PromptCodeActionRewriteSystem = "RSYS" cfg.PromptCodeActionRewriteUser = "RUSER" - if err := RunWithFactory("", &in, &out, logger, cfg, nil, factory); err != nil { + if err := RunWithFactory("", "", &in, &out, logger, cfg, nil, factory); err != nil { t.Fatalf("RunWithFactory error: %v", err) } if captured.MaxTokens != 123 { @@ -71,7 +71,7 @@ func TestRunWithFactory_SubscriptionAppliesUpdates(t *testing.T) { cfg := appconfig.Load(nil) cfg.StatsWindowMinutes = 0 cfg.ContextMode = " WINDOW " - if err := RunWithFactory("", &in, &out, logger, cfg, stubClient{}, factory); err != nil { + if err := RunWithFactory("", "", &in, &out, logger, cfg, stubClient{}, factory); err != nil { t.Fatalf("RunWithFactory error: %v", err) } if capturedStore == nil { diff --git a/internal/hexailsp/run_test.go b/internal/hexailsp/run_test.go index 340a08a..6a3c789 100644 --- a/internal/hexailsp/run_test.go +++ b/internal/hexailsp/run_test.go @@ -36,7 +36,7 @@ func TestRunWithFactory_UsesDefaultsAndCallsServer(t *testing.T) { gotOpts = opts return &fakeServer{opts: opts} } - if err := RunWithFactory("", bytes.NewBuffer(nil), bytes.NewBuffer(nil), logger, cfg, nil, factory); err != nil { + if err := RunWithFactory("", "", bytes.NewBuffer(nil), bytes.NewBuffer(nil), logger, cfg, nil, factory); err != nil { t.Fatalf("RunWithFactory error: %v", err) } if gotOpts.MaxTokens != cfg.MaxTokens { @@ -71,7 +71,7 @@ func TestRunWithFactory_BuildsClientWhenKeysPresent(t *testing.T) { got = opts.Client return &fakeServer{opts: opts} } - if err := RunWithFactory("", bytes.NewBuffer(nil), bytes.NewBuffer(nil), logger, cfg, nil, factory); err != nil { + if err := RunWithFactory("", "", bytes.NewBuffer(nil), bytes.NewBuffer(nil), logger, cfg, nil, factory); err != nil { t.Fatalf("RunWithFactory error: %v", err) } if got == nil { @@ -104,7 +104,7 @@ func TestRunWithFactory_NormalizesContextMode_AndSetsPreviewLimit(t *testing.T) gotOpts = opts return &fakeServer{opts: opts} } - if err := RunWithFactory("", bytes.NewBuffer(nil), bytes.NewBuffer(nil), logger, cfg, nil, factory); err != nil { + if err := RunWithFactory("", "", bytes.NewBuffer(nil), bytes.NewBuffer(nil), logger, cfg, nil, factory); err != nil { t.Fatalf("RunWithFactory error: %v", err) } if gotOpts.ContextMode != "file-on-new-func" { @@ -130,13 +130,13 @@ func TestRunWithFactory_LogContextFlag(t *testing.T) { } return &fakeServer{opts: opts} } - if err := RunWithFactory("/tmp/some.log", bytes.NewBuffer(nil), bytes.NewBuffer(nil), logger, cfg, nil, factory); err != nil { + if err := RunWithFactory("/tmp/some.log", "", bytes.NewBuffer(nil), bytes.NewBuffer(nil), logger, cfg, nil, factory); err != nil { t.Fatalf("RunWithFactory error: %v", err) } if !got1.LogContext { t.Fatalf("expected LogContext true when logPath is non-empty") } - if err := RunWithFactory("", bytes.NewBuffer(nil), bytes.NewBuffer(nil), logger, cfg, nil, factory); err != nil { + if err := RunWithFactory("", "", bytes.NewBuffer(nil), bytes.NewBuffer(nil), logger, cfg, nil, factory); err != nil { t.Fatalf("RunWithFactory error: %v", err) } if got2.LogContext { diff --git a/internal/lsp/chat_commands.go b/internal/lsp/chat_commands.go index 89efa49..b2da7d4 100644 --- a/internal/lsp/chat_commands.go +++ b/internal/lsp/chat_commands.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/runtimeconfig" ) @@ -40,7 +39,9 @@ func (s *Server) handleReloadCommand() chatCommandResult { if s.configStore == nil { return chatCommandResult{message: "Reload unavailable: no config store"} } - changes, err := s.configStore.Reload(s.logger, appconfig.LoadOptions{IgnoreEnv: true}) + loadOpts := s.configLoadOpts + loadOpts.IgnoreEnv = true + changes, err := s.configStore.Reload(s.logger, loadOpts) if err != nil { s.logger.Printf("config reload failed: %v", err) return chatCommandResult{message: fmt.Sprintf("Reload failed: %v", err)} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index f8b328b..974b926 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -43,6 +43,7 @@ type Server struct { compCache map[string]string compCacheOrder []string // most-recent at end; cap ~10 pendingCompletions map[string][]CompletionItem + configLoadOpts appconfig.LoadOptions // Outgoing JSON-RPC id counter for server-initiated requests nextID int64 lastLLMCall time.Time @@ -53,13 +54,14 @@ type Server struct { // ServerOptions collects configuration for NewServer to avoid long parameter lists. type ServerOptions struct { - LogContext bool - ConfigStore *runtimeconfig.Store - Config *appconfig.App - MaxTokens int - ContextMode string - WindowLines int - MaxContextTokens int + LogContext bool + ConfigStore *runtimeconfig.Store + Config *appconfig.App + MaxTokens int + ContextMode string + WindowLines int + MaxContextTokens int + ConfigLoadOptions appconfig.LoadOptions Client llm.Client TriggerCharacters []string @@ -136,6 +138,7 @@ func (s *Server) applyOptions(opts ServerOptions) { s.mu.Lock() defer s.mu.Unlock() s.logContext = opts.LogContext + s.configLoadOpts = opts.ConfigLoadOptions if opts.ConfigStore != nil { s.configStore = opts.ConfigStore } |
