Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, ", "))
}
Expand Down
2 changes: 1 addition & 1 deletion src/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
55 changes: 55 additions & 0 deletions src/mcp/ask_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions src/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
59 changes: 59 additions & 0 deletions src/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
}
113 changes: 113 additions & 0 deletions src/runners/agy.go
Original file line number Diff line number Diff line change
@@ -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
}
76 changes: 76 additions & 0 deletions src/runners/agy_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions src/runners/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func DetectRunners() []Runner {
&ClaudeRunner{},
&CodexRunner{},
&GeminiRunner{},
&AgyRunner{},
&LocalRunner{},
}
var available []Runner
Expand Down