feat(hyperguild): mode subcommand

'hyperguild mode <cloud|client-local|sovereign>' 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.
This commit is contained in:
Mathias Bergqvist
2026-05-03 21:50:05 +02:00
parent 8f9642df69
commit a0d0914a85
4 changed files with 229 additions and 11 deletions

View File

@@ -4,7 +4,6 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -16,18 +15,11 @@ import (
// touching os.Stdin / os.Stdout / os.Exit. // touching os.Stdin / os.Stdout / os.Exit.
type subcommand func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error 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 { func subcommands() map[string]subcommand {
return map[string]subcommand{ return map[string]subcommand{
"tier": runTier, "tier": runTier,
"brain": runBrain, "brain": runBrain,
"mode": notYet, "mode": runMode,
} }
} }

View File

@@ -33,9 +33,13 @@ func TestDispatch_UnknownSubcommand_ReturnsTwo(t *testing.T) {
assert.Contains(t, errBuf.String(), "unknown subcommand: bogus") 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 var out, errBuf bytes.Buffer
code := dispatch(context.Background(), []string{"mode"}, strings.NewReader(""), &out, &errBuf) code := dispatch(context.Background(), []string{"mode"}, strings.NewReader(""), &out, &errBuf)
assert.Equal(t, 1, code) 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")
} }

99
cmd/hyperguild/mode.go Normal file
View 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("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",
},
},
}
}

123
cmd/hyperguild/mode_test.go Normal file
View 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")
}