summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-28 17:30:44 +0300
committerPaul Buetow <paul@buetow.org>2025-09-28 17:30:44 +0300
commit0761409497041c752086b9aded08cf9e32e30fd2 (patch)
treee62721bc119d4ae435d2609292faea06a68244a4
parent0ac2d186e84f77d73d924e2c0ce975a17c3a8078 (diff)
Add --config flag support across CLI, LSP, and tmux tools
-rw-r--r--cmd/hexai-lsp/main.go5
-rw-r--r--cmd/hexai-tmux-action/main.go8
-rw-r--r--cmd/hexai/main.go36
-rw-r--r--internal/appconfig/config.go21
-rw-r--r--internal/hexaiaction/run.go22
-rw-r--r--internal/hexaicli/run.go26
-rw-r--r--internal/hexailsp/run.go15
-rw-r--r--internal/hexailsp/run_more_test.go4
-rw-r--r--internal/hexailsp/run_test.go10
-rw-r--r--internal/lsp/chat_commands.go5
-rw-r--r--internal/lsp/server.go17
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
}