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:
59
cmd/hyperguild/brain.go
Normal file
59
cmd/hyperguild/brain.go
Normal 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)")
|
||||||
|
}
|
||||||
81
cmd/hyperguild/brain_test.go
Normal file
81
cmd/hyperguild/brain_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user