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 diff --git a/cmd/hyperguild/brain.go b/cmd/hyperguild/brain.go new file mode 100644 index 0000000..d9d1aab --- /dev/null +++ b/cmd/hyperguild/brain.go @@ -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 +} diff --git a/cmd/hyperguild/brain_test.go b/cmd/hyperguild/brain_test.go new file mode 100644 index 0000000..08f687d --- /dev/null +++ b/cmd/hyperguild/brain_test.go @@ -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") +} diff --git a/cmd/hyperguild/http.go b/cmd/hyperguild/http.go new file mode 100644 index 0000000..6dc1f3a --- /dev/null +++ b/cmd/hyperguild/http.go @@ -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 +} diff --git a/cmd/hyperguild/http_test.go b/cmd/hyperguild/http_test.go new file mode 100644 index 0000000..d3c77d9 --- /dev/null +++ b/cmd/hyperguild/http_test.go @@ -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) +} diff --git a/cmd/hyperguild/main.go b/cmd/hyperguild/main.go new file mode 100644 index 0000000..5ac393f --- /dev/null +++ b/cmd/hyperguild/main.go @@ -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 [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. + 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)) +} diff --git a/cmd/hyperguild/main_test.go b/cmd/hyperguild/main_test.go new file mode 100644 index 0000000..c93eb1e --- /dev/null +++ b/cmd/hyperguild/main_test.go @@ -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") +} diff --git a/cmd/hyperguild/mode.go b/cmd/hyperguild/mode.go new file mode 100644 index 0000000..db4a583 --- /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("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", + }, + }, + } +} 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") +} 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) +}