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 | |
| parent | 0ac2d186e84f77d73d924e2c0ce975a17c3a8078 (diff) | |
Add --config flag support across CLI, LSP, and tmux tools
| -rw-r--r-- | cmd/hexai-lsp/main.go | 5 | ||||
| -rw-r--r-- | cmd/hexai-tmux-action/main.go | 8 | ||||
| -rw-r--r-- | cmd/hexai/main.go | 36 | ||||
| -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 |
11 files changed, 135 insertions, 34 deletions
diff --git a/cmd/hexai-lsp/main.go b/cmd/hexai-lsp/main.go index f2cad6d..9764f0b 100644 --- a/cmd/hexai-lsp/main.go +++ b/cmd/hexai-lsp/main.go @@ -5,6 +5,7 @@ import ( "flag" "log" "os" + "strings" "codeberg.org/snonux/hexai/internal" "codeberg.org/snonux/hexai/internal/hexailsp" @@ -12,6 +13,7 @@ import ( func main() { logPath := flag.String("log", "/tmp/hexai-lsp.log", "path to log file (optional)") + configPath := flag.String("config", "", "path to config file") showVersion := flag.Bool("version", false, "print version and exit") flag.Parse() if *showVersion { @@ -19,7 +21,8 @@ func main() { return } - if err := hexailsp.Run(*logPath, os.Stdin, os.Stdout, os.Stderr); err != nil { + path := strings.TrimSpace(*configPath) + if err := hexailsp.RunWithConfig(*logPath, path, os.Stdin, os.Stdout, os.Stderr); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/cmd/hexai-tmux-action/main.go b/cmd/hexai-tmux-action/main.go index 2d8793b..3b066cc 100644 --- a/cmd/hexai-tmux-action/main.go +++ b/cmd/hexai-tmux-action/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "os" + "strings" "codeberg.org/snonux/hexai/internal/hexaiaction" ) @@ -13,6 +14,7 @@ func main() { infile := flag.String("infile", "", "Read input from this file instead of stdin") outfile := flag.String("outfile", "", "Write output to this file instead of stdout") uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically") + configPath := flag.String("config", "", "path to config file") tmuxTarget := flag.String("tmux-target", "", "tmux split target (advanced)") tmuxSplit := flag.String("tmux-split", "v", "tmux split orientation: v or h") tmuxPercent := flag.Int("tmux-percent", 33, "tmux split size percentage (1-100)") @@ -22,7 +24,11 @@ func main() { Infile: *infile, Outfile: *outfile, UIChild: *uiChild, TmuxTarget: *tmuxTarget, TmuxSplit: *tmuxSplit, TmuxPercent: *tmuxPercent, } - if err := hexaiaction.RunCommand(context.Background(), opts, os.Stdin, os.Stdout, os.Stderr); err != nil { + ctx := context.Background() + if path := strings.TrimSpace(*configPath); path != "" { + ctx = hexaiaction.WithConfigPath(ctx, path) + } + if err := hexaiaction.RunCommand(ctx, opts, os.Stdin, os.Stdout, os.Stderr); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/cmd/hexai/main.go b/cmd/hexai/main.go index 4c4fbd2..a6fc1a6 100644 --- a/cmd/hexai/main.go +++ b/cmd/hexai/main.go @@ -17,13 +17,15 @@ import ( ) func main() { + configPath, remaining := splitConfigPath(os.Args[1:]) logger := log.New(io.Discard, "", 0) - cfg := appconfig.Load(logger) + cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPath}) cliEntries := cfg.CLIConfigs if len(cliEntries) == 0 { cliEntries = []appconfig.SurfaceConfig{{Provider: cfg.Provider}} } fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + configFlag := fs.String("config", configPath, "path to config file") showVersion := fs.Bool("version", false, "print version and exit") selectedFlags := make([]bool, len(cliEntries)) for i, entry := range cliEntries { @@ -39,7 +41,7 @@ func main() { desc := fmt.Sprintf("use only provider #%d (%s:%s)", i, provider, model) fs.BoolVar(&selectedFlags[i], name, false, desc) } - _ = fs.Parse(os.Args[1:]) + _ = fs.Parse(remaining) if *showVersion { fmt.Fprintln(os.Stdout, internal.Version) return @@ -54,11 +56,41 @@ func main() { if len(selection) > 0 { ctx = hexaicli.WithCLISelection(ctx, selection) } + if path := strings.TrimSpace(*configFlag); path != "" { + ctx = hexaicli.WithCLIConfigPath(ctx, path) + } if err := hexaicli.Run(ctx, fs.Args(), os.Stdin, os.Stdout, os.Stderr); err != nil { os.Exit(1) } } +func splitConfigPath(args []string) (string, []string) { + var path string + rest := make([]string, 0, len(args)) + skip := false + for i := 0; i < len(args); i++ { + if skip { + skip = false + continue + } + arg := args[i] + switch { + case arg == "--config" || arg == "-config": + if i+1 < len(args) { + path = args[i+1] + skip = true + } + case strings.HasPrefix(arg, "--config="): + path = arg[len("--config="):] + case strings.HasPrefix(arg, "-config="): + path = arg[len("-config="):] + default: + rest = append(rest, arg) + } + } + return strings.TrimSpace(path), rest +} + func pickDefaultModel(cfg appconfig.App, provider string) string { switch strings.ToLower(strings.TrimSpace(provider)) { case "ollama": 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 } |
