From 0106ab7f6543a5f201af42ca93f5dcafe2cbd5da Mon Sep 17 00:00:00 2001 From: 5uck1ess Date: Tue, 7 Jul 2026 15:33:47 -0400 Subject: [PATCH] feat: add agy runner and devkit_ask bridge tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire Claude, Codex, and agy (Google Antigravity CLI) into a direct, commutative bridge instead of the parallel fan-out model. - runners/agy.go: new AgyRunner (stdin-piped one-shot, mirrors CodexRunner) with AGY_CMD / AGY_ARGS env overrides since agy's headless flags are not yet verified. Registered in DetectRunners; added to --agent choices. - mcp: new devkit_ask tool — {to: claude|codex|agy, prompt, workdir?} resolves the peer via FindRunner and returns its answer synchronously, bounded by commandTimeout. Any MCP host (Claude, Codex) can ask any peer and block for the reply. - Tests: agy_test.go (name, argv builder incl. AGY_ARGS/{workdir}, AGY_CMD, Available) and ask_test.go (missing-arg, unknown-peer, unavailable-peer). Note: agy's exact non-interactive argv is a documented TODO(agy) placeholder; agy is reachable as a target now, and works once the argv is probed or AGY_ARGS is set. agy-initiated calls depend on agy supporting MCP-client attach (TBD). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01W7VdbGfPkkce5UGQqjeSE9 --- src/cmd/root.go | 4 +- src/cmd/root_test.go | 2 +- src/mcp/ask_test.go | 55 +++++++++++++++++++ src/mcp/server.go | 3 ++ src/mcp/tools.go | 59 +++++++++++++++++++++ src/runners/agy.go | 113 ++++++++++++++++++++++++++++++++++++++++ src/runners/agy_test.go | 76 +++++++++++++++++++++++++++ src/runners/runner.go | 1 + 8 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 src/mcp/ask_test.go create mode 100644 src/runners/agy.go create mode 100644 src/runners/agy_test.go diff --git a/src/cmd/root.go b/src/cmd/root.go index 1d9c243..0ca1d29 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -56,7 +56,7 @@ var rootCmd = &cobra.Command{ } func init() { - rootCmd.PersistentFlags().String("agent", "claude", "AI agent to use (claude, codex, gemini)") + rootCmd.PersistentFlags().String("agent", "claude", "AI agent to use (claude, codex, gemini, agy)") } func Execute() error { @@ -90,7 +90,7 @@ func resolveRunnerFrom(name string, available []runners.Runner) (runners.Runner, names = append(names, a.Name()) } if len(names) == 0 { - return nil, fmt.Errorf("no AI agents found in PATH — install claude, codex, or gemini") + return nil, fmt.Errorf("no AI agents found in PATH — install claude, codex, gemini, or agy") } return nil, fmt.Errorf("agent %q not found — available: %s", name, strings.Join(names, ", ")) } diff --git a/src/cmd/root_test.go b/src/cmd/root_test.go index 5b57905..a55dc12 100644 --- a/src/cmd/root_test.go +++ b/src/cmd/root_test.go @@ -66,7 +66,7 @@ func TestResolveRunnerFrom_NoAgents(t *testing.T) { if err == nil { t.Fatal("expected error when no agents available") } - if got := err.Error(); got != "no AI agents found in PATH — install claude, codex, or gemini" { + if got := err.Error(); got != "no AI agents found in PATH — install claude, codex, gemini, or agy" { t.Errorf("unexpected error: %s", got) } } diff --git a/src/mcp/ask_test.go b/src/mcp/ask_test.go new file mode 100644 index 0000000..0f5d32b --- /dev/null +++ b/src/mcp/ask_test.go @@ -0,0 +1,55 @@ +package mcp + +import ( + "strings" + "testing" +) + +func TestAskMissingArgs(t *testing.T) { + srv := newTestServer(t, t.TempDir(), t.TempDir()) + _, handler := srv.askTool() + + out, isErr := callToolHandler(t, handler, map[string]interface{}{}) + if !isErr { + t.Fatalf("expected IsError=true for missing args, got: %s", out) + } + if !strings.Contains(out, "missing argument") { + t.Errorf("expected 'missing argument' in output, got: %s", out) + } +} + +func TestAskUnknownPeer(t *testing.T) { + srv := newTestServer(t, t.TempDir(), t.TempDir()) + _, handler := srv.askTool() + + out, isErr := callToolHandler(t, handler, map[string]interface{}{ + "to": "gemini", + "prompt": "hi", + }) + if !isErr { + t.Fatalf("expected IsError=true for unknown peer, got: %s", out) + } + if !strings.Contains(out, "unknown peer") { + t.Errorf("expected 'unknown peer' in output, got: %s", out) + } +} + +func TestAskPeerUnavailable(t *testing.T) { + // Force the agy binary to be undiscoverable so DetectRunners filters it + // out and FindRunner returns nil — deterministic regardless of host. + t.Setenv("AGY_CMD", "definitely-not-a-real-binary-xyz123") + + srv := newTestServer(t, t.TempDir(), t.TempDir()) + _, handler := srv.askTool() + + out, isErr := callToolHandler(t, handler, map[string]interface{}{ + "to": "agy", + "prompt": "hi", + }) + if !isErr { + t.Fatalf("expected IsError=true for unavailable peer, got: %s", out) + } + if !strings.Contains(out, "not available") { + t.Errorf("expected 'not available' in output, got: %s", out) + } +} diff --git a/src/mcp/server.go b/src/mcp/server.go index ce51e97..b85f928 100644 --- a/src/mcp/server.go +++ b/src/mcp/server.go @@ -83,6 +83,9 @@ func (s *Server) Serve(ctx context.Context) error { tool, handler = s.listTool() srv.AddTool(tool, handler) + tool, handler = s.askTool() + srv.AddTool(tool, handler) + stdio := mcpgo.NewStdioServer(srv) return stdio.Listen(ctx, os.Stdin, os.Stdout) } diff --git a/src/mcp/tools.go b/src/mcp/tools.go index e0aa926..b7a685b 100644 --- a/src/mcp/tools.go +++ b/src/mcp/tools.go @@ -13,6 +13,7 @@ import ( "github.com/5uck1ess/devkit/engine" "github.com/5uck1ess/devkit/lib" + "github.com/5uck1ess/devkit/runners" mcpmcp "github.com/mark3labs/mcp-go/mcp" mcpgo "github.com/mark3labs/mcp-go/server" ) @@ -711,3 +712,61 @@ func (s *Server) advancePastLoop(wf *engine.Workflow, state *lib.SessionState) ( response := s.formatStepResponse(wf, state, &nextStep, state.Input) return mcpmcp.NewToolResultText(response), nil } + +func (s *Server) askTool() (mcpmcp.Tool, mcpgo.ToolHandlerFunc) { + tool := mcpmcp.NewTool("devkit_ask", + mcpmcp.WithDescription("Send a prompt to a peer AI CLI (claude, codex, or agy) and return its answer synchronously. The direct, commutative bridge primitive: any MCP host can ask any peer and block for the reply."), + mcpmcp.WithString("to", mcpmcp.Required(), mcpmcp.Description("Target peer: claude, codex, or agy")), + mcpmcp.WithString("prompt", mcpmcp.Required(), mcpmcp.Description("The prompt to send to the peer")), + mcpmcp.WithString("workdir", mcpmcp.Description("Working directory for the peer (optional)")), + ) + return tool, func(ctx context.Context, req mcpmcp.CallToolRequest) (*mcpmcp.CallToolResult, error) { + to, err := req.RequireString("to") + if err != nil { + return mcpmcp.NewToolResultError(fmt.Sprintf("missing argument: %v", err)), nil + } + prompt, err := req.RequireString("prompt") + if err != nil { + return mcpmcp.NewToolResultError(fmt.Sprintf("missing argument: %v", err)), nil + } + workdir := "" + if args := req.GetArguments(); args != nil { + if w, ok := args["workdir"].(string); ok { + workdir = w + } + } + + to = strings.ToLower(strings.TrimSpace(to)) + // Restrict to the three bridge peers so devkit_ask stays an explicit + // surface and cannot reach gemini/local via this primitive. + switch to { + case "claude", "codex", "agy": + default: + return mcpmcp.NewToolResultError(fmt.Sprintf("unknown peer %q: want claude, codex, or agy", to)), nil + } + + peer := runners.FindRunner(to, runners.DetectRunners()) + if peer == nil { + return mcpmcp.NewToolResultError(fmt.Sprintf("peer %q is not available — check it is installed and on PATH", to)), nil + } + + // Bound the synchronous call so a wedged peer cannot hang the MCP + // request forever; parent cancellation still propagates. + ctx, cancel := context.WithTimeout(ctx, commandTimeout) + defer cancel() + + res, runErr := peer.Run(ctx, prompt, runners.RunOpts{WorkDir: workdir}) + if runErr != nil { + // Surface exit code + whatever the peer produced so the caller + // sees the real failure, not just the Go wrapper message. + detail := res.Output + if detail == "" { + detail = runErr.Error() + } else { + detail = detail + "\n\n[error] " + runErr.Error() + } + return mcpmcp.NewToolResultError(fmt.Sprintf("%s exited %d: %s", to, res.ExitCode, detail)), nil + } + return mcpmcp.NewToolResultText(res.Output), nil + } +} diff --git a/src/runners/agy.go b/src/runners/agy.go new file mode 100644 index 0000000..d1714a9 --- /dev/null +++ b/src/runners/agy.go @@ -0,0 +1,113 @@ +package runners + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" +) + +// AgyRunner dispatches to the Google Antigravity CLI ("agy") for a single +// headless request/response. It mirrors CodexRunner: the prompt is piped via +// stdin, stdout is buffered and returned verbatim, and a non-zero exit is +// surfaced with truncated stderr. +// +// The agy CLI's non-interactive invocation is NOT verified in this repo (agy +// is not installed on the build host). Both the binary name and the argv are +// therefore overridable via environment so an operator can wire in the real +// flags without recompiling: +// +// AGY_CMD — binary name/path (default: "agy"), used by both Available() +// and Run(). +// AGY_ARGS — space-separated argv template that REPLACES the built-in +// default. Use the literal token {workdir} where the working +// directory should be substituted; the token is dropped when no +// WorkDir is set. cmd.Dir is still applied from WorkDir +// regardless. Example: +// AGY_ARGS="exec --cwd {workdir} -" +type AgyRunner struct{} + +func (r *AgyRunner) Name() string { return "agy" } + +// agyCmd resolves the agy binary name (AGY_CMD override, default "agy"). +func agyCmd() string { return envDefault("AGY_CMD", "agy") } + +func (r *AgyRunner) Available() bool { + _, err := exec.LookPath(agyCmd()) + return err == nil +} + +// agyExecArgs builds the argv for a one-shot, stdin-fed request. +// +// TODO(agy): the default below is a PLACEHOLDER modeled on the codex shape +// (`agy exec [-C dir] -`, prompt on stdin). It has NOT been validated against +// a real agy CLI. Before relying on agy as a bridge target, run these probes +// on a host with agy installed and either update this default or set AGY_ARGS: +// +// agy --version +// agy --help # find the non-interactive / exec / run subcommand +// agy exec --help # or `agy run --help`: how to read the prompt from +// # stdin, set the working dir, force headless mode, and +// # emit plain (non-streaming) text +// agy models # confirm the default model / whether --model is required +func agyExecArgs(workDir string) []string { + if raw := strings.TrimSpace(os.Getenv("AGY_ARGS")); raw != "" { + fields := strings.Fields(raw) + args := make([]string, 0, len(fields)) + for _, f := range fields { + if f == "{workdir}" { + if workDir != "" { + args = append(args, workDir) + } + continue + } + args = append(args, f) + } + return args + } + // TODO(agy): PLACEHOLDER default — verify against `agy --help`. + args := []string{"exec"} + if workDir != "" { + args = append(args, "-C", workDir) + } + return append(args, "-") +} + +func (r *AgyRunner) Run(ctx context.Context, prompt string, opts RunOpts) (RunResult, error) { + // `agy` reads the prompt from stdin (via the trailing "-"); piping avoids + // arg-length limits on large prompts/diffs, matching codex/gemini. + cmd := exec.CommandContext(ctx, agyCmd(), agyExecArgs(opts.WorkDir)...) + if opts.WorkDir != "" { + cmd.Dir = opts.WorkDir + } + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return RunResult{ExitCode: 1}, fmt.Errorf("agy failed to run: %w", err) + } + } + + result := RunResult{ + Output: stdout.String(), + ExitCode: exitCode, + } + if exitCode != 0 { + errMsg := stderr.String() + if errMsg == "" { + errMsg = stdout.String() + } + return result, fmt.Errorf("agy exited %d: %s", exitCode, TruncStr(errMsg, 200)) + } + return result, nil +} diff --git a/src/runners/agy_test.go b/src/runners/agy_test.go new file mode 100644 index 0000000..b82e086 --- /dev/null +++ b/src/runners/agy_test.go @@ -0,0 +1,76 @@ +package runners + +import "testing" + +func TestAgyRunnerName(t *testing.T) { + r := &AgyRunner{} + if r.Name() != "agy" { + t.Errorf("got %q, want %q", r.Name(), "agy") + } +} + +func TestAgyExecArgs(t *testing.T) { + tests := []struct { + name string + agyArgs string + workDir string + want []string + }{ + { + name: "default no workdir", + want: []string{"exec", "-"}, + }, + { + name: "default with workdir", + workDir: "/tmp/repo", + want: []string{"exec", "-C", "/tmp/repo", "-"}, + }, + { + name: "AGY_ARGS override with workdir token", + agyArgs: "run --cwd {workdir} -", + workDir: "/tmp/repo", + want: []string{"run", "--cwd", "/tmp/repo", "-"}, + }, + { + name: "AGY_ARGS override drops workdir token when unset", + agyArgs: "run --cwd {workdir} -", + want: []string{"run", "--cwd", "-"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // t.Setenv with "" forces the default branch (TrimSpace == ""). + t.Setenv("AGY_ARGS", tt.agyArgs) + got := agyExecArgs(tt.workDir) + if len(got) != len(tt.want) { + t.Fatalf("len = %d, want %d (%v)", len(got), len(tt.want), got) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("arg[%d] = %q, want %q (all args: %v)", i, got[i], tt.want[i], got) + } + } + }) + } +} + +func TestAgyCmdOverride(t *testing.T) { + t.Setenv("AGY_CMD", "my-custom-agy") + if got := agyCmd(); got != "my-custom-agy" { + t.Errorf("agyCmd() = %q, want %q", got, "my-custom-agy") + } + // Empty override falls back to the default binary name. + t.Setenv("AGY_CMD", "") + if got := agyCmd(); got != "agy" { + t.Errorf("agyCmd() default = %q, want %q", got, "agy") + } +} + +func TestAgyAvailableFalseWhenBinaryMissing(t *testing.T) { + t.Setenv("AGY_CMD", "definitely-not-a-real-binary-xyz123") + r := &AgyRunner{} + if r.Available() { + t.Error("Available() = true for a nonexistent binary, want false") + } +} diff --git a/src/runners/runner.go b/src/runners/runner.go index 74b7ac3..2b84855 100644 --- a/src/runners/runner.go +++ b/src/runners/runner.go @@ -30,6 +30,7 @@ func DetectRunners() []Runner { &ClaudeRunner{}, &CodexRunner{}, &GeminiRunner{}, + &AgyRunner{}, &LocalRunner{}, } var available []Runner