diff options
Diffstat (limited to 'internal/hexaicli/runner.go')
| -rw-r--r-- | internal/hexaicli/runner.go | 161 |
1 files changed, 161 insertions, 0 deletions
diff --git a/internal/hexaicli/runner.go b/internal/hexaicli/runner.go new file mode 100644 index 0000000..f372021 --- /dev/null +++ b/internal/hexaicli/runner.go @@ -0,0 +1,161 @@ +package hexaicli + +import ( + "context" + "fmt" + "io" + "log" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/stats" + "codeberg.org/snonux/hexai/internal/tmux" +) + +type cliConfigLoader func(context.Context, *log.Logger) appconfig.App + +type cliEditorOpener func([]byte) (string, error) + +type cliClientFactory func(appconfig.App) (llm.Client, error) + +type cliStatusSink interface { + SetLLMStart(provider, model string) error + SetGlobal(snapshot stats.Snapshot, provider, model string, scopeRPM float64, scopeReq int64) error +} + +// Runner executes the CLI with injectable configuration, editor, client, and status dependencies. +type Runner struct { + loadConfig cliConfigLoader + openEditor cliEditorOpener + newClient cliClientFactory + statusSink cliStatusSink +} + +type tmuxCLIStatusSink struct{} + +func (tmuxCLIStatusSink) SetLLMStart(provider, model string) error { + return tmux.SetStatus(tmux.FormatLLMStartStatus(provider, model)) +} + +func (tmuxCLIStatusSink) SetGlobal(snapshot stats.Snapshot, provider, model string, scopeRPM float64, scopeReq int64) error { + return tmux.SetStatus(tmux.FormatGlobalStatusColored( + snapshot.Global.Reqs, + snapshot.RPM, + snapshot.Global.Sent, + snapshot.Global.Recv, + provider, + model, + scopeRPM, + scopeReq, + snapshot.Window, + )) +} + +// NewRunner builds a CLI runner with production dependencies. +func NewRunner() *Runner { + return &Runner{ + loadConfig: loadConfigFromContext, + openEditor: editor.OpenTempAndEdit, + newClient: newClientFromApp, + statusSink: tmuxCLIStatusSink{}, + } +} + +func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + runner := normalizeRunner(r) + if spec, ok, err := tpsSimulationFromContext(ctx); err != nil { + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) + return err + } else if ok { + input, inputErr := readSimulationInput(stdin, args) + if inputErr != nil { + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+inputErr.Error()+logging.AnsiReset) + return inputErr + } + return runTPSSimulation(ctx, spec, input, stdout) + } + + logger := log.New(io.Discard, "", 0) + cfg := runner.loadConfig(ctx, logger) + if cfg.StatsWindowMinutes > 0 { + stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute) + } + jobs, err := buildCLIJobs(cfg) + if err != nil { + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) + return err + } + if selected := selectionFromContext(ctx); len(selected) > 0 { + jobs, err = filterJobsBySelection(jobs, selected) + if err != nil { + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: %v"+logging.AnsiReset+"\n", err) + return err + } + } + if len(jobs) == 0 { + return fmt.Errorf("hexai: no CLI providers configured") + } + + input, rerr := readInput(stdin, args) + if rerr != nil && len(args) == 0 { + if prompt, eerr := runner.openEditor(nil); eerr == nil && strings.TrimSpace(prompt) != "" { + args = []string{prompt} + input, rerr = readInput(stdin, args) + } + } + if rerr != nil { + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) + return rerr + } + msgs := buildMessagesFromConfig(cfg, input) + if err := runCLIJobs(ctx, jobs, msgs, input, stdout, stderr, runner.newClient, runner.statusSink); err != nil { + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + return err + } + return nil +} + +func (r *Runner) RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error { + runner := normalizeRunner(r) + input, err := readInput(stdin, args) + if err != nil { + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) + return err + } + req := requestArgs{model: strings.TrimSpace(client.DefaultModel())} + printProviderInfo(stderr, client, req.model) + msgs := buildMessages(input) + if err := runChatWithStatus(runner.statusSink, ctx, client, req, msgs, input, stdout, stderr); err != nil { + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + return err + } + return nil +} + +func normalizeRunner(r *Runner) Runner { + if r == nil { + return *NewRunner() + } + runner := *r + if runner.loadConfig == nil { + runner.loadConfig = loadConfigFromContext + } + if runner.openEditor == nil { + runner.openEditor = editor.OpenTempAndEdit + } + if runner.newClient == nil { + runner.newClient = newClientFromApp + } + if runner.statusSink == nil { + runner.statusSink = tmuxCLIStatusSink{} + } + return runner +} + +func loadConfigFromContext(ctx context.Context, logger *log.Logger) appconfig.App { + return appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPathFromContext(ctx)}) +} |
