Compare commits
13 Commits
d44427e71f
...
b3b1fde825
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3b1fde825 | ||
|
|
ab4cfaaeb7 | ||
|
|
eb844edb29 | ||
|
|
317ec20392 | ||
|
|
eab8775f5f | ||
|
|
a0d0914a85 | ||
|
|
8f9642df69 | ||
|
|
cd5f3c0175 | ||
|
|
ed4966927c | ||
|
|
3c4e8e8bb8 | ||
|
|
5c88eff46f | ||
|
|
646a86f2c3 | ||
|
|
adf0504116 |
16
Taskfile.yml
16
Taskfile.yml
@@ -39,6 +39,22 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- go run ./cmd/supervisor
|
- 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:
|
ingestion:dev:
|
||||||
desc: Run ingestion server in development mode
|
desc: Run ingestion server in development mode
|
||||||
dir: ingestion
|
dir: ingestion
|
||||||
|
|||||||
110
cmd/hyperguild/README.md
Normal file
110
cmd/hyperguild/README.md
Normal file
@@ -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 <topic>`
|
||||||
|
|
||||||
|
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 <type> <slug>`
|
||||||
|
|
||||||
|
Reads markdown from stdin, writes a knowledge entry.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ cat <<EOF | hyperguild brain write knowledge example-lesson
|
||||||
|
# Example lesson
|
||||||
|
|
||||||
|
## Lesson
|
||||||
|
...
|
||||||
|
EOF
|
||||||
|
knowledge/example-lesson.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### `hyperguild mode <cloud|client-local|sovereign>`
|
||||||
|
|
||||||
|
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
|
||||||
73
cmd/hyperguild/brain.go
Normal file
73
cmd/hyperguild/brain.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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("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("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("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
|
||||||
|
}
|
||||||
|
|
||||||
|
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("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
|
||||||
|
}
|
||||||
156
cmd/hyperguild/brain_test.go
Normal file
156
cmd/hyperguild/brain_test.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"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 := -1
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
121
cmd/hyperguild/http.go
Normal file
121
cmd/hyperguild/http.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 POST /query: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
97
cmd/hyperguild/http_test.go
Normal file
97
cmd/hyperguild/http_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "/query", r.URL.Path)
|
||||||
|
|
||||||
|
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}]}`))
|
||||||
|
}))
|
||||||
|
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)
|
||||||
|
}
|
||||||
71
cmd/hyperguild/main.go
Normal file
71
cmd/hyperguild/main.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// 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"
|
||||||
|
"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
|
||||||
|
|
||||||
|
func subcommands() map[string]subcommand {
|
||||||
|
return map[string]subcommand{
|
||||||
|
"tier": runTier,
|
||||||
|
"brain": runBrain,
|
||||||
|
"mode": runMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = `Usage: hyperguild <subcommand> [options]
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
tier Probe Anthropic + LiteLLM, print current operating tier.
|
||||||
|
brain query <q> BM25 search the brain (HTTP REST).
|
||||||
|
brain write <t> <s>
|
||||||
|
Write stdin as a knowledge entry of type <t>, slug <s>.
|
||||||
|
mode <name> 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.
|
||||||
|
Optional; if empty, falls through to airplane tier.
|
||||||
|
`
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
45
cmd/hyperguild/main_test.go
Normal file
45
cmd/hyperguild/main_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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_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(), "name required")
|
||||||
|
assert.NotContains(t, errBuf.String(), "unknown subcommand")
|
||||||
|
}
|
||||||
99
cmd/hyperguild/mode.go
Normal file
99
cmd/hyperguild/mode.go
Normal file
@@ -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("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("unknown mode: %s (expected cloud|client-local|sovereign)", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*force {
|
||||||
|
if _, err := os.Stat(*out); err == nil {
|
||||||
|
return fmt.Errorf("%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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
123
cmd/hyperguild/mode_test.go
Normal file
123
cmd/hyperguild/mode_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
42
cmd/hyperguild/tier.go
Normal file
42
cmd/hyperguild/tier.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
77
cmd/hyperguild/tier_test.go
Normal file
77
cmd/hyperguild/tier_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
79
docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md
Normal file
79
docs/superpowers/specs/2026-05-03-hyperguild-cli-design.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Spec: hyperguild CLI
|
||||||
|
|
||||||
|
> Plan 4 of 7 — Hyperguild Skill Migration. Loaded after `feature-spec` skill.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Three needs converge on a single small Go binary:
|
||||||
|
|
||||||
|
1. **Tier probing as MCP is overkill.** The supervisor's `tier` MCP runs on `koala:30320` and answers a one-shot question (which models are reachable right now?). Pulling Claude Code through MCP startup, tool listing, and a JSON-RPC call for a 2-second probe is wasteful and adds a network hop the answer doesn't need.
|
||||||
|
2. **Brain access from shell scripts has no good front door.** The brain's HTTP REST API exists (Plan 1) at `koala:3300` for non-MCP clients, but every shell script that wants to query or write to the brain re-implements the curl invocation. A CLI gives shell pipelines, ad-hoc agent prompts, and quick-debug scenarios a stable interface.
|
||||||
|
3. **Mode bootstrap is manual.** Each new project that wants to operate in a chosen mode (cloud / client-local / sovereign) needs a `.mcp.json` written by hand. Without automation, mode adoption is gated on remembering the right MCP server URLs.
|
||||||
|
|
||||||
|
**Why now:** Plans 1–3 are merged. The CLI is the next building block in shrinking the supervisor pod toward a thin Mode-2 routing layer. Plans 5 and 6 build on the CLI's tier and brain helpers.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] `hyperguild tier` returns the same `tier.Info` that `internal/tier.Detect` produces for the same probe URLs, in < 3 s under all three tier conditions, with both human-readable and `--json` output.
|
||||||
|
- [ ] `hyperguild brain query <topic>` returns BM25 results from the brain HTTP REST `/query` endpoint, exit 0 on success and non-zero on transport failure.
|
||||||
|
- [ ] `hyperguild brain write <type> <slug>` reads markdown content from stdin, posts to `/write` with the type and slug, and creates `brain/knowledge/<slug>.md`. A round-trip (`hyperguild brain query <slug>` immediately after) finds the entry.
|
||||||
|
- [ ] `hyperguild mode <cloud|client-local|sovereign>` writes a parseable JSON file at the target path with the per-mode `mcpServers` entries; `jq -e .mcpServers` succeeds on the output.
|
||||||
|
- [ ] All commands print usage on `--help`, exit 2 on unknown flags, exit non-zero on operational errors.
|
||||||
|
- [ ] `task check` passes (lint + test + vet) on each task and on the merged branch.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- **Stdlib only.** No `cobra`, `urfave/cli`, `viper`, etc. CLI router and flag parsing use `flag.NewFlagSet`.
|
||||||
|
- **Go 1.26.1**, project default.
|
||||||
|
- **Module:** `github.com/mathiasbq/supervisor`, peer to `cmd/supervisor/`. New code at `cmd/hyperguild/`. The module name keeps its historical `supervisor` value — renaming the module is out of scope and would touch every import.
|
||||||
|
- **Reuse `internal/tier`** unchanged. The CLI is a thin wrapper around `tier.Detect`.
|
||||||
|
- **Brain endpoint configurable** via `BRAIN_URL` env var (default `http://koala:30330` — Tailscale-exposed NodePort, both MCP at `/mcp` and HTTP REST at `/query`, `/write`, etc., share the port). No hostname literals embedded in the CLI body — sourced from env per the existing "logical-addresses-in-instructions" memory.
|
||||||
|
- **Test discipline:** table-driven, testify, fakes for HTTP and tier probing. No live network in tests.
|
||||||
|
- **Errors:** wrapped via `fmt.Errorf("op: %w", err)`. No naked returns. Stderr for errors, stdout for results.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- The Mode 6 routing pod itself — `mode client-local` writes a placeholder entry pointing at the future routing URL with a `_routing_pending` annotation; the CLI does not provision the pod.
|
||||||
|
- Pass-rate logging (Plan 5) — the CLI's `brain write` does not emit `session_log` events.
|
||||||
|
- Skill worker CLIs (`hyperguild tdd_red`, `hyperguild review`, etc.) — those stay on the supervisor MCP until Plan 7.
|
||||||
|
- Brain HTTP server changes — the REST endpoints already exist.
|
||||||
|
- Authentication / TLS — Tailscale provides network isolation; no auth currently.
|
||||||
|
- Windows/Linux binaries — macOS-only per the user's setup. `go build` is portable but no cross-compilation in CI.
|
||||||
|
- A `crush` config writer for Mode 3 — Mode 3 (sovereign) writes a Claude-Code-compatible `.mcp.json` with brain-only MCP, on the assumption that even Crush-primary users may fall back to Claude Code with brain access. Crush's own config is owned by the user manually.
|
||||||
|
- A unified `--config` file for the CLI — env var + flags is enough today.
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
- **Single binary, inline subcommand router.** `cmd/hyperguild/main.go` dispatches on `os.Args[1]` to per-subcommand functions, each owning its own `flag.NewFlagSet`. Rationale: 4 top-level subcommands (`tier`, `brain`, `mode`, plus `--help`) and one nested level (`brain query`, `brain write`); ~80 lines of routing plumbing in stdlib beats pulling cobra's ~3 KLOC of dependencies for a tiny CLI. The router is testable by injecting `args []string` instead of reading `os.Args` directly.
|
||||||
|
|
||||||
|
- **`tier` subcommand reuses `internal/tier.Detect` verbatim.** Probe URLs (`https://api.anthropic.com` and the LiteLLM base URL) come from environment: `ANTHROPIC_PROBE_URL` (default the literal Anthropic URL) and `LITELLM_BASE_URL` (no default — error if `--mode-needs-llm` and unset). Rationale: matching the supervisor's existing wiring means the CLI cannot disagree with the supervisor about tier; a single source of truth.
|
||||||
|
|
||||||
|
- **`brain` subcommand calls the HTTP REST API.** Two nested subcommands:
|
||||||
|
- `brain query <topic>` issues `POST /query` with JSON body `{query, limit}` (default `--limit 5`), prints results in human-readable form by default and with `--json` for machine consumption.
|
||||||
|
- `brain write <type> <slug>` reads stdin, posts `POST /write` with JSON body `{type, slug, content}`, prints the resulting path on success.
|
||||||
|
Rationale: HTTP REST is simpler than MCP framing for a CLI. Per CLAUDE.md, the REST endpoints are documented as the official non-MCP interface.
|
||||||
|
|
||||||
|
- **`mode <name>` writes a per-mode `.mcp.json` template.** Defaults to writing `./.mcp.json` (cwd); accepts `--out <path>`. Per-mode bodies:
|
||||||
|
- `cloud` — `mcpServers` contains only `brain` at `http://koala:30330/mcp`.
|
||||||
|
- `client-local` — `mcpServers` contains `brain` at `http://koala:30330/mcp` and a `routing` placeholder entry with `url` set to a marker (`http://koala:30310/mcp`) and an extra field `"_routing_pending": "Plan 6 — routing pod not deployed yet"`. Rationale: keeping strict-JSON parseable means using a placeholder field rather than a JSON comment, which the spec parser would reject.
|
||||||
|
- `sovereign` — `mcpServers` contains only `brain`, plus a top-level `"_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback."`.
|
||||||
|
All three are valid JSON and all three round-trip through `jq` for verification.
|
||||||
|
Rationale: a single subcommand with three clearly-different outputs is easier to evolve than three nearly-duplicate subcommands. The placeholder fields are intentional documentation in the file itself, which the user actually opens and edits.
|
||||||
|
|
||||||
|
- **No global state.** Each subcommand is a function `(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error`, allowing table-driven tests to exercise full subcommand flows without `os.Exit` or fd capture.
|
||||||
|
|
||||||
|
- **HTTP client injection.** A package-level `http.Client` with 5s timeout for `brain` calls, overridable in tests via a constructor. Real client for `main`, `httptest.Server` for tests.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`.mcp.json` schema may evolve.** Claude Code's MCP config format is defined by the harness, and Anthropic could change it. Mitigation: document the format in the CLI's `--help` text and in the spec; if it breaks, the fix is local to one template function.
|
||||||
|
|
||||||
|
- **Brain endpoint hostname drift.** If the brain moves off `koala`, the env-var override avoids breaking the CLI but the `mode` template's hardcoded `koala:30330` becomes stale. Mitigation: source the URL in the `mode` template from the same env var (`BRAIN_URL`) so all three subcommands stay in lockstep with the user's actual environment.
|
||||||
|
|
||||||
|
- **`tier` probe URL gap.** The CLI inherits the supervisor's hardcoded `https://api.anthropic.com` probe URL via `internal/tier`. If Anthropic changes the URL, both supervisor and CLI break together. Mitigation: env-var override `ANTHROPIC_PROBE_URL`; default unchanged.
|
||||||
|
|
||||||
|
- **No HTTP retry logic.** The CLI returns first-error to the user. For ad-hoc shell use this is fine; for automation a future `--retry` flag may be needed. Out of scope for this iteration.
|
||||||
|
|
||||||
|
- **Tests don't cover live network.** Pure-fake tests catch regression but not "does the brain pod actually answer." Mitigation: add a smoke-test `task hyperguild:smoke` in a follow-up that runs against the real brain — separate concern, not in Plan 4.
|
||||||
|
|
||||||
|
- **Mode 3 sovereign output may surprise users** who expect Mode 3 to skip writing a `.mcp.json` entirely (since Crush is the primary harness). Mitigation: the `_mode_note` field explains the choice; the `--out /dev/null` escape hatch lets users skip the write if they want.
|
||||||
Reference in New Issue
Block a user