feat(hyperguild): brain query subcommand

Adds 'hyperguild brain query <topic>' against the brain HTTP REST
/query endpoint. Default human output prints path + score + title;
--json passes through the response envelope. --limit overrides the
default 5-result cap.

runBrainWrite remains a stub for Task 5.
This commit is contained in:
Mathias Bergqvist
2026-05-03 21:37:39 +02:00
parent ed4966927c
commit cd5f3c0175
4 changed files with 142 additions and 3 deletions

59
cmd/hyperguild/brain.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
)
func runBrain(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
if len(args) == 0 {
return errors.New("brain: subcommand required (query|write)")
}
switch args[0] {
case "query":
return runBrainQuery(ctx, args[1:], stdin, stdout, stderr)
case "write":
return runBrainWrite(ctx, args[1:], stdin, stdout, stderr)
default:
return fmt.Errorf("brain: unknown subcommand: %s (expected query|write)", args[0])
}
}
func runBrainQuery(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error {
fs := flag.NewFlagSet("brain query", flag.ContinueOnError)
fs.SetOutput(stderr)
asJSON := fs.Bool("json", false, "output JSON instead of human-readable")
limit := fs.Int("limit", 5, "maximum number of results")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parse flags: %w", err)
}
if fs.NArg() < 1 {
return errors.New("brain query: topic required")
}
topic := fs.Arg(0)
res, err := newBrainClient().Query(ctx, topic, *limit)
if err != nil {
return err
}
if *asJSON {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
return enc.Encode(res)
}
for _, hit := range res.Results {
fmt.Fprintf(stdout, "%s score=%d %s\n", hit.Path, hit.Score, hit.Title) //nolint:errcheck
}
return nil
}
// runBrainWrite is implemented in Task 5; stub now returns an explicit error
// so the router compiles and tests for runBrainQuery can run.
func runBrainWrite(_ context.Context, _ []string, _ io.Reader, _, _ io.Writer) error {
return errors.New("brain write: not implemented (Task 5)")
}

View File

@@ -0,0 +1,81 @@
package main
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func brainQueryServer(t *testing.T, body string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
}
func TestRunBrainQuery_Human(t *testing.T) {
srv := brainQueryServer(t, `{"results":[{"path":"knowledge/a.md","title":"A","excerpt":"...","score":9},{"path":"knowledge/b.md","title":"B","excerpt":"...","score":3}]}`)
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"query", "topic"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
got := out.String()
assert.Contains(t, got, "knowledge/a.md")
assert.Contains(t, got, "score=9")
assert.Contains(t, got, "knowledge/b.md")
}
func TestRunBrainQuery_JSON(t *testing.T) {
srv := brainQueryServer(t, `{"results":[{"path":"x.md","title":"X","excerpt":"e","score":5}]}`)
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"query", "--json", "topic"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Contains(t, out.String(), `"path": "x.md"`)
assert.Contains(t, out.String(), `"score": 5`)
}
func TestRunBrainQuery_Limit(t *testing.T) {
gotLimit := ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotLimit = r.URL.Query().Get("limit")
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer srv.Close()
t.Setenv("BRAIN_URL", srv.URL)
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"query", "--limit", "12", "topic"}, strings.NewReader(""), &out, &errBuf)
require.NoError(t, err)
assert.Equal(t, "12", gotLimit)
}
func TestRunBrainQuery_MissingTopic(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"query"}, strings.NewReader(""), &out, &errBuf)
assert.Error(t, err)
}
func TestRunBrain_NoSubsubcommand(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf)
assert.Error(t, err)
assert.Contains(t, err.Error(), "subcommand required")
}
func TestRunBrain_UnknownSubsubcommand(t *testing.T) {
var out, errBuf bytes.Buffer
err := runBrain(context.Background(), []string{"bogus"}, strings.NewReader(""), &out, &errBuf)
assert.Error(t, err)
}

View File

@@ -26,7 +26,7 @@ func notYet(ctx context.Context, args []string, stdin io.Reader, stdout, stderr
func subcommands() map[string]subcommand { func subcommands() map[string]subcommand {
return map[string]subcommand{ return map[string]subcommand{
"tier": runTier, "tier": runTier,
"brain": notYet, "brain": runBrain,
"mode": notYet, "mode": notYet,
} }
} }

View File

@@ -35,8 +35,7 @@ func TestDispatch_UnknownSubcommand_ReturnsTwo(t *testing.T) {
func TestDispatch_KnownSubcommand_RoutesAndReturnsExitCode(t *testing.T) { func TestDispatch_KnownSubcommand_RoutesAndReturnsExitCode(t *testing.T) {
var out, errBuf bytes.Buffer var out, errBuf bytes.Buffer
code := dispatch(context.Background(), []string{"brain"}, strings.NewReader(""), &out, &errBuf) code := dispatch(context.Background(), []string{"mode"}, strings.NewReader(""), &out, &errBuf)
// At this point, brain still returns the not-implemented error → exit 1.
assert.Equal(t, 1, code) assert.Equal(t, 1, code)
assert.Contains(t, errBuf.String(), "not implemented") assert.Contains(t, errBuf.String(), "not implemented")
} }