summaryrefslogtreecommitdiff
path: root/internal/hexaicli
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-17 18:52:51 +0300
committerPaul Buetow <paul@buetow.org>2025-08-17 18:52:51 +0300
commit454451105ad3522d2ac3d22136eedee4a4d034af (patch)
treeaa4b5723809c4d45bfc9094a38c01c6415582f9c /internal/hexaicli
parent498923e77c201ca90dc35c7934f4f7f1c9c3ccd2 (diff)
cli+lsp: refactor main packages into internal runners; add tests
- Move CLI logic to internal/hexaicli with Run/RunWithClient - Move LSP logic to internal/hexailsp with Run/RunWithFactory - Extract helpers; keep behavior identical for both binaries - Add unit tests for hexaicli (input parsing, messages, streaming) and hexailsp (factory wiring, client creation, logging settings) - Add top-of-file summaries and 'Not yet reviewed by a human' comments to all Go files - Update README with internal package docs
Diffstat (limited to 'internal/hexaicli')
-rw-r--r--internal/hexaicli/run.go129
-rw-r--r--internal/hexaicli/run_test.go194
-rw-r--r--internal/hexaicli/testhelpers_test.go63
3 files changed, 386 insertions, 0 deletions
diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go
new file mode 100644
index 0000000..018b5d2
--- /dev/null
+++ b/internal/hexaicli/run.go
@@ -0,0 +1,129 @@
+// Summary: Hexai CLI runner; reads input, creates an LLM client, builds messages,
+// streams or collects the model output, and prints a short summary to stderr.
+// Not yet reviewed by a human
+package hexaicli
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "hexai/internal/appconfig"
+ "hexai/internal/llm"
+ "hexai/internal/logging"
+)
+
+// Run executes the Hexai CLI behavior given arguments and I/O streams.
+// It assumes flags have already been parsed by the caller.
+func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
+ cfg := appconfig.Load(nil)
+ client, err := newClientFromConfig(cfg)
+ if err != nil {
+ fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err)
+ return err
+ }
+
+ return RunWithClient(ctx, args, stdin, stdout, stderr, client)
+}
+
+// RunWithClient executes the CLI flow using an already-constructed client.
+// Useful for testing and embedding.
+func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error {
+ input, err := readInput(stdin, args)
+ if err != nil {
+ fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset)
+ return err
+ }
+ printProviderInfo(stderr, client)
+ msgs := buildMessages(input)
+ if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil {
+ fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err)
+ return err
+ }
+ return nil
+}
+
+// readInput reads from stdin and args, then combines them per CLI rules.
+func readInput(stdin io.Reader, args []string) (string, error) {
+ var stdinData string
+ if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 {
+ b, _ := io.ReadAll(bufio.NewReader(stdin))
+ stdinData = strings.TrimSpace(string(b))
+ }
+ argData := strings.TrimSpace(strings.Join(args, " "))
+ switch {
+ case stdinData != "" && argData != "":
+ return fmt.Sprintf("%s:\n\n%s", argData, stdinData), nil
+ case stdinData != "":
+ return stdinData, nil
+ case argData != "":
+ return argData, nil
+ default:
+ return "", fmt.Errorf("hexai: no input provided; pass text as an argument or via stdin")
+ }
+}
+
+// newClientFromConfig builds an LLM client from the app config and env keys.
+func newClientFromConfig(cfg appconfig.App) (llm.Client, error) {
+ llmCfg := llm.Config{
+ Provider: cfg.Provider,
+ OpenAIBaseURL: cfg.OpenAIBaseURL,
+ OpenAIModel: cfg.OpenAIModel,
+ OllamaBaseURL: cfg.OllamaBaseURL,
+ OllamaModel: cfg.OllamaModel,
+ CopilotBaseURL: cfg.CopilotBaseURL,
+ CopilotModel: cfg.CopilotModel,
+ }
+ oaKey := os.Getenv("OPENAI_API_KEY")
+ cpKey := os.Getenv("COPILOT_API_KEY")
+ return llm.NewFromConfig(llmCfg, oaKey, cpKey)
+}
+
+// buildMessages creates system and user messages based on input content.
+func buildMessages(input string) []llm.Message {
+ lower := strings.ToLower(input)
+ system := "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation."
+ if strings.Contains(lower, "explain") {
+ system = "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context."
+ }
+ return []llm.Message{
+ {Role: "system", Content: system},
+ {Role: "user", Content: input},
+ }
+}
+
+// runChat executes the chat request, handling streaming and summary output.
+func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error {
+ start := time.Now()
+ var output string
+ if s, ok := client.(llm.Streamer); ok {
+ var b strings.Builder
+ if err := s.ChatStream(ctx, msgs, func(chunk string) {
+ b.WriteString(chunk)
+ fmt.Fprint(out, chunk)
+ }); err != nil {
+ return err
+ }
+ output = b.String()
+ } else {
+ txt, err := client.Chat(ctx, msgs)
+ if err != nil {
+ return err
+ }
+ output = txt
+ fmt.Fprint(out, output)
+ }
+ dur := time.Since(start)
+ fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d"+logging.AnsiReset+"\n",
+ client.Name(), client.DefaultModel(), dur.Round(time.Millisecond), len(input), len(output))
+ return nil
+}
+
+// printProviderInfo writes the provider/model line to stderr.
+func printProviderInfo(errw io.Writer, client llm.Client) {
+ fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel())
+}
diff --git a/internal/hexaicli/run_test.go b/internal/hexaicli/run_test.go
new file mode 100644
index 0000000..f9c8443
--- /dev/null
+++ b/internal/hexaicli/run_test.go
@@ -0,0 +1,194 @@
+// Summary: Unit tests for Hexai CLI helpers and run flow (input parsing, messages, streaming).
+// Not yet reviewed by a human
+package hexaicli
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+)
+
+// helpers moved to testhelpers_test.go
+
+func TestReadInput_ArgsOnly(t *testing.T) {
+ restore, f := setStdin(t, "")
+ defer restore()
+ // Pass the same file reader used for os.Stdin (empty)
+ got, err := readInput(f, []string{"hello", "world"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := "hello world"
+ if got != want {
+ t.Fatalf("want %q, got %q", want, got)
+ }
+}
+
+func TestReadInput_StdinOnly(t *testing.T) {
+ restore, f := setStdin(t, "payload")
+ defer restore()
+ got, err := readInput(f, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != "payload" {
+ t.Fatalf("want %q, got %q", "payload", got)
+ }
+}
+
+func TestReadInput_Combined(t *testing.T) {
+ restore, f := setStdin(t, "payload")
+ defer restore()
+ got, err := readInput(f, []string{"subject"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := "subject:\n\npayload"
+ if got != want {
+ t.Fatalf("want %q, got %q", want, got)
+ }
+}
+
+func TestReadInput_EmptyError(t *testing.T) {
+ restore, f := setStdin(t, "")
+ defer restore()
+ _, err := readInput(f, nil)
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "no input") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestBuildMessages_DefaultAndExplain(t *testing.T) {
+ // Default concise
+ msgs := buildMessages("list files in folder")
+ if len(msgs) != 2 {
+ t.Fatalf("expected 2 messages, got %d", len(msgs))
+ }
+ if msgs[0].Role != "system" || msgs[1].Role != "user" {
+ t.Fatalf("unexpected roles: %+v", msgs)
+ }
+ if !strings.Contains(msgs[0].Content, "very short, concise answers") {
+ t.Fatalf("unexpected system message: %q", msgs[0].Content)
+ }
+ if msgs[1].Content != "list files in folder" {
+ t.Fatalf("unexpected user content: %q", msgs[1].Content)
+ }
+
+ // Verbose explain
+ msgs2 := buildMessages("please explain how this works")
+ if len(msgs2) != 2 {
+ t.Fatalf("expected 2 messages, got %d", len(msgs2))
+ }
+ if !strings.Contains(strings.ToLower(msgs2[0].Content), "requested an explanation") {
+ t.Fatalf("unexpected system message: %q", msgs2[0].Content)
+ }
+ if msgs2[1].Content != "please explain how this works" {
+ t.Fatalf("unexpected user content: %q", msgs2[1].Content)
+ }
+}
+
+func TestRunChat_NonStreaming(t *testing.T) {
+ var out bytes.Buffer
+ var errb bytes.Buffer
+ fc := fakeClient{name: "fake", model: "m", resp: "OUTPUT"}
+ if err := runChat(context.Background(), &fc, nil, "input", &out, &errb); err != nil {
+ t.Fatalf("runChat error: %v", err)
+ }
+ if out.String() != "OUTPUT" {
+ t.Fatalf("stdout want %q, got %q", "OUTPUT", out.String())
+ }
+ es := errb.String()
+ if !strings.Contains(es, "done provider=fake model=m") {
+ t.Fatalf("stderr missing provider/model: %q", es)
+ }
+ if !strings.Contains(es, "in_bytes=5") || !strings.Contains(es, "out_bytes=6") {
+ t.Fatalf("stderr missing byte counts: %q", es)
+ }
+}
+
+func TestRunChat_Streaming(t *testing.T) {
+ var out bytes.Buffer
+ var errb bytes.Buffer
+ fs := fakeStreamer{fakeClient: fakeClient{name: "fake", model: "m"}, chunks: []string{"OUT", "PUT"}}
+ if err := runChat(context.Background(), &fs, nil, "input", &out, &errb); err != nil {
+ t.Fatalf("runChat error: %v", err)
+ }
+ if out.String() != "OUTPUT" {
+ t.Fatalf("stdout want %q, got %q", "OUTPUT", out.String())
+ }
+ es := errb.String()
+ if !strings.Contains(es, "done provider=fake model=m") {
+ t.Fatalf("stderr missing provider/model: %q", es)
+ }
+ if !strings.Contains(es, "in_bytes=5") || !strings.Contains(es, "out_bytes=6") {
+ t.Fatalf("stderr missing byte counts: %q", es)
+ }
+}
+
+func TestPrintProviderInfo(t *testing.T) {
+ var b bytes.Buffer
+ fc := fakeClient{name: "fake", model: "m"}
+ printProviderInfo(&b, &fc)
+ s := b.String()
+ if !strings.Contains(s, "provider=fake model=m") {
+ t.Fatalf("unexpected banner: %q", s)
+ }
+}
+
+func TestRunWithClient_NonStreaming(t *testing.T) {
+ restore, f := setStdin(t, "")
+ defer restore()
+ var out bytes.Buffer
+ var errb bytes.Buffer
+ fc := fakeClient{name: "fake", model: "m", resp: "OK"}
+ if err := RunWithClient(context.Background(), []string{"ask"}, f, &out, &errb, &fc); err != nil {
+ t.Fatalf("RunWithClient error: %v", err)
+ }
+ if out.String() != "OK" {
+ t.Fatalf("stdout want %q, got %q", "OK", out.String())
+ }
+ if !strings.Contains(errb.String(), "provider=fake model=m") {
+ t.Fatalf("missing banner: %q", errb.String())
+ }
+}
+
+func TestRunWithClient_Streaming(t *testing.T) {
+ restore, f := setStdin(t, "")
+ defer restore()
+ var out bytes.Buffer
+ var errb bytes.Buffer
+ fs := fakeStreamer{fakeClient: fakeClient{name: "fake", model: "m"}, chunks: []string{"A", "B"}}
+ if err := RunWithClient(context.Background(), []string{"ask"}, f, &out, &errb, &fs); err != nil {
+ t.Fatalf("RunWithClient error: %v", err)
+ }
+ if out.String() != "AB" {
+ t.Fatalf("stdout want %q, got %q", "AB", out.String())
+ }
+ if !strings.Contains(errb.String(), "provider=fake model=m") {
+ t.Fatalf("missing banner: %q", errb.String())
+ }
+}
+
+func TestRunWithClient_CombinedInput_UsesCombinedMessage(t *testing.T) {
+ restore, f := setStdin(t, "payload")
+ defer restore()
+ var out bytes.Buffer
+ var errb bytes.Buffer
+ fc := fakeClient{name: "fake", model: "m", resp: "OK"}
+ if err := RunWithClient(context.Background(), []string{"subject"}, f, &out, &errb, &fc); err != nil {
+ t.Fatalf("RunWithClient error: %v", err)
+ }
+ if out.String() != "OK" {
+ t.Fatalf("stdout want %q, got %q", "OK", out.String())
+ }
+ if len(fc.gotMsgs) != 2 {
+ t.Fatalf("expected 2 messages, got %d", len(fc.gotMsgs))
+ }
+ if fc.gotMsgs[1].Content != "subject:\n\npayload" {
+ t.Fatalf("unexpected user message: %q", fc.gotMsgs[1].Content)
+ }
+}
diff --git a/internal/hexaicli/testhelpers_test.go b/internal/hexaicli/testhelpers_test.go
new file mode 100644
index 0000000..4a25ff1
--- /dev/null
+++ b/internal/hexaicli/testhelpers_test.go
@@ -0,0 +1,63 @@
+// Summary: Test helpers for Hexai CLI tests (stdin swapping and fake LLM clients/streamers).
+// Not yet reviewed by a human
+package hexaicli
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "hexai/internal/llm"
+)
+
+// setStdin sets os.Stdin from a string and returns a restore func and reader.
+func setStdin(t *testing.T, content string) (func(), *os.File) {
+ t.Helper()
+ tmpDir := t.TempDir()
+ fpath := filepath.Join(tmpDir, "stdin.txt")
+ if err := os.WriteFile(fpath, []byte(content), 0o600); err != nil {
+ t.Fatalf("write temp stdin: %v", err)
+ }
+ f, err := os.Open(fpath)
+ if err != nil {
+ t.Fatalf("open temp stdin: %v", err)
+ }
+ old := os.Stdin
+ os.Stdin = f
+ restore := func() {
+ f.Close()
+ os.Stdin = old
+ }
+ return restore, f
+}
+
+// fakeClient implements llm.Client for tests.
+type fakeClient struct {
+ name string
+ model string
+ resp string
+ gotMsgs []llm.Message
+}
+
+func (f *fakeClient) Chat(ctx context.Context, messages []llm.Message, opts ...llm.RequestOption) (string, error) {
+ f.gotMsgs = append([]llm.Message{}, messages...)
+ return f.resp, nil
+}
+func (f fakeClient) Name() string { return f.name }
+func (f fakeClient) DefaultModel() string { return f.model }
+
+// fakeStreamer implements llm.Streamer over fakeClient.
+type fakeStreamer struct {
+ fakeClient
+ chunks []string
+ sMsgs []llm.Message
+}
+
+func (s *fakeStreamer) ChatStream(ctx context.Context, messages []llm.Message, onDelta func(string), opts ...llm.RequestOption) error {
+ s.sMsgs = append([]llm.Message{}, messages...)
+ for _, c := range s.chunks {
+ onDelta(c)
+ }
+ return nil
+}