From 5c88eff46f6853f0bed7a9a4e5c4d6c07dd02e6e Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 21:21:08 +0200 Subject: [PATCH 1/9] feat(hyperguild): subcommand router skeleton Lays down the cmd/hyperguild/ entry point. Defines the subcommand contract (ctx, args, stdin, stdout, stderr) error, the dispatch() function that's testable without os.Exit, and stubs for tier / brain / mode that return errNotImplemented. Subsequent commits replace each stub. Part of Plan 4 (hyperguild CLI) of the hyperguild migration. --- cmd/hyperguild/main.go | 79 +++++++++++++++++++++++++++++++++++++ cmd/hyperguild/main_test.go | 42 ++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 cmd/hyperguild/main.go create mode 100644 cmd/hyperguild/main_test.go diff --git a/cmd/hyperguild/main.go b/cmd/hyperguild/main.go new file mode 100644 index 0000000..222b7cf --- /dev/null +++ b/cmd/hyperguild/main.go @@ -0,0 +1,79 @@ +// Package main implements the hyperguild CLI: tier probe, brain HTTP REST +// access, and .mcp.json mode bootstrap. See docs/superpowers/specs/. +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" +) + +// subcommand is the contract every hyperguild subcommand satisfies. +// Functions take an explicit context, args (without the subcommand name +// itself), and explicit IO so tests can exercise full flows without +// touching os.Stdin / os.Stdout / os.Exit. +type subcommand func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error + +// errNotImplemented is returned by stub subcommands until their task lands. +var errNotImplemented = errors.New("not implemented") + +func notYet(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + return errNotImplemented +} + +func subcommands() map[string]subcommand { + return map[string]subcommand{ + "tier": notYet, + "brain": notYet, + "mode": notYet, + } +} + +const usage = `Usage: hyperguild [options] + +Subcommands: + tier Probe Anthropic + LiteLLM, print current operating tier. + brain query BM25 search the brain (HTTP REST). + brain write + Write stdin as a knowledge entry of type , slug . + mode Bootstrap .mcp.json for a chosen mode: + cloud | client-local | sovereign + +Environment: + BRAIN_URL Brain HTTP REST + MCP base URL. + Default: http://koala:30330 + ANTHROPIC_PROBE_URL Tier probe URL for the Anthropic API. + Default: https://api.anthropic.com + LITELLM_BASE_URL Tier probe URL for the LiteLLM gateway. + Required for tier probe; no default. +` + +// dispatch routes args to a subcommand and returns the process exit code. +// Split from main() so tests can drive it without process exit. +func dispatch(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprint(stderr, usage) //nolint:errcheck + return 2 + } + switch args[0] { + case "-h", "--help", "help": + fmt.Fprint(stdout, usage) //nolint:errcheck + return 0 + } + cmd, ok := subcommands()[args[0]] + if !ok { + fmt.Fprintf(stderr, "hyperguild: unknown subcommand: %s\n%s", args[0], usage) //nolint:errcheck + return 2 + } + if err := cmd(ctx, args[1:], stdin, stdout, stderr); err != nil { + fmt.Fprintf(stderr, "hyperguild %s: %v\n", args[0], err) //nolint:errcheck + return 1 + } + return 0 +} + +func main() { + os.Exit(dispatch(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} diff --git a/cmd/hyperguild/main_test.go b/cmd/hyperguild/main_test.go new file mode 100644 index 0000000..7c91bd0 --- /dev/null +++ b/cmd/hyperguild/main_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDispatch_Help_PrintsUsageAndReturnsZero(t *testing.T) { + var out, errBuf bytes.Buffer + code := dispatch(context.Background(), []string{"--help"}, strings.NewReader(""), &out, &errBuf) + assert.Equal(t, 0, code) + assert.Contains(t, out.String(), "Usage: hyperguild") + assert.Contains(t, out.String(), "tier") + assert.Contains(t, out.String(), "brain") + assert.Contains(t, out.String(), "mode") +} + +func TestDispatch_NoArgs_PrintsUsageAndReturnsTwo(t *testing.T) { + var out, errBuf bytes.Buffer + code := dispatch(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf) + assert.Equal(t, 2, code) + assert.Contains(t, errBuf.String(), "Usage: hyperguild") +} + +func TestDispatch_UnknownSubcommand_ReturnsTwo(t *testing.T) { + var out, errBuf bytes.Buffer + code := dispatch(context.Background(), []string{"bogus"}, strings.NewReader(""), &out, &errBuf) + assert.Equal(t, 2, code) + assert.Contains(t, errBuf.String(), "unknown subcommand: bogus") +} + +func TestDispatch_KnownSubcommand_RoutesAndReturnsExitCode(t *testing.T) { + var out, errBuf bytes.Buffer + code := dispatch(context.Background(), []string{"tier"}, strings.NewReader(""), &out, &errBuf) + // At Task 1 time, tier returns the not-implemented error → exit 1. + assert.Equal(t, 1, code) + assert.Contains(t, errBuf.String(), "not implemented") +} From 3c4e8e8bb830491cc63e0f3277e4f6e63aae8a7c Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 21:27:33 +0200 Subject: [PATCH 2/9] feat(hyperguild): tier subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the tier subcommand to the hyperguild CLI. Reuses internal/tier.Detect verbatim, sources probe URLs from ANTHROPIC_PROBE_URL (default https://api.anthropic.com) and LITELLM_BASE_URL (no default — empty triggers airplane). Human-readable output by default; --json emits the same Info struct as the supervisor's tier MCP returns. Tests cover all three tier states via httptest fakes. --- cmd/hyperguild/main.go | 2 +- cmd/hyperguild/main_test.go | 4 +- cmd/hyperguild/tier.go | 42 ++++++++++++++++++++ cmd/hyperguild/tier_test.go | 77 +++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 cmd/hyperguild/tier.go create mode 100644 cmd/hyperguild/tier_test.go diff --git a/cmd/hyperguild/main.go b/cmd/hyperguild/main.go index 222b7cf..cca51f4 100644 --- a/cmd/hyperguild/main.go +++ b/cmd/hyperguild/main.go @@ -25,7 +25,7 @@ func notYet(ctx context.Context, args []string, stdin io.Reader, stdout, stderr func subcommands() map[string]subcommand { return map[string]subcommand{ - "tier": notYet, + "tier": runTier, "brain": notYet, "mode": notYet, } diff --git a/cmd/hyperguild/main_test.go b/cmd/hyperguild/main_test.go index 7c91bd0..6959f20 100644 --- a/cmd/hyperguild/main_test.go +++ b/cmd/hyperguild/main_test.go @@ -35,8 +35,8 @@ func TestDispatch_UnknownSubcommand_ReturnsTwo(t *testing.T) { func TestDispatch_KnownSubcommand_RoutesAndReturnsExitCode(t *testing.T) { var out, errBuf bytes.Buffer - code := dispatch(context.Background(), []string{"tier"}, strings.NewReader(""), &out, &errBuf) - // At Task 1 time, tier returns the not-implemented error → exit 1. + code := dispatch(context.Background(), []string{"brain"}, strings.NewReader(""), &out, &errBuf) + // At this point, brain still returns the not-implemented error → exit 1. assert.Equal(t, 1, code) assert.Contains(t, errBuf.String(), "not implemented") } diff --git a/cmd/hyperguild/tier.go b/cmd/hyperguild/tier.go new file mode 100644 index 0000000..a18f573 --- /dev/null +++ b/cmd/hyperguild/tier.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/mathiasbq/supervisor/internal/tier" +) + +const defaultAnthropicProbe = "https://api.anthropic.com" + +func runTier(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("tier", flag.ContinueOnError) + fs.SetOutput(stderr) + asJSON := fs.Bool("json", false, "output JSON instead of human-readable") + if err := fs.Parse(args); err != nil { + return fmt.Errorf("parse flags: %w", err) + } + + anthropicURL := os.Getenv("ANTHROPIC_PROBE_URL") + if anthropicURL == "" { + anthropicURL = defaultAnthropicProbe + } + liteLLMURL := os.Getenv("LITELLM_BASE_URL") // empty → tier falls through to airplane + + info := tier.Detect(ctx, anthropicURL, liteLLMURL) + + if *asJSON { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + if err := enc.Encode(info); err != nil { + return fmt.Errorf("encode json: %w", err) + } + return nil + } + fmt.Fprintf(stdout, "tier %d (%s) managed_agents=%t\n", int(info.Tier), info.Label, info.ManagedAgents) //nolint:errcheck + return nil +} diff --git a/cmd/hyperguild/tier_test.go b/cmd/hyperguild/tier_test.go new file mode 100644 index 0000000..cd9e509 --- /dev/null +++ b/cmd/hyperguild/tier_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func okServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) +} + +func TestRunTier_Full_Human(t *testing.T) { + anthropic := okServer(t) + defer anthropic.Close() + litellm := okServer(t) + defer litellm.Close() + + t.Setenv("ANTHROPIC_PROBE_URL", anthropic.URL) + t.Setenv("LITELLM_BASE_URL", litellm.URL) + + var out, errBuf bytes.Buffer + err := runTier(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf) + require.NoError(t, err) + assert.Contains(t, out.String(), "tier 1") + assert.Contains(t, out.String(), "full-online") + assert.Contains(t, out.String(), "managed_agents=true") +} + +func TestRunTier_LANOnly_JSON(t *testing.T) { + litellm := okServer(t) + defer litellm.Close() + + t.Setenv("ANTHROPIC_PROBE_URL", "http://127.0.0.1:1") // unreachable + t.Setenv("LITELLM_BASE_URL", litellm.URL) + + var out, errBuf bytes.Buffer + err := runTier(context.Background(), []string{"--json"}, strings.NewReader(""), &out, &errBuf) + require.NoError(t, err) + + var got struct { + Tier int `json:"tier"` + Label string `json:"label"` + ManagedAgents bool `json:"managed_agents"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &got)) + assert.Equal(t, 2, got.Tier) + assert.Equal(t, "lan-only", got.Label) + assert.False(t, got.ManagedAgents) +} + +func TestRunTier_Airplane_NoLiteLLMBaseURL(t *testing.T) { + t.Setenv("ANTHROPIC_PROBE_URL", "http://127.0.0.1:1") + t.Setenv("LITELLM_BASE_URL", "") + + var out, errBuf bytes.Buffer + err := runTier(context.Background(), []string{}, strings.NewReader(""), &out, &errBuf) + require.NoError(t, err) + assert.Contains(t, out.String(), "tier 3") + assert.Contains(t, out.String(), "airplane") +} + +func TestRunTier_UnknownFlag_ReturnsError(t *testing.T) { + var out, errBuf bytes.Buffer + err := runTier(context.Background(), []string{"--bogus"}, strings.NewReader(""), &out, &errBuf) + assert.Error(t, err) +} From ed4966927cbb2dd4129c205a6c2e9feb18c2e7d9 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 21:32:48 +0200 Subject: [PATCH 3/9] feat(hyperguild): brain HTTP REST client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds brainClient with Query and Write methods against the brain's HTTP REST endpoints (/query, /write). Constructor reads BRAIN_URL env var, defaulting to http://koala:30330 — the Tailscale-exposed NodePort that serves both MCP and REST. Tests cover success, transport error, and non-200 cases via httptest fakes; URL override is verified via t.Setenv. --- cmd/hyperguild/http.go | 117 ++++++++++++++++++++++++++++++++++++ cmd/hyperguild/http_test.go | 88 +++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 cmd/hyperguild/http.go create mode 100644 cmd/hyperguild/http_test.go diff --git a/cmd/hyperguild/http.go b/cmd/hyperguild/http.go new file mode 100644 index 0000000..36fefda --- /dev/null +++ b/cmd/hyperguild/http.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "time" +) + +const defaultBrainURL = "http://koala:30330" + +// brainClient calls the brain HTTP REST API exposed alongside the MCP +// endpoint at the same host:port. /mcp serves MCP framing; /query and /write +// serve plain REST. We use the REST surface because the CLI is a +// shell-friendly client; MCP framing is unnecessary. +type brainClient struct { + baseURL string + http *http.Client +} + +func newBrainClient() *brainClient { + u := os.Getenv("BRAIN_URL") + if u == "" { + u = defaultBrainURL + } + return &brainClient{ + baseURL: u, + http: &http.Client{Timeout: 5 * time.Second}, + } +} + +// QueryHit mirrors a single result from the brain's /query endpoint. +type QueryHit struct { + Path string `json:"path"` + Title string `json:"title"` + Excerpt string `json:"excerpt"` + Score int `json:"score"` +} + +// QueryResult mirrors the /query response envelope. +type QueryResult struct { + Results []QueryHit `json:"results"` +} + +func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*QueryResult, error) { + q := url.Values{} + q.Set("q", topic) + q.Set("limit", strconv.Itoa(limit)) + u := c.baseURL + "/query?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("brain GET /query: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("brain GET /query: status %d: %s", resp.StatusCode, string(body)) + } + var out QueryResult + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode /query response: %w", err) + } + return &out, nil +} + +// WriteResult mirrors the /write response envelope. +type WriteResult struct { + Path string `json:"path"` +} + +func (c *brainClient) Write(ctx context.Context, kind, slug string, content io.Reader) (*WriteResult, error) { + body, err := io.ReadAll(content) + if err != nil { + return nil, fmt.Errorf("read content: %w", err) + } + payload, err := json.Marshal(struct { + Type string `json:"type"` + Slug string `json:"slug"` + Content string `json:"content"` + }{Type: kind, Slug: slug, Content: string(body)}) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + u := c.baseURL + "/write" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("brain POST /write: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("brain POST /write: status %d: %s", resp.StatusCode, string(respBody)) + } + var out WriteResult + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode /write response: %w", err) + } + return &out, nil +} diff --git a/cmd/hyperguild/http_test.go b/cmd/hyperguild/http_test.go new file mode 100644 index 0000000..c697dc6 --- /dev/null +++ b/cmd/hyperguild/http_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBrainClient_Query_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/query", r.URL.Path) + assert.Equal(t, "find-h", r.URL.Query().Get("q")) + assert.Equal(t, "3", r.URL.Query().Get("limit")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":[{"path":"knowledge/x.md","title":"x","excerpt":"...","score":7}]}`)) + })) + defer srv.Close() + + c := &brainClient{baseURL: srv.URL, http: srv.Client()} + res, err := c.Query(context.Background(), "find-h", 3) + require.NoError(t, err) + require.Len(t, res.Results, 1) + assert.Equal(t, "knowledge/x.md", res.Results[0].Path) + assert.Equal(t, 7, res.Results[0].Score) +} + +func TestBrainClient_Query_TransportError(t *testing.T) { + c := &brainClient{baseURL: "http://127.0.0.1:1", http: http.DefaultClient} + _, err := c.Query(context.Background(), "x", 5) + assert.Error(t, err) +} + +func TestBrainClient_Query_Non200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + })) + defer srv.Close() + + c := &brainClient{baseURL: srv.URL, http: srv.Client()} + _, err := c.Query(context.Background(), "x", 5) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestBrainClient_Write_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/write", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + body, _ := io.ReadAll(r.Body) + var got struct { + Type string `json:"type"` + Slug string `json:"slug"` + Content string `json:"content"` + } + require.NoError(t, json.Unmarshal(body, &got)) + assert.Equal(t, "knowledge", got.Type) + assert.Equal(t, "find-h", got.Slug) + assert.Equal(t, "# body\n", got.Content) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"path":"knowledge/find-h.md"}`)) + })) + defer srv.Close() + + c := &brainClient{baseURL: srv.URL, http: srv.Client()} + res, err := c.Write(context.Background(), "knowledge", "find-h", strings.NewReader("# body\n")) + require.NoError(t, err) + assert.Equal(t, "knowledge/find-h.md", res.Path) +} + +func TestNewBrainClient_DefaultURL(t *testing.T) { + t.Setenv("BRAIN_URL", "") + c := newBrainClient() + assert.Equal(t, "http://koala:30330", c.baseURL) +} + +func TestNewBrainClient_OverrideURL(t *testing.T) { + t.Setenv("BRAIN_URL", "http://localhost:9999") + c := newBrainClient() + assert.Equal(t, "http://localhost:9999", c.baseURL) +} From cd5f3c0175a122f971f4c29daec8ff28f5e6d260 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 21:37:39 +0200 Subject: [PATCH 4/9] feat(hyperguild): brain query subcommand Adds 'hyperguild brain query ' 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. --- cmd/hyperguild/brain.go | 59 ++++++++++++++++++++++++++ cmd/hyperguild/brain_test.go | 81 ++++++++++++++++++++++++++++++++++++ cmd/hyperguild/main.go | 2 +- cmd/hyperguild/main_test.go | 3 +- 4 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 cmd/hyperguild/brain.go create mode 100644 cmd/hyperguild/brain_test.go diff --git a/cmd/hyperguild/brain.go b/cmd/hyperguild/brain.go new file mode 100644 index 0000000..f224dc5 --- /dev/null +++ b/cmd/hyperguild/brain.go @@ -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)") +} diff --git a/cmd/hyperguild/brain_test.go b/cmd/hyperguild/brain_test.go new file mode 100644 index 0000000..4c7f063 --- /dev/null +++ b/cmd/hyperguild/brain_test.go @@ -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) +} diff --git a/cmd/hyperguild/main.go b/cmd/hyperguild/main.go index cca51f4..d4caae8 100644 --- a/cmd/hyperguild/main.go +++ b/cmd/hyperguild/main.go @@ -26,7 +26,7 @@ func notYet(ctx context.Context, args []string, stdin io.Reader, stdout, stderr func subcommands() map[string]subcommand { return map[string]subcommand{ "tier": runTier, - "brain": notYet, + "brain": runBrain, "mode": notYet, } } diff --git a/cmd/hyperguild/main_test.go b/cmd/hyperguild/main_test.go index 6959f20..594a832 100644 --- a/cmd/hyperguild/main_test.go +++ b/cmd/hyperguild/main_test.go @@ -35,8 +35,7 @@ func TestDispatch_UnknownSubcommand_ReturnsTwo(t *testing.T) { func TestDispatch_KnownSubcommand_RoutesAndReturnsExitCode(t *testing.T) { var out, errBuf bytes.Buffer - code := dispatch(context.Background(), []string{"brain"}, strings.NewReader(""), &out, &errBuf) - // At this point, brain still returns the not-implemented error → exit 1. + code := dispatch(context.Background(), []string{"mode"}, strings.NewReader(""), &out, &errBuf) assert.Equal(t, 1, code) assert.Contains(t, errBuf.String(), "not implemented") } From 8f9642df69d14877ea88d64f4c7519c11063d915 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 21:42:36 +0200 Subject: [PATCH 5/9] feat(hyperguild): brain write subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads markdown from stdin, POSTs to the brain's /write endpoint with type + slug, prints the resulting path. Pairs with 'brain query' for shell-friendly read/write access to the brain HTTP REST API. Tests cover success, missing args, backend error propagation, and empty stdin (which produces an empty content payload — the brain server's responsibility to validate). --- cmd/hyperguild/brain.go | 22 +++++++++--- cmd/hyperguild/brain_test.go | 69 ++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/cmd/hyperguild/brain.go b/cmd/hyperguild/brain.go index f224dc5..0dbf76d 100644 --- a/cmd/hyperguild/brain.go +++ b/cmd/hyperguild/brain.go @@ -52,8 +52,22 @@ func runBrainQuery(ctx context.Context, args []string, _ io.Reader, stdout, stde 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)") +func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("brain write", flag.ContinueOnError) + fs.SetOutput(stderr) + if err := fs.Parse(args); err != nil { + return fmt.Errorf("parse flags: %w", err) + } + if fs.NArg() < 2 { + return errors.New("brain write: type and slug required (e.g. brain write knowledge my-slug)") + } + kind := fs.Arg(0) + slug := fs.Arg(1) + + res, err := newBrainClient().Write(ctx, kind, slug, stdin) + if err != nil { + return err + } + fmt.Fprintln(stdout, res.Path) //nolint:errcheck + return nil } diff --git a/cmd/hyperguild/brain_test.go b/cmd/hyperguild/brain_test.go index 4c7f063..8cb3aa8 100644 --- a/cmd/hyperguild/brain_test.go +++ b/cmd/hyperguild/brain_test.go @@ -3,6 +3,8 @@ package main import ( "bytes" "context" + "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -79,3 +81,70 @@ func TestRunBrain_UnknownSubsubcommand(t *testing.T) { err := runBrain(context.Background(), []string{"bogus"}, strings.NewReader(""), &out, &errBuf) assert.Error(t, err) } + +func TestRunBrainWrite_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/write", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"path":"knowledge/test-slug.md"}`)) + })) + defer srv.Close() + t.Setenv("BRAIN_URL", srv.URL) + + var out, errBuf bytes.Buffer + err := runBrain( + context.Background(), + []string{"write", "knowledge", "test-slug"}, + strings.NewReader("# Test\n\nSome body content.\n"), + &out, &errBuf, + ) + require.NoError(t, err) + assert.Contains(t, out.String(), "knowledge/test-slug.md") +} + +func TestRunBrainWrite_MissingArgs(t *testing.T) { + var out, errBuf bytes.Buffer + err := runBrain(context.Background(), []string{"write", "knowledge"}, strings.NewReader("x"), &out, &errBuf) + assert.Error(t, err) + assert.Contains(t, err.Error(), "type and slug required") +} + +func TestRunBrainWrite_BackendError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("invalid slug")) + })) + defer srv.Close() + t.Setenv("BRAIN_URL", srv.URL) + + var out, errBuf bytes.Buffer + err := runBrain( + context.Background(), + []string{"write", "knowledge", "bad slug"}, + strings.NewReader("body"), + &out, &errBuf, + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "400") +} + +func TestRunBrainWrite_EmptyStdin(t *testing.T) { + gotLen := -1 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var p struct { + Content string `json:"content"` + } + _ = json.Unmarshal(body, &p) + gotLen = len(p.Content) + _, _ = w.Write([]byte(`{"path":"x.md"}`)) + })) + defer srv.Close() + t.Setenv("BRAIN_URL", srv.URL) + + var out, errBuf bytes.Buffer + err := runBrain(context.Background(), []string{"write", "knowledge", "empty"}, strings.NewReader(""), &out, &errBuf) + require.NoError(t, err) + assert.Equal(t, 0, gotLen, "empty stdin should produce empty content payload") +} From a0d0914a85b962e1de512cf7545c75152836b498 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 21:50:05 +0200 Subject: [PATCH 6/9] feat(hyperguild): mode subcommand 'hyperguild mode ' writes a per-mode .mcp.json template: - cloud: brain MCP only - client-local: brain + routing placeholder with _routing_pending pointer to Plan 6 - sovereign: brain only + top-level _mode_note explaining Crush is primary; .mcp.json is Claude Code fallback Default output is ./.mcp.json; --out overrides; --force overwrites. Brain URL sourced from BRAIN_URL (default http://koala:30330) so the template stays in lockstep with the user's brain host. All three subcommands now wired; notYet/errNotImplemented removed from main.go. --- cmd/hyperguild/main.go | 10 +-- cmd/hyperguild/main_test.go | 8 ++- cmd/hyperguild/mode.go | 99 +++++++++++++++++++++++++++++ cmd/hyperguild/mode_test.go | 123 ++++++++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 cmd/hyperguild/mode.go create mode 100644 cmd/hyperguild/mode_test.go diff --git a/cmd/hyperguild/main.go b/cmd/hyperguild/main.go index d4caae8..3377c6f 100644 --- a/cmd/hyperguild/main.go +++ b/cmd/hyperguild/main.go @@ -4,7 +4,6 @@ package main import ( "context" - "errors" "fmt" "io" "os" @@ -16,18 +15,11 @@ import ( // touching os.Stdin / os.Stdout / os.Exit. type subcommand func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error -// errNotImplemented is returned by stub subcommands until their task lands. -var errNotImplemented = errors.New("not implemented") - -func notYet(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { - return errNotImplemented -} - func subcommands() map[string]subcommand { return map[string]subcommand{ "tier": runTier, "brain": runBrain, - "mode": notYet, + "mode": runMode, } } diff --git a/cmd/hyperguild/main_test.go b/cmd/hyperguild/main_test.go index 594a832..c93eb1e 100644 --- a/cmd/hyperguild/main_test.go +++ b/cmd/hyperguild/main_test.go @@ -33,9 +33,13 @@ func TestDispatch_UnknownSubcommand_ReturnsTwo(t *testing.T) { assert.Contains(t, errBuf.String(), "unknown subcommand: bogus") } -func TestDispatch_KnownSubcommand_RoutesAndReturnsExitCode(t *testing.T) { +func TestDispatch_KnownSubcommand_RoutesToHandler(t *testing.T) { + // "mode" without args fails → exit 1, message on stderr. + // (Confirms dispatch reached the handler rather than printing "unknown + // subcommand: mode".) var out, errBuf bytes.Buffer code := dispatch(context.Background(), []string{"mode"}, strings.NewReader(""), &out, &errBuf) assert.Equal(t, 1, code) - assert.Contains(t, errBuf.String(), "not implemented") + assert.Contains(t, errBuf.String(), "name required") + assert.NotContains(t, errBuf.String(), "unknown subcommand") } diff --git a/cmd/hyperguild/mode.go b/cmd/hyperguild/mode.go new file mode 100644 index 0000000..f25cbcc --- /dev/null +++ b/cmd/hyperguild/mode.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" +) + +func runMode(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("mode", flag.ContinueOnError) + fs.SetOutput(stderr) + out := fs.String("out", ".mcp.json", "output file path") + force := fs.Bool("force", false, "overwrite an existing file") + // Pull the first positional (mode name) out so flags after it still parse + // with stdlib flag (which stops at the first non-flag arg). + if len(args) < 1 { + return errors.New("mode: name required (cloud|client-local|sovereign)") + } + name := args[0] + if err := fs.Parse(args[1:]); err != nil { + return fmt.Errorf("parse flags: %w", err) + } + + brainURL := os.Getenv("BRAIN_URL") + if brainURL == "" { + brainURL = defaultBrainURL + } + + var doc map[string]any + switch name { + case "cloud": + doc = modeCloud(brainURL) + case "client-local": + doc = modeClientLocal(brainURL) + case "sovereign": + doc = modeSovereign(brainURL) + default: + return fmt.Errorf("mode: unknown mode: %s (expected cloud|client-local|sovereign)", name) + } + + if !*force { + if _, err := os.Stat(*out); err == nil { + return fmt.Errorf("mode: %s exists (use --force to overwrite)", *out) + } + } + + body, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return fmt.Errorf("marshal mode doc: %w", err) + } + if err := os.WriteFile(*out, append(body, '\n'), 0o644); err != nil { + return fmt.Errorf("write %s: %w", *out, err) + } + fmt.Fprintf(stdout, "wrote %s (mode: %s)\n", *out, name) //nolint:errcheck + return nil +} + +func modeCloud(brainURL string) map[string]any { + return map[string]any{ + "mcpServers": map[string]any{ + "brain": map[string]any{ + "url": brainURL + "/mcp", + "description": "Brain MCP — knowledge query, write, ingestion, session log", + }, + }, + } +} + +func modeClientLocal(brainURL string) map[string]any { + return map[string]any{ + "mcpServers": map[string]any{ + "brain": map[string]any{ + "url": brainURL + "/mcp", + "description": "Brain MCP — knowledge query, write, ingestion, session log", + }, + "routing": map[string]any{ + "url": "http://koala:30310/mcp", + "description": "Mode 2 routing pod — routes skill calls to LiteLLM/local", + "_routing_pending": "Plan 6 — routing pod not deployed yet; this URL is a placeholder", + }, + }, + } +} + +func modeSovereign(brainURL string) map[string]any { + return map[string]any{ + "_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback (e.g. emergency offline editing).", + "mcpServers": map[string]any{ + "brain": map[string]any{ + "url": brainURL + "/mcp", + "description": "Brain MCP — knowledge query, write, ingestion, session log", + }, + }, + } +} diff --git a/cmd/hyperguild/mode_test.go b/cmd/hyperguild/mode_test.go new file mode 100644 index 0000000..43bbc5f --- /dev/null +++ b/cmd/hyperguild/mode_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + var out map[string]any + require.NoError(t, json.Unmarshal(b, &out)) + return out +} + +func TestRunMode_Cloud_Default(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + t.Setenv("BRAIN_URL", "http://koala:30330") + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"cloud", "--out", outPath}, strings.NewReader(""), &stdout, &stderr) + require.NoError(t, err) + + got := readJSON(t, outPath) + servers, ok := got["mcpServers"].(map[string]any) + require.True(t, ok, "mcpServers must be a JSON object") + assert.Contains(t, servers, "brain") + assert.NotContains(t, servers, "routing") + assert.NotContains(t, got, "_mode_note") +} + +func TestRunMode_ClientLocal_HasRoutingPlaceholder(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + t.Setenv("BRAIN_URL", "http://koala:30330") + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"client-local", "--out", outPath}, strings.NewReader(""), &stdout, &stderr) + require.NoError(t, err) + + got := readJSON(t, outPath) + servers := got["mcpServers"].(map[string]any) + require.Contains(t, servers, "brain") + require.Contains(t, servers, "routing") + + routing := servers["routing"].(map[string]any) + assert.Contains(t, routing, "_routing_pending") +} + +func TestRunMode_Sovereign_HasModeNote(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"sovereign", "--out", outPath}, strings.NewReader(""), &stdout, &stderr) + require.NoError(t, err) + + got := readJSON(t, outPath) + assert.Contains(t, got, "_mode_note") + servers := got["mcpServers"].(map[string]any) + assert.Contains(t, servers, "brain") + assert.NotContains(t, servers, "routing") +} + +func TestRunMode_DefaultsOutToCwd(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) // Go 1.24+ — replaces the older os.Chdir-with-cleanup pattern + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"cloud"}, strings.NewReader(""), &stdout, &stderr) + require.NoError(t, err) + _, statErr := os.Stat(filepath.Join(dir, ".mcp.json")) + assert.NoError(t, statErr, ".mcp.json should exist in cwd") +} + +func TestRunMode_UnknownMode(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"bogus", "--out", outPath}, strings.NewReader(""), &stdout, &stderr) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown mode") +} + +func TestRunMode_NoArgs(t *testing.T) { + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{}, strings.NewReader(""), &stdout, &stderr) + assert.Error(t, err) +} + +func TestRunMode_RefusesToOverwrite(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + require.NoError(t, os.WriteFile(outPath, []byte(`{"existing":"file"}`), 0o644)) + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"cloud", "--out", outPath}, strings.NewReader(""), &stdout, &stderr) + require.Error(t, err) + assert.Contains(t, err.Error(), "exists") +} + +func TestRunMode_Force(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + require.NoError(t, os.WriteFile(outPath, []byte(`{"existing":"file"}`), 0o644)) + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"cloud", "--out", outPath, "--force"}, strings.NewReader(""), &stdout, &stderr) + require.NoError(t, err) + got := readJSON(t, outPath) + assert.Contains(t, got, "mcpServers") + assert.NotContains(t, got, "existing") +} From eab8775f5f1e05dc239858011c414744e975eadf Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 21:56:20 +0200 Subject: [PATCH 7/9] feat(hyperguild): README + Taskfile integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds cmd/hyperguild/README.md (subcommands, env vars, install path) and three Taskfile targets: task hyperguild:dev — go run from source task hyperguild:build — build into ./bin/hyperguild task hyperguild:install — go install into $GOBIN Concludes Plan 4 of the hyperguild migration. The binary replaces the supervisor's tier MCP and surfaces brain HTTP REST access plus mode bootstrap to shell pipelines and ad-hoc agent prompts. --- Taskfile.yml | 16 ++++++ cmd/hyperguild/README.md | 110 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 cmd/hyperguild/README.md diff --git a/Taskfile.yml b/Taskfile.yml index f8bdddb..67f8657 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -39,6 +39,22 @@ tasks: cmds: - go run ./cmd/supervisor + hyperguild:dev: + desc: Run hyperguild CLI from source (e.g. task hyperguild:dev -- tier) + cmds: + - go run ./cmd/hyperguild {{.CLI_ARGS}} + + hyperguild:build: + desc: Build the hyperguild binary into ./bin/hyperguild + cmds: + - mkdir -p bin + - go build -o bin/hyperguild ./cmd/hyperguild + + hyperguild:install: + desc: Install hyperguild into $GOBIN + cmds: + - go install ./cmd/hyperguild + ingestion:dev: desc: Run ingestion server in development mode dir: ingestion diff --git a/cmd/hyperguild/README.md b/cmd/hyperguild/README.md new file mode 100644 index 0000000..a27d56d --- /dev/null +++ b/cmd/hyperguild/README.md @@ -0,0 +1,110 @@ +# hyperguild CLI + +A small Go binary for tier probing, brain HTTP REST access, and +`.mcp.json` mode bootstrap. Replaces the supervisor's `tier` MCP and +gives shell scripts a stable interface to the brain. + +## Install + +```bash +task hyperguild:install +# or: go install ./cmd/hyperguild +``` + +The binary lands at `$(go env GOBIN)/hyperguild` (typically +`~/go/bin/hyperguild`). Make sure that's on your PATH. + +## Subcommands + +### `hyperguild tier` + +Probes Anthropic and LiteLLM and reports the current operating tier. + +```bash +$ hyperguild tier +tier 1 (full-online) managed_agents=true + +$ hyperguild tier --json +{ + "tier": 1, + "label": "full-online", + "available_models": null, + "managed_agents": true +} +``` + +Probe URLs are read from environment: + +| Var | Default | +|-----------------------|-------------------------------| +| `ANTHROPIC_PROBE_URL` | `https://api.anthropic.com` | +| `LITELLM_BASE_URL` | (empty → falls through to airplane) | + +### `hyperguild brain query ` + +BM25 search over the brain's knowledge + wiki entries. + +```bash +$ hyperguild brain query "find -H symlink" +knowledge/2026-05-03-find-h-not-l-symlinked-root.md score=12 Use find -H, not find -L +... +``` + +Flags: + +- `--limit N` — max results (default 5) +- `--json` — emit the raw response envelope + +### `hyperguild brain write ` + +Reads markdown from stdin, writes a knowledge entry. + +```bash +$ cat <` + +Writes a `.mcp.json` template for the chosen operating mode. + +```bash +$ hyperguild mode cloud --out ./.mcp.json +wrote ./.mcp.json (mode: cloud) +``` + +Flags: + +- `--out PATH` — output file (default `./.mcp.json`) +- `--force` — overwrite an existing file + +Modes: + +- **cloud** — brain MCP only. Claude Code with no routing. +- **client-local** — brain + routing placeholder. The routing entry's + URL points at `koala:30310/mcp`; a `_routing_pending` field marks it + as awaiting Plan 6 of the hyperguild migration. +- **sovereign** — brain only, with a `_mode_note` explaining that this + mode primarily uses Crush + LiteLLM and the `.mcp.json` is a Claude + Code fallback for emergency offline use. + +## Environment + +| Var | Default | Used by | +|-----------------------|--------------------------|---------------------| +| `BRAIN_URL` | `http://koala:30330` | `brain *`, `mode *` | +| `ANTHROPIC_PROBE_URL` | `https://api.anthropic.com` | `tier` | +| `LITELLM_BASE_URL` | (empty) | `tier` | + +Override `BRAIN_URL` if your brain pod is at a different Tailscale name +or port. + +## See also + +- `docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md` — full spec +- `docs/superpowers/plans/2026-05-03-hyperguild-cli.md` — implementation plan From 317ec203920a995c4c811af93cfab7cff0f7201e Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 21:59:17 +0200 Subject: [PATCH 8/9] fix(hyperguild): brain Query uses POST /query with JSON body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brain HTTP REST /query endpoint accepts POST with JSON {query, limit}, not GET with URL query string. Surfaced by Task 7 smoke testing — GET returned 405 Method Not Allowed. The response shape ({results:[...]}) is unchanged; only the request side flips to POST + JSON body. brainClient.Write was already using POST + JSON body and is unaffected. Tests updated to assert POST + JSON body on the Query path. --- cmd/hyperguild/brain_test.go | 12 +++++++++--- cmd/hyperguild/http.go | 22 +++++++++++++--------- cmd/hyperguild/http_test.go | 13 +++++++++++-- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/cmd/hyperguild/brain_test.go b/cmd/hyperguild/brain_test.go index 8cb3aa8..08f687d 100644 --- a/cmd/hyperguild/brain_test.go +++ b/cmd/hyperguild/brain_test.go @@ -49,9 +49,15 @@ func TestRunBrainQuery_JSON(t *testing.T) { } func TestRunBrainQuery_Limit(t *testing.T) { - gotLimit := "" + gotLimit := -1 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotLimit = r.URL.Query().Get("limit") + body, _ := io.ReadAll(r.Body) + var p struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + _ = json.Unmarshal(body, &p) + gotLimit = p.Limit _, _ = w.Write([]byte(`{"results":[]}`)) })) defer srv.Close() @@ -60,7 +66,7 @@ func TestRunBrainQuery_Limit(t *testing.T) { 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) + assert.Equal(t, 12, gotLimit) } func TestRunBrainQuery_MissingTopic(t *testing.T) { diff --git a/cmd/hyperguild/http.go b/cmd/hyperguild/http.go index 36fefda..6dc1f3a 100644 --- a/cmd/hyperguild/http.go +++ b/cmd/hyperguild/http.go @@ -7,9 +7,7 @@ import ( "fmt" "io" "net/http" - "net/url" "os" - "strconv" "time" ) @@ -49,23 +47,29 @@ type QueryResult struct { } func (c *brainClient) Query(ctx context.Context, topic string, limit int) (*QueryResult, error) { - q := url.Values{} - q.Set("q", topic) - q.Set("limit", strconv.Itoa(limit)) - u := c.baseURL + "/query?" + q.Encode() + payload, err := json.Marshal(struct { + Query string `json:"query"` + Limit int `json:"limit"` + }{Query: topic, Limit: limit}) + if err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + u := c.baseURL + "/query" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("build request: %w", err) } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) if err != nil { - return nil, fmt.Errorf("brain GET /query: %w", err) + return nil, fmt.Errorf("brain POST /query: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("brain GET /query: status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("brain POST /query: status %d: %s", resp.StatusCode, string(body)) } var out QueryResult if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { diff --git a/cmd/hyperguild/http_test.go b/cmd/hyperguild/http_test.go index c697dc6..d3c77d9 100644 --- a/cmd/hyperguild/http_test.go +++ b/cmd/hyperguild/http_test.go @@ -15,9 +15,18 @@ import ( func TestBrainClient_Query_Success(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) assert.Equal(t, "/query", r.URL.Path) - assert.Equal(t, "find-h", r.URL.Query().Get("q")) - assert.Equal(t, "3", r.URL.Query().Get("limit")) + + body, _ := io.ReadAll(r.Body) + var got struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + require.NoError(t, json.Unmarshal(body, &got)) + assert.Equal(t, "find-h", got.Query) + assert.Equal(t, 3, got.Limit) + w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"results":[{"path":"knowledge/x.md","title":"x","excerpt":"...","score":7}]}`)) })) From ab4cfaaeb70114e403383daabd237cbd414b6fce Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Sun, 3 May 2026 22:06:33 +0200 Subject: [PATCH 9/9] fix(hyperguild): remove redundant subcommand prefixes from error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch() already prefixes errors with 'hyperguild : ', so handlers re-prefixing with their own name produced stuttered output like 'hyperguild brain: brain query: topic required'. Strip the redundant prefixes from the seven affected errors.New / fmt.Errorf calls in brain.go and mode.go. Also fix the LITELLM_BASE_URL usage text — it's optional (empty falls through to airplane tier), not required, matching the README. --- cmd/hyperguild/brain.go | 8 ++++---- cmd/hyperguild/main.go | 2 +- cmd/hyperguild/mode.go | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/hyperguild/brain.go b/cmd/hyperguild/brain.go index 0dbf76d..d9d1aab 100644 --- a/cmd/hyperguild/brain.go +++ b/cmd/hyperguild/brain.go @@ -11,7 +11,7 @@ import ( 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)") + return errors.New("subcommand required (query|write)") } switch args[0] { case "query": @@ -19,7 +19,7 @@ func runBrain(ctx context.Context, args []string, stdin io.Reader, stdout, stder case "write": return runBrainWrite(ctx, args[1:], stdin, stdout, stderr) default: - return fmt.Errorf("brain: unknown subcommand: %s (expected query|write)", args[0]) + return fmt.Errorf("unknown subcommand: %s (expected query|write)", args[0]) } } @@ -32,7 +32,7 @@ func runBrainQuery(ctx context.Context, args []string, _ io.Reader, stdout, stde return fmt.Errorf("parse flags: %w", err) } if fs.NArg() < 1 { - return errors.New("brain query: topic required") + return errors.New("topic required") } topic := fs.Arg(0) @@ -59,7 +59,7 @@ func runBrainWrite(ctx context.Context, args []string, stdin io.Reader, stdout, return fmt.Errorf("parse flags: %w", err) } if fs.NArg() < 2 { - return errors.New("brain write: type and slug required (e.g. brain write knowledge my-slug)") + return errors.New("type and slug required (e.g. brain write knowledge my-slug)") } kind := fs.Arg(0) slug := fs.Arg(1) diff --git a/cmd/hyperguild/main.go b/cmd/hyperguild/main.go index 3377c6f..5ac393f 100644 --- a/cmd/hyperguild/main.go +++ b/cmd/hyperguild/main.go @@ -39,7 +39,7 @@ Environment: ANTHROPIC_PROBE_URL Tier probe URL for the Anthropic API. Default: https://api.anthropic.com LITELLM_BASE_URL Tier probe URL for the LiteLLM gateway. - Required for tier probe; no default. + Optional; if empty, falls through to airplane tier. ` // dispatch routes args to a subcommand and returns the process exit code. diff --git a/cmd/hyperguild/mode.go b/cmd/hyperguild/mode.go index f25cbcc..db4a583 100644 --- a/cmd/hyperguild/mode.go +++ b/cmd/hyperguild/mode.go @@ -18,7 +18,7 @@ func runMode(ctx context.Context, args []string, _ io.Reader, stdout, stderr io. // Pull the first positional (mode name) out so flags after it still parse // with stdlib flag (which stops at the first non-flag arg). if len(args) < 1 { - return errors.New("mode: name required (cloud|client-local|sovereign)") + return errors.New("name required (cloud|client-local|sovereign)") } name := args[0] if err := fs.Parse(args[1:]); err != nil { @@ -39,12 +39,12 @@ func runMode(ctx context.Context, args []string, _ io.Reader, stdout, stderr io. case "sovereign": doc = modeSovereign(brainURL) default: - return fmt.Errorf("mode: unknown mode: %s (expected cloud|client-local|sovereign)", name) + return fmt.Errorf("unknown mode: %s (expected cloud|client-local|sovereign)", name) } if !*force { if _, err := os.Stat(*out); err == nil { - return fmt.Errorf("mode: %s exists (use --force to overwrite)", *out) + return fmt.Errorf("%s exists (use --force to overwrite)", *out) } }