diff --git a/cmd/hyperguild/main.go b/cmd/hyperguild/main.go index d4caae8..3377c6f 100644 --- a/cmd/hyperguild/main.go +++ b/cmd/hyperguild/main.go @@ -4,7 +4,6 @@ package main import ( "context" - "errors" "fmt" "io" "os" @@ -16,18 +15,11 @@ import ( // touching os.Stdin / os.Stdout / os.Exit. type subcommand func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error -// errNotImplemented is returned by stub subcommands until their task lands. -var errNotImplemented = errors.New("not implemented") - -func notYet(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { - return errNotImplemented -} - func subcommands() map[string]subcommand { return map[string]subcommand{ "tier": runTier, "brain": runBrain, - "mode": notYet, + "mode": runMode, } } diff --git a/cmd/hyperguild/main_test.go b/cmd/hyperguild/main_test.go index 594a832..c93eb1e 100644 --- a/cmd/hyperguild/main_test.go +++ b/cmd/hyperguild/main_test.go @@ -33,9 +33,13 @@ func TestDispatch_UnknownSubcommand_ReturnsTwo(t *testing.T) { assert.Contains(t, errBuf.String(), "unknown subcommand: bogus") } -func TestDispatch_KnownSubcommand_RoutesAndReturnsExitCode(t *testing.T) { +func TestDispatch_KnownSubcommand_RoutesToHandler(t *testing.T) { + // "mode" without args fails → exit 1, message on stderr. + // (Confirms dispatch reached the handler rather than printing "unknown + // subcommand: mode".) var out, errBuf bytes.Buffer code := dispatch(context.Background(), []string{"mode"}, strings.NewReader(""), &out, &errBuf) assert.Equal(t, 1, code) - assert.Contains(t, errBuf.String(), "not implemented") + assert.Contains(t, errBuf.String(), "name required") + assert.NotContains(t, errBuf.String(), "unknown subcommand") } diff --git a/cmd/hyperguild/mode.go b/cmd/hyperguild/mode.go new file mode 100644 index 0000000..f25cbcc --- /dev/null +++ b/cmd/hyperguild/mode.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" +) + +func runMode(ctx context.Context, args []string, _ io.Reader, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("mode", flag.ContinueOnError) + fs.SetOutput(stderr) + out := fs.String("out", ".mcp.json", "output file path") + force := fs.Bool("force", false, "overwrite an existing file") + // Pull the first positional (mode name) out so flags after it still parse + // with stdlib flag (which stops at the first non-flag arg). + if len(args) < 1 { + return errors.New("mode: name required (cloud|client-local|sovereign)") + } + name := args[0] + if err := fs.Parse(args[1:]); err != nil { + return fmt.Errorf("parse flags: %w", err) + } + + brainURL := os.Getenv("BRAIN_URL") + if brainURL == "" { + brainURL = defaultBrainURL + } + + var doc map[string]any + switch name { + case "cloud": + doc = modeCloud(brainURL) + case "client-local": + doc = modeClientLocal(brainURL) + case "sovereign": + doc = modeSovereign(brainURL) + default: + return fmt.Errorf("mode: unknown mode: %s (expected cloud|client-local|sovereign)", name) + } + + if !*force { + if _, err := os.Stat(*out); err == nil { + return fmt.Errorf("mode: %s exists (use --force to overwrite)", *out) + } + } + + body, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return fmt.Errorf("marshal mode doc: %w", err) + } + if err := os.WriteFile(*out, append(body, '\n'), 0o644); err != nil { + return fmt.Errorf("write %s: %w", *out, err) + } + fmt.Fprintf(stdout, "wrote %s (mode: %s)\n", *out, name) //nolint:errcheck + return nil +} + +func modeCloud(brainURL string) map[string]any { + return map[string]any{ + "mcpServers": map[string]any{ + "brain": map[string]any{ + "url": brainURL + "/mcp", + "description": "Brain MCP — knowledge query, write, ingestion, session log", + }, + }, + } +} + +func modeClientLocal(brainURL string) map[string]any { + return map[string]any{ + "mcpServers": map[string]any{ + "brain": map[string]any{ + "url": brainURL + "/mcp", + "description": "Brain MCP — knowledge query, write, ingestion, session log", + }, + "routing": map[string]any{ + "url": "http://koala:30310/mcp", + "description": "Mode 2 routing pod — routes skill calls to LiteLLM/local", + "_routing_pending": "Plan 6 — routing pod not deployed yet; this URL is a placeholder", + }, + }, + } +} + +func modeSovereign(brainURL string) map[string]any { + return map[string]any{ + "_mode_note": "Sovereign mode primarily uses Crush + LiteLLM. This .mcp.json is provided as Claude Code fallback (e.g. emergency offline editing).", + "mcpServers": map[string]any{ + "brain": map[string]any{ + "url": brainURL + "/mcp", + "description": "Brain MCP — knowledge query, write, ingestion, session log", + }, + }, + } +} diff --git a/cmd/hyperguild/mode_test.go b/cmd/hyperguild/mode_test.go new file mode 100644 index 0000000..43bbc5f --- /dev/null +++ b/cmd/hyperguild/mode_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + var out map[string]any + require.NoError(t, json.Unmarshal(b, &out)) + return out +} + +func TestRunMode_Cloud_Default(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + t.Setenv("BRAIN_URL", "http://koala:30330") + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"cloud", "--out", outPath}, strings.NewReader(""), &stdout, &stderr) + require.NoError(t, err) + + got := readJSON(t, outPath) + servers, ok := got["mcpServers"].(map[string]any) + require.True(t, ok, "mcpServers must be a JSON object") + assert.Contains(t, servers, "brain") + assert.NotContains(t, servers, "routing") + assert.NotContains(t, got, "_mode_note") +} + +func TestRunMode_ClientLocal_HasRoutingPlaceholder(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + t.Setenv("BRAIN_URL", "http://koala:30330") + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"client-local", "--out", outPath}, strings.NewReader(""), &stdout, &stderr) + require.NoError(t, err) + + got := readJSON(t, outPath) + servers := got["mcpServers"].(map[string]any) + require.Contains(t, servers, "brain") + require.Contains(t, servers, "routing") + + routing := servers["routing"].(map[string]any) + assert.Contains(t, routing, "_routing_pending") +} + +func TestRunMode_Sovereign_HasModeNote(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"sovereign", "--out", outPath}, strings.NewReader(""), &stdout, &stderr) + require.NoError(t, err) + + got := readJSON(t, outPath) + assert.Contains(t, got, "_mode_note") + servers := got["mcpServers"].(map[string]any) + assert.Contains(t, servers, "brain") + assert.NotContains(t, servers, "routing") +} + +func TestRunMode_DefaultsOutToCwd(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) // Go 1.24+ — replaces the older os.Chdir-with-cleanup pattern + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"cloud"}, strings.NewReader(""), &stdout, &stderr) + require.NoError(t, err) + _, statErr := os.Stat(filepath.Join(dir, ".mcp.json")) + assert.NoError(t, statErr, ".mcp.json should exist in cwd") +} + +func TestRunMode_UnknownMode(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"bogus", "--out", outPath}, strings.NewReader(""), &stdout, &stderr) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown mode") +} + +func TestRunMode_NoArgs(t *testing.T) { + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{}, strings.NewReader(""), &stdout, &stderr) + assert.Error(t, err) +} + +func TestRunMode_RefusesToOverwrite(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + require.NoError(t, os.WriteFile(outPath, []byte(`{"existing":"file"}`), 0o644)) + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"cloud", "--out", outPath}, strings.NewReader(""), &stdout, &stderr) + require.Error(t, err) + assert.Contains(t, err.Error(), "exists") +} + +func TestRunMode_Force(t *testing.T) { + dir := t.TempDir() + outPath := filepath.Join(dir, ".mcp.json") + require.NoError(t, os.WriteFile(outPath, []byte(`{"existing":"file"}`), 0o644)) + + var stdout, stderr bytes.Buffer + err := runMode(context.Background(), []string{"cloud", "--out", outPath, "--force"}, strings.NewReader(""), &stdout, &stderr) + require.NoError(t, err) + got := readJSON(t, outPath) + assert.Contains(t, got, "mcpServers") + assert.NotContains(t, got, "existing") +}