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:
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
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("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
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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user